@vvedantb/ui 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -12
- package/package.json +8 -1
- package/scripts/build-brand-assets.mjs +126 -0
- package/src/brand/AppMark.tsx +55 -0
- package/src/brand/index.ts +16 -0
- package/src/brand/svg.ts +119 -0
- package/src/index.ts +12 -2
- package/src/marketing/PricingModal.tsx +229 -6
- package/src/marketing/marketing-classes.ts +12 -0
package/README.md
CHANGED
|
@@ -867,20 +867,84 @@ links under a separator.
|
|
|
867
867
|
|
|
868
868
|
### `PricingModal`
|
|
869
869
|
|
|
870
|
-
|
|
871
|
-
`
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
`
|
|
876
|
-
|
|
877
|
-
|
|
870
|
+
Upgrade dialog with two layouts. Without `plans` it is the single-plan dialog
|
|
871
|
+
below. With `plans` (0.22.0) it is the wide multi-plan dialog.
|
|
872
|
+
|
|
873
|
+
Shared props: `open`, `onClose`, `title` (accessible, hidden), `description`
|
|
874
|
+
(hidden), `tone` (`"muted"` default or `"muted-foreground"`),
|
|
875
|
+
`billingPeriod`, `onBillingPeriodChange`, `defaultBillingPeriod` (`"month"`
|
|
876
|
+
default) and `renderCheckout`.
|
|
877
|
+
|
|
878
|
+
#### Multi-plan (0.22.0)
|
|
879
|
+
|
|
880
|
+
Header: `apps` (an `AppName[]`, shown as `AppMark` tiles at the top left),
|
|
881
|
+
`headline` (centre), a Monthly / Annual switch and a close button (top right).
|
|
882
|
+
Then one card per entry of `plans` (2 to 4 fit) and one `finePrint` line. Cards
|
|
883
|
+
stack on narrow screens.
|
|
884
|
+
|
|
885
|
+
A `PricingPlan` is `{ id, name, price: { month, year }, ctaLabel?, intro?,
|
|
886
|
+
highlight?: { title, description }, benefits?: { label, icon?: "check" |
|
|
887
|
+
AppName, muted? }[], current?, featured?, periodSuffix? }`. Prices are display
|
|
888
|
+
strings and always per month (`periodSuffix` default `"/month"`; the yearly
|
|
889
|
+
price is the per-month price). `ctaLabel` defaults to "Get started". `featured`
|
|
890
|
+
(or `highlighted`) adds a subtle ring. A `current` plan shows a disabled,
|
|
891
|
+
outlined "Current plan" button and does not call `renderCheckout`.
|
|
892
|
+
|
|
893
|
+
The `featured` plan's button is a solid primary button (`Button` `variant="default"`). Other plans use `secondary`.
|
|
894
|
+
|
|
895
|
+
`renderCheckout(button, plan, period)` wraps each plan's button.
|
|
896
|
+
|
|
897
|
+
Each app passes its own plan names, prices and copy. The example and the screenshots use placeholder text only.
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
<PricingModal
|
|
901
|
+
open={open}
|
|
902
|
+
onClose={close}
|
|
903
|
+
title="Plans"
|
|
904
|
+
apps={["vibot", "verve", "velth", "vmem", "vhub"]}
|
|
905
|
+
headline="One plan. Every V app."
|
|
906
|
+
finePrint="Cancel anytime. $120 billed yearly. Save 33%."
|
|
907
|
+
plans={[
|
|
908
|
+
{ id: "free", name: "Free", price: { month: "$0", year: "$0" }, current: true },
|
|
909
|
+
{
|
|
910
|
+
id: "pro",
|
|
911
|
+
name: "V Pro",
|
|
912
|
+
price: { month: "$15", year: "$10" },
|
|
913
|
+
featured: true,
|
|
914
|
+
intro: "Everything in Free, and:",
|
|
915
|
+
highlight: { title: "Shared", description: "usage across every V app" },
|
|
916
|
+
benefits: [
|
|
917
|
+
{ label: "Higher limits", icon: "vibot" },
|
|
918
|
+
{ label: "Pro features", icon: "verve" },
|
|
919
|
+
{ label: "Pro features", icon: "velth" },
|
|
920
|
+
{ label: "More memory", icon: "vmem" },
|
|
921
|
+
{ label: "More storage", icon: "vhub" },
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
]}
|
|
925
|
+
renderCheckout={(button, plan, period) => (
|
|
926
|
+
<CheckoutButton planId={plan.id} planPeriod={period}>{button}</CheckoutButton>
|
|
927
|
+
)}
|
|
928
|
+
/>
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
In the example, V Pro costs $15/month, or $10/month billed yearly ($120/year,
|
|
932
|
+
a 33% saving). The cards show the per-month price only, so the yearly total and
|
|
933
|
+
the saving go in `finePrint`. Screenshots: `brand/pricing-modal/` (regenerate with
|
|
934
|
+
`node scripts/build-pricing-screenshots.mjs`).
|
|
935
|
+
|
|
936
|
+
#### Single plan
|
|
937
|
+
|
|
938
|
+
Props: `logo` (`dark` and `light` image URLs, shown in that colour mode),
|
|
939
|
+
`wordmark`, `price`, `tagline`, `taglineFontClassName`, `features`, `ctaLabel`
|
|
940
|
+
(default "Get Pro"), `renderCheckout(button, period)` (wrap the button in the
|
|
941
|
+
billing provider's checkout), `closeButton` (an extra compact close button,
|
|
942
|
+
default `false`) and, when `description` is set, the title is an `sr-only`
|
|
943
|
+
`DialogTitle`, otherwise it sits in `VisuallyHidden`.
|
|
878
944
|
|
|
879
945
|
From 0.20.0, `yearly={{ pricePerMonth, total?, saveLabel? }}` adds a
|
|
880
|
-
monthly/yearly toggle; `price` stays the monthly price.
|
|
881
|
-
`
|
|
882
|
-
`defaultBillingPeriod` (`"month"` default). `renderCheckout` gets the selected
|
|
883
|
-
`period` (`"month"` or `"year"`) as its second argument, for Clerk's
|
|
946
|
+
monthly/yearly toggle; `price` stays the monthly price. `renderCheckout` gets the
|
|
947
|
+
selected `period` (`"month"` or `"year"`) as its second argument, for Clerk's
|
|
884
948
|
`planPeriod`. Without `yearly` the DOM is unchanged.
|
|
885
949
|
|
|
886
950
|
```tsx
|
|
@@ -894,6 +958,15 @@ monthly/yearly toggle; `price` stays the monthly price. Control the period with
|
|
|
894
958
|
/>
|
|
895
959
|
```
|
|
896
960
|
|
|
961
|
+
#### Migration
|
|
962
|
+
|
|
963
|
+
Single-plan callers need no change. To move to the multi-plan layout, replace
|
|
964
|
+
`logo`, `wordmark`, `price`, `tagline`, `features` and `yearly` with `plans`,
|
|
965
|
+
`apps` and `headline`, and change `renderCheckout` to
|
|
966
|
+
`(button, plan, period)`. Type helpers: use `PricingModalLegacyProps` or
|
|
967
|
+
`PricingModalMultiProps` when you need one layout's props;
|
|
968
|
+
`PricingModalProps` is their union.
|
|
969
|
+
|
|
897
970
|
### Account menu items and rail collapse tile
|
|
898
971
|
|
|
899
972
|
`SidebarAccountMenu` takes three optional props. Without them it renders as before.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./tokens/src/tailwind-preset.d.mts",
|
|
41
41
|
"default": "./tokens/src/tailwind-preset.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./brand": {
|
|
44
|
+
"types": "./src/brand/index.ts",
|
|
45
|
+
"default": "./src/brand/index.ts"
|
|
46
|
+
},
|
|
43
47
|
"./landing.css": "./src/marketing/landing.css",
|
|
44
48
|
"./package.json": "./package.json"
|
|
45
49
|
},
|
|
@@ -50,6 +54,7 @@
|
|
|
50
54
|
"app/src",
|
|
51
55
|
"tokens/src",
|
|
52
56
|
"tokens/examples",
|
|
57
|
+
"scripts/build-brand-assets.mjs",
|
|
53
58
|
"README.md",
|
|
54
59
|
"LICENSE",
|
|
55
60
|
"!**/*.test.ts",
|
|
@@ -66,6 +71,8 @@
|
|
|
66
71
|
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
|
|
67
72
|
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
68
73
|
"analyze": "node tokens/verify/analyze.mjs",
|
|
74
|
+
"brand:assets": "node scripts/build-brand-assets.mjs",
|
|
75
|
+
"brand:sheet": "node scripts/build-brand-sheet.mjs",
|
|
69
76
|
"prepack": "node tokens/scripts/check-exports.mjs"
|
|
70
77
|
},
|
|
71
78
|
"dependencies": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Writes the favicon, PWA, extension and Android icon sets for the family apps.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/build-brand-assets.mjs --out <dir> [--app <name>] [--no-png] [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// SVGs are always written. PNGs and favicon.ico need `rsvg-convert`; without it they are skipped
|
|
6
|
+
// with a message. Layout: <dir>/<app>/{mark,icon,favicon,icon-light}.svg, PNGs, extension/, android/.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { APP_NAMES, appAdaptiveSvg, appExtension128Svg, appMarkSvg, appTileSvg } from "../src/brand/svg.ts";
|
|
12
|
+
|
|
13
|
+
/** Whether `rsvg-convert` can be run. */
|
|
14
|
+
export function hasRsvg() {
|
|
15
|
+
return spawnSync("rsvg-convert", ["--version"], { stdio: "ignore" }).status === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Renders `svg` to a square PNG buffer of `px` pixels. */
|
|
19
|
+
export function renderPng(svg, px) {
|
|
20
|
+
const result = spawnSync("rsvg-convert", ["-w", String(px), "-h", String(px), "-f", "png"], { input: svg, maxBuffer: 64 * 1024 * 1024 });
|
|
21
|
+
if (result.status !== 0) throw new Error(`rsvg-convert failed: ${String(result.stderr)}`);
|
|
22
|
+
return result.stdout;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An ICO file that embeds each PNG (sizes in pixels, same order). */
|
|
26
|
+
export function icoFromPngs(pngs, sizes) {
|
|
27
|
+
const header = Buffer.alloc(6 + 16 * pngs.length);
|
|
28
|
+
header.writeUInt16LE(1, 2);
|
|
29
|
+
header.writeUInt16LE(pngs.length, 4);
|
|
30
|
+
let offset = header.length;
|
|
31
|
+
pngs.forEach((png, i) => {
|
|
32
|
+
const at = 6 + 16 * i;
|
|
33
|
+
header.writeUInt8(sizes[i] % 256, at);
|
|
34
|
+
header.writeUInt8(sizes[i] % 256, at + 1);
|
|
35
|
+
header.writeUInt16LE(1, at + 4);
|
|
36
|
+
header.writeUInt16LE(32, at + 6);
|
|
37
|
+
header.writeUInt32LE(png.length, at + 8);
|
|
38
|
+
header.writeUInt32LE(offset, at + 12);
|
|
39
|
+
offset += png.length;
|
|
40
|
+
});
|
|
41
|
+
return Buffer.concat([header, ...pngs]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** SVG files for one app, as `[relative path, content]`. */
|
|
45
|
+
export function svgFiles(app) {
|
|
46
|
+
const tile = appTileSvg(app);
|
|
47
|
+
return [
|
|
48
|
+
["mark.svg", appMarkSvg(app)],
|
|
49
|
+
["icon.svg", tile],
|
|
50
|
+
["favicon.svg", tile],
|
|
51
|
+
["icon-light.svg", appTileSvg(app, "light")],
|
|
52
|
+
["android/adaptive-foreground.svg", appAdaptiveSvg(app, "#fff")],
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** PNG files for one app, as `[relative path, svg source, pixels]`. */
|
|
57
|
+
export function pngSpecs(app) {
|
|
58
|
+
const tile = appTileSvg(app);
|
|
59
|
+
const square = appTileSvg(app, "dark", 0);
|
|
60
|
+
return [
|
|
61
|
+
["favicon-16.png", tile, 16],
|
|
62
|
+
["favicon-32.png", tile, 32],
|
|
63
|
+
["apple-touch-icon.png", square, 180],
|
|
64
|
+
["icon-192.png", tile, 192],
|
|
65
|
+
["icon-512.png", tile, 512],
|
|
66
|
+
["icon-1024.png", tile, 1024],
|
|
67
|
+
["icon-light.png", appTileSvg(app, "light"), 512],
|
|
68
|
+
["maskable-512.png", square, 512],
|
|
69
|
+
["extension/icon-16.png", tile, 16],
|
|
70
|
+
["extension/icon-32.png", tile, 32],
|
|
71
|
+
["extension/icon-48.png", tile, 48],
|
|
72
|
+
["extension/icon-128.png", appExtension128Svg(app), 128],
|
|
73
|
+
["android/adaptive-foreground-432.png", appAdaptiveSvg(app, "#fff"), 432],
|
|
74
|
+
["android/adaptive-monochrome-432.png", appAdaptiveSvg(app, "#000"), 432],
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Writes (or, with `dryRun`, lists) the set for each app. Returns the relative file paths per app and any skip notes. */
|
|
79
|
+
export function buildBrandAssets({ out, apps = APP_NAMES, png = true, dryRun = false }) {
|
|
80
|
+
const written = [];
|
|
81
|
+
const notes = [];
|
|
82
|
+
const withPng = png && hasRsvg();
|
|
83
|
+
if (png && !withPng) notes.push("rsvg-convert not found: PNGs and favicon.ico skipped (SVGs written).");
|
|
84
|
+
const put = (file, data) => {
|
|
85
|
+
written.push(file);
|
|
86
|
+
if (dryRun) return;
|
|
87
|
+
mkdirSync(dirname(join(out, file)), { recursive: true });
|
|
88
|
+
writeFileSync(join(out, file), data);
|
|
89
|
+
};
|
|
90
|
+
for (const app of apps) {
|
|
91
|
+
for (const [name, svg] of svgFiles(app)) put(join(app, name), svg);
|
|
92
|
+
if (!withPng) continue;
|
|
93
|
+
for (const [name, svg, px] of pngSpecs(app)) put(join(app, name), dryRun ? "" : renderPng(svg, px));
|
|
94
|
+
const sizes = [16, 32, 48];
|
|
95
|
+
const tile = appTileSvg(app);
|
|
96
|
+
put(join(app, "favicon.ico"), dryRun ? "" : icoFromPngs(sizes.map((px) => renderPng(tile, px)), sizes));
|
|
97
|
+
}
|
|
98
|
+
return { written, notes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseArgs(argv) {
|
|
102
|
+
const args = { out: "", app: "", png: true, dryRun: false };
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const arg = argv[i];
|
|
105
|
+
if (arg === "--out") args.out = argv[++i] ?? "";
|
|
106
|
+
else if (arg === "--app") args.app = argv[++i] ?? "";
|
|
107
|
+
else if (arg === "--no-png") args.png = false;
|
|
108
|
+
else if (arg === "--dry-run") args.dryRun = true;
|
|
109
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
if (!args.out) throw new Error("--out <dir> is required");
|
|
112
|
+
if (args.app && !APP_NAMES.includes(args.app)) throw new Error(`--app must be one of ${APP_NAMES.join(", ")}`);
|
|
113
|
+
return args;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
117
|
+
try {
|
|
118
|
+
const args = parseArgs(process.argv.slice(2));
|
|
119
|
+
const { written, notes } = buildBrandAssets({ out: args.out, apps: args.app ? [args.app] : APP_NAMES, png: args.png, dryRun: args.dryRun });
|
|
120
|
+
for (const file of written) console.log(`${args.dryRun ? "would write" : "wrote"} ${join(args.out, file)}`);
|
|
121
|
+
for (const note of notes) console.warn(note);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(error instanceof Error ? error.message : error);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useId, type SVGProps } from "react";
|
|
2
|
+
import { APP_MARKS, MARK_VIEWBOX, TILE_RADIUS, markLinecap, markStrokeWidth, type AppMarkVariant, type AppName } from "./svg";
|
|
3
|
+
|
|
4
|
+
export type AppMarkProps = Omit<SVGProps<SVGSVGElement>, "children" | "height" | "width" | "viewBox"> & {
|
|
5
|
+
/** Which family app's mark to draw. */
|
|
6
|
+
app: AppName;
|
|
7
|
+
/** `"mark"` (default): glyph in `currentColor`. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
|
|
8
|
+
variant?: AppMarkVariant;
|
|
9
|
+
/** Width and height. Default 24. */
|
|
10
|
+
size?: number | string;
|
|
11
|
+
/** Accessible name. Without it the mark is `aria-hidden`. */
|
|
12
|
+
title?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const TILE_TONES = {
|
|
16
|
+
tile: { bg: "#000", fg: "#fff" },
|
|
17
|
+
"tile-light": { bg: "#fff", fg: "#000" },
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/** One of the six family app marks on the 1024 grid. */
|
|
21
|
+
export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: AppMarkProps) {
|
|
22
|
+
const titleId = useId();
|
|
23
|
+
const { kind, paths } = APP_MARKS[app];
|
|
24
|
+
const tone = variant === "mark" ? undefined : TILE_TONES[variant];
|
|
25
|
+
const color = tone?.fg ?? "currentColor";
|
|
26
|
+
const glyphProps =
|
|
27
|
+
kind === "fill"
|
|
28
|
+
? { fill: color }
|
|
29
|
+
: { fill: "none", stroke: color, strokeWidth: markStrokeWidth(app), strokeLinecap: markLinecap(app), strokeLinejoin: "round" };
|
|
30
|
+
const glyph = (
|
|
31
|
+
<g {...(glyphProps as SVGProps<SVGGElement>)} data-glyph={app}>
|
|
32
|
+
{paths.map((d) => (
|
|
33
|
+
<path key={d} d={d} />
|
|
34
|
+
))}
|
|
35
|
+
</g>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
{...rest}
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
width={size}
|
|
42
|
+
height={size}
|
|
43
|
+
viewBox={MARK_VIEWBOX}
|
|
44
|
+
data-app={app}
|
|
45
|
+
data-variant={variant}
|
|
46
|
+
role={title ? "img" : undefined}
|
|
47
|
+
aria-hidden={title ? undefined : true}
|
|
48
|
+
aria-labelledby={title ? titleId : undefined}
|
|
49
|
+
>
|
|
50
|
+
{title ? <title id={titleId}>{title}</title> : null}
|
|
51
|
+
{tone ? <rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} /> : null}
|
|
52
|
+
{glyph}
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { AppMark } from "./AppMark";
|
|
2
|
+
export type { AppMarkProps } from "./AppMark";
|
|
3
|
+
export {
|
|
4
|
+
APP_MARKS,
|
|
5
|
+
APP_MARK_SVG,
|
|
6
|
+
APP_NAMES,
|
|
7
|
+
APP_TILE_LIGHT_SVG,
|
|
8
|
+
APP_TILE_SVG,
|
|
9
|
+
appAdaptiveSvg,
|
|
10
|
+
appExtension128Svg,
|
|
11
|
+
appMarkSvg,
|
|
12
|
+
appTileSvg,
|
|
13
|
+
markLinecap,
|
|
14
|
+
markStrokeWidth,
|
|
15
|
+
} from "./svg";
|
|
16
|
+
export type { AppMarkVariant, AppName, MarkShape } from "./svg";
|
package/src/brand/svg.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Geometry and raw SVG strings for the family app marks. No imports, so the
|
|
2
|
+
// asset script can load this file directly with Node.
|
|
3
|
+
|
|
4
|
+
export const APP_NAMES = ["projectv", "vibot", "vhub", "vmem", "verve", "velth"] as const;
|
|
5
|
+
|
|
6
|
+
export type AppName = (typeof APP_NAMES)[number];
|
|
7
|
+
|
|
8
|
+
/** `mark`: glyph only, follows `currentColor`. `tile`: white glyph on a black tile. `tile-light`: black glyph on a white tile. */
|
|
9
|
+
export type AppMarkVariant = "mark" | "tile" | "tile-light";
|
|
10
|
+
|
|
11
|
+
export const MARK_VIEWBOX = "0 0 1024 1024";
|
|
12
|
+
export const MARK_STROKE_WIDTH = 50;
|
|
13
|
+
export const TILE_RADIUS = 224;
|
|
14
|
+
|
|
15
|
+
export type MarkShape = {
|
|
16
|
+
/** Filled shapes (no stroke) versus lines (50 wide, round caps by default). */
|
|
17
|
+
kind: "fill" | "stroke";
|
|
18
|
+
/** Path data on the 1024 grid. */
|
|
19
|
+
paths: readonly string[];
|
|
20
|
+
/** Stroke width override on the 1024 grid. Defaults to `MARK_STROKE_WIDTH`. */
|
|
21
|
+
strokeWidth?: number;
|
|
22
|
+
/** Line cap override. Defaults to `"round"`. */
|
|
23
|
+
linecap?: "butt" | "round";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Stroke width of a mark on the 1024 grid. */
|
|
27
|
+
export function markStrokeWidth(app: AppName): number {
|
|
28
|
+
return APP_MARKS[app].strokeWidth ?? MARK_STROKE_WIDTH;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Line cap of a mark. */
|
|
32
|
+
export function markLinecap(app: AppName): "butt" | "round" {
|
|
33
|
+
return APP_MARKS[app].linecap ?? "round";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Glyphs sized by one rule: longest side = 512 * min(1.2, aspect^0.25), centred halfway between
|
|
38
|
+
* the bounding-box centre and the centre of mass. `verve` and `velth` are the final vectors.
|
|
39
|
+
* The others are traced from the approved 1024px renders.
|
|
40
|
+
*/
|
|
41
|
+
export const APP_MARKS: Record<AppName, MarkShape> = {
|
|
42
|
+
projectv: {
|
|
43
|
+
kind: "fill",
|
|
44
|
+
paths: ["M172 398L260 397L418 558L580 377L858 378L824 415L604 415L452 598L466 611L622 433L808 433L773 471L658 471L491 671L442 672Z"],
|
|
45
|
+
},
|
|
46
|
+
vibot: {
|
|
47
|
+
kind: "fill",
|
|
48
|
+
paths: [
|
|
49
|
+
"M295 314L396 314L396 574C520 540 620 440 655 314L711 314C680 470 560 590 396 627L390 627L390 651L396 651C560 610 700 480 734 314L789 314C740 540 560 712 295 712Z",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
vhub: {
|
|
53
|
+
kind: "stroke",
|
|
54
|
+
paths: ["M211 512L743 512", "M415 512C500 512 540 363 743 363", "M415 512C500 512 540 661 743 661"],
|
|
55
|
+
},
|
|
56
|
+
vmem: {
|
|
57
|
+
kind: "stroke",
|
|
58
|
+
strokeWidth: 58,
|
|
59
|
+
linecap: "butt",
|
|
60
|
+
paths: [
|
|
61
|
+
"M693.2 379.5A227.25 227.25 0 0 1 581.0 732.7",
|
|
62
|
+
"M442.5 732.7A227.25 227.25 0 0 1 330.9 378.7",
|
|
63
|
+
"M397.3 319.9A227.25 227.25 0 0 1 627.1 320.4",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
verve: {
|
|
67
|
+
kind: "stroke",
|
|
68
|
+
paths: [
|
|
69
|
+
"M285.4 344.1Q428.6 344.1 512 552.6Q595.4 344.1 738.6 344.1",
|
|
70
|
+
"M285.4 344.1L285.4 579.8Q428.6 579.8 512 706.7Q595.4 579.8 738.6 579.8L738.6 344.1",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
velth: {
|
|
74
|
+
kind: "stroke",
|
|
75
|
+
paths: [
|
|
76
|
+
"M512 732.3C379.8 652.1 289.7 471.9 299.7 301.6C419.9 371.7 500 552 512 732.3Z",
|
|
77
|
+
"M512 732.3C644.2 652.1 734.3 471.9 724.3 301.6C604.1 371.7 524 552 512 732.3Z",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
|
83
|
+
|
|
84
|
+
/** Attributes that paint the glyph in `color`. */
|
|
85
|
+
export function markPaintAttrs(app: AppName, color: string): string {
|
|
86
|
+
return APP_MARKS[app].kind === "fill" ? `fill="${color}"` : `fill="none" stroke="${color}" stroke-width="${markStrokeWidth(app)}" stroke-linecap="${markLinecap(app)}" stroke-linejoin="round"`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pathElements(app: AppName): string {
|
|
90
|
+
return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Glyph only, painted with `color` (default `currentColor`). */
|
|
94
|
+
export function appMarkSvg(app: AppName, color = "currentColor"): string {
|
|
95
|
+
return `<svg ${NS} viewBox="${MARK_VIEWBOX}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Tile with a glyph. `radius` 0 gives a full-bleed square (apple-touch, maskable). */
|
|
99
|
+
export function appTileSvg(app: AppName, tone: "dark" | "light" = "dark", radius = TILE_RADIUS): string {
|
|
100
|
+
const [bg, fg] = tone === "dark" ? ["#000", "#fff"] : ["#fff", "#000"];
|
|
101
|
+
const rx = radius > 0 ? ` rx="${radius}"` : "";
|
|
102
|
+
return `<svg ${NS} viewBox="${MARK_VIEWBOX}"><rect width="1024" height="1024"${rx} fill="${bg}"/><g ${markPaintAttrs(app, fg)}>${pathElements(app)}</g></svg>\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Android adaptive-icon layer: 432 canvas, glyph scaled into the 288 inner square. */
|
|
106
|
+
export function appAdaptiveSvg(app: AppName, color: string): string {
|
|
107
|
+
const scale = 288 / 1024;
|
|
108
|
+
return `<svg ${NS} viewBox="0 0 432 432"><g transform="translate(216 216) scale(${scale}) translate(-512 -512)" ${markPaintAttrs(app, color)}>${pathElements(app)}</g></svg>\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Chrome extension 128px icon: 96px tile plus 16px transparent padding. */
|
|
112
|
+
export function appExtension128Svg(app: AppName): string {
|
|
113
|
+
const inner = appTileSvg(app).replace(`<svg ${NS} viewBox="${MARK_VIEWBOX}">`, `<svg ${NS} x="16" y="16" width="96" height="96" viewBox="${MARK_VIEWBOX}">`);
|
|
114
|
+
return `<svg ${NS} viewBox="0 0 128 128">${inner.trim()}</svg>\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const APP_MARK_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appMarkSvg(app)])) as Record<AppName, string>;
|
|
118
|
+
export const APP_TILE_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app)])) as Record<AppName, string>;
|
|
119
|
+
export const APP_TILE_LIGHT_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app, "light")])) as Record<AppName, string>;
|
package/src/index.ts
CHANGED
|
@@ -143,7 +143,7 @@ export * from "./ai-elements/sources";
|
|
|
143
143
|
export * from "./ai-elements/speech-input";
|
|
144
144
|
export * from "./ai-elements/suggestion";
|
|
145
145
|
|
|
146
|
-
// Agent activity and run surfaces (moved from
|
|
146
|
+
// Agent activity and run surfaces (moved from an app): the activity timeline and
|
|
147
147
|
// its step model, Trace lines, plan, test results, web preview, context usage
|
|
148
148
|
// and the model picker.
|
|
149
149
|
export * from "./ai-elements/activity-shared";
|
|
@@ -289,11 +289,21 @@ export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-
|
|
|
289
289
|
export { SiteFooter } from "./marketing/SiteFooter";
|
|
290
290
|
export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
|
|
291
291
|
export { PricingModal } from "./marketing/PricingModal";
|
|
292
|
-
export type {
|
|
292
|
+
export type {
|
|
293
|
+
PricingModalLegacyProps,
|
|
294
|
+
PricingModalMultiProps,
|
|
295
|
+
PricingModalPeriod,
|
|
296
|
+
PricingModalProps,
|
|
297
|
+
PricingPlan,
|
|
298
|
+
PricingPlanBenefit,
|
|
299
|
+
} from "./marketing/PricingModal";
|
|
293
300
|
|
|
294
301
|
// App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
|
|
295
302
|
export * from "../app/src/index";
|
|
296
303
|
|
|
304
|
+
// Family app marks (AppMark and raw SVG strings for favicons).
|
|
305
|
+
export * from "./brand";
|
|
306
|
+
|
|
297
307
|
// Native tokens (JS values that match tokens.css).
|
|
298
308
|
export { tokens } from "../tokens/src/native";
|
|
299
309
|
export type { Tokens } from "../tokens/src/native";
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { useState, type ReactElement, type ReactNode } from "react";
|
|
1
|
+
import { useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import { IconCheck, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { AppMark } from "../brand/AppMark";
|
|
4
|
+
import type { AppName } from "../brand/svg";
|
|
3
5
|
import { Button } from "../ui/button";
|
|
4
6
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
|
|
5
7
|
import { VisuallyHidden } from "../ui/visually-hidden";
|
|
6
|
-
import { pricingModalClasses, type PricingModalTone } from "./marketing-classes";
|
|
8
|
+
import { pricingModalClasses, pricingMultiClasses, type PricingModalTone } from "./marketing-classes";
|
|
7
9
|
|
|
8
10
|
export type PricingModalPeriod = "month" | "year";
|
|
9
11
|
|
|
10
|
-
export type
|
|
12
|
+
export type PricingModalLegacyProps = {
|
|
13
|
+
/** Absent in the single-plan layout. Set `plans` for the multi-plan layout. */
|
|
14
|
+
plans?: undefined;
|
|
11
15
|
open: boolean;
|
|
12
16
|
/** Called when the dialog asks to close and by the `closeButton`. */
|
|
13
17
|
onClose: () => void;
|
|
@@ -51,8 +55,67 @@ export type PricingModalProps = {
|
|
|
51
55
|
tone?: PricingModalTone;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
export type PricingPlanBenefit = {
|
|
59
|
+
label: ReactNode;
|
|
60
|
+
/** `"check"` (default) or an app mark. */
|
|
61
|
+
icon?: "check" | AppName;
|
|
62
|
+
/** Dimmed row. */
|
|
63
|
+
muted?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type PricingPlan = {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
/** Display strings per month, for example `{ month: "$10", year: "$8" }`. The yearly price is still per month. */
|
|
70
|
+
price: { month: string; year: string };
|
|
71
|
+
/** Default "Get started". */
|
|
72
|
+
ctaLabel?: string;
|
|
73
|
+
/** Line above the highlight, for example "Everything in Free, and:". */
|
|
74
|
+
intro?: ReactNode;
|
|
75
|
+
/** Large title with small text under it. */
|
|
76
|
+
highlight?: { title: string; description: string };
|
|
77
|
+
benefits?: readonly PricingPlanBenefit[];
|
|
78
|
+
/** The plan the user is on: the button reads "Current plan" and is disabled. */
|
|
79
|
+
current?: boolean;
|
|
80
|
+
/** Subtle ring on the card. `highlighted` is an alias. */
|
|
81
|
+
featured?: boolean;
|
|
82
|
+
highlighted?: boolean;
|
|
83
|
+
/** Text after the price. Default "/month". */
|
|
84
|
+
periodSuffix?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type PricingModalMultiProps = {
|
|
88
|
+
open: boolean;
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
/** Accessible dialog title. Not visible. */
|
|
91
|
+
title: string;
|
|
92
|
+
/** Accessible description. Not visible. */
|
|
93
|
+
description?: string;
|
|
94
|
+
/** Cards, left to right. Sized for 1 to 4. */
|
|
95
|
+
plans: readonly PricingPlan[];
|
|
96
|
+
/** App marks at the top left. */
|
|
97
|
+
apps?: readonly AppName[];
|
|
98
|
+
/** Centred headline, for example "One plan. Every V app." */
|
|
99
|
+
headline?: ReactNode;
|
|
100
|
+
/** Small print under the cards. */
|
|
101
|
+
finePrint?: ReactNode;
|
|
102
|
+
billingPeriod?: PricingModalPeriod;
|
|
103
|
+
onBillingPeriodChange?: (period: PricingModalPeriod) => void;
|
|
104
|
+
/** Default `"month"`. */
|
|
105
|
+
defaultBillingPeriod?: PricingModalPeriod;
|
|
106
|
+
/** Wraps each plan's button, for example in the billing provider's checkout. Not called for the `current` plan. */
|
|
107
|
+
renderCheckout: (button: ReactElement, plan: PricingPlan, period: PricingModalPeriod) => ReactNode;
|
|
108
|
+
tone?: PricingModalTone;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type PricingModalProps = PricingModalLegacyProps | PricingModalMultiProps;
|
|
112
|
+
|
|
113
|
+
/** Upgrade dialog. With `plans`: wide multi-plan layout. Without: the single-plan layout. */
|
|
114
|
+
export function PricingModal(props: PricingModalProps) {
|
|
115
|
+
return props.plans ? <PricingModalMulti {...props} /> : <PricingModalSingle {...props} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function PricingModalSingle({
|
|
56
119
|
open,
|
|
57
120
|
onClose,
|
|
58
121
|
title,
|
|
@@ -71,7 +134,7 @@ export function PricingModal({
|
|
|
71
134
|
renderCheckout,
|
|
72
135
|
closeButton = false,
|
|
73
136
|
tone = "muted",
|
|
74
|
-
}:
|
|
137
|
+
}: PricingModalLegacyProps) {
|
|
75
138
|
const c = pricingModalClasses[tone];
|
|
76
139
|
const [localPeriod, setLocalPeriod] = useState<PricingModalPeriod>(defaultBillingPeriod);
|
|
77
140
|
const period: PricingModalPeriod = yearly ? (billingPeriod ?? localPeriod) : "month";
|
|
@@ -174,3 +237,163 @@ export function PricingModal({
|
|
|
174
237
|
</Dialog>
|
|
175
238
|
);
|
|
176
239
|
}
|
|
240
|
+
|
|
241
|
+
const multiWidth = {
|
|
242
|
+
1: "sm:max-w-md",
|
|
243
|
+
2: "sm:max-w-3xl",
|
|
244
|
+
3: "sm:max-w-5xl",
|
|
245
|
+
4: "sm:max-w-6xl",
|
|
246
|
+
} as const;
|
|
247
|
+
|
|
248
|
+
const multiColumns = {
|
|
249
|
+
1: "",
|
|
250
|
+
2: "sm:grid-cols-2",
|
|
251
|
+
3: "md:grid-cols-3",
|
|
252
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
253
|
+
} as const;
|
|
254
|
+
|
|
255
|
+
function PricingModalMulti({
|
|
256
|
+
open,
|
|
257
|
+
onClose,
|
|
258
|
+
title,
|
|
259
|
+
description,
|
|
260
|
+
plans,
|
|
261
|
+
apps,
|
|
262
|
+
headline,
|
|
263
|
+
finePrint,
|
|
264
|
+
billingPeriod,
|
|
265
|
+
onBillingPeriodChange,
|
|
266
|
+
defaultBillingPeriod = "month",
|
|
267
|
+
renderCheckout,
|
|
268
|
+
tone = "muted",
|
|
269
|
+
}: PricingModalMultiProps) {
|
|
270
|
+
const c = pricingMultiClasses[tone];
|
|
271
|
+
const [localPeriod, setLocalPeriod] = useState<PricingModalPeriod>(defaultBillingPeriod);
|
|
272
|
+
const period = billingPeriod ?? localPeriod;
|
|
273
|
+
const selectPeriod = (next: PricingModalPeriod) => {
|
|
274
|
+
setLocalPeriod(next);
|
|
275
|
+
onBillingPeriodChange?.(next);
|
|
276
|
+
};
|
|
277
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
278
|
+
const count = Math.min(Math.max(plans.length, 1), 4) as 1 | 2 | 3 | 4;
|
|
279
|
+
return (
|
|
280
|
+
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
|
281
|
+
<DialogContent
|
|
282
|
+
ref={contentRef}
|
|
283
|
+
hideCloseButton
|
|
284
|
+
// Focus the dialog, not the first segment button, so no ring shows on open.
|
|
285
|
+
onOpenAutoFocus={(event) => {
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
contentRef.current?.focus();
|
|
288
|
+
}}
|
|
289
|
+
className={`max-h-[calc(100dvh-2rem)] gap-0 overflow-y-auto p-0 font-sans sm:w-[calc(100vw-2rem)] ${multiWidth[count]}`}
|
|
290
|
+
>
|
|
291
|
+
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
292
|
+
{description ? <DialogDescription className="sr-only">{description}</DialogDescription> : null}
|
|
293
|
+
|
|
294
|
+
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-3 px-4 pb-4 pt-5 sm:grid-cols-[1fr_auto_1fr] sm:px-6">
|
|
295
|
+
<div className="flex items-center gap-2 text-foreground" data-testid="pricing-apps">
|
|
296
|
+
{apps?.map((app) => <AppMark key={app} app={app} variant="tile" size={24} className="rounded-md" />)}
|
|
297
|
+
</div>
|
|
298
|
+
<div className="flex items-center justify-end gap-2 sm:order-last">
|
|
299
|
+
<div role="group" aria-label="Billing period" className="inline-flex rounded-full bg-surface-tertiary p-0.5">
|
|
300
|
+
{(["month", "year"] as const).map((value) => (
|
|
301
|
+
<button
|
|
302
|
+
key={value}
|
|
303
|
+
type="button"
|
|
304
|
+
aria-pressed={period === value}
|
|
305
|
+
onClick={() => selectPeriod(value)}
|
|
306
|
+
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors duration-150 motion-reduce:transition-none ${
|
|
307
|
+
period === value ? "bg-segment text-segment-foreground" : `${c.switchIdle} hover:text-foreground`
|
|
308
|
+
}`}
|
|
309
|
+
>
|
|
310
|
+
{value === "month" ? "Monthly" : "Annual"}
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={onClose}
|
|
317
|
+
className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-[background-color,transform] duration-100 ease-out hover:bg-surface-tertiary hover:text-foreground active:scale-[0.96] motion-reduce:transition-none"
|
|
318
|
+
>
|
|
319
|
+
<IconX className="size-4" />
|
|
320
|
+
<span className="sr-only">Close</span>
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
{headline ? (
|
|
324
|
+
<p className="col-span-2 text-balance text-center text-xl font-medium tracking-tight text-foreground sm:order-none sm:col-span-1 sm:text-2xl">
|
|
325
|
+
{headline}
|
|
326
|
+
</p>
|
|
327
|
+
) : (
|
|
328
|
+
<span className="hidden sm:block" />
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<div className={`grid grid-cols-1 gap-3 px-4 sm:px-6 ${multiColumns[count]}`}>
|
|
333
|
+
{plans.map((plan) => {
|
|
334
|
+
const emphasised = plan.featured ?? plan.highlighted;
|
|
335
|
+
const button = (
|
|
336
|
+
<Button
|
|
337
|
+
size="lg"
|
|
338
|
+
variant={plan.current ? "outline" : emphasised ? "default" : "secondary"}
|
|
339
|
+
disabled={plan.current}
|
|
340
|
+
className="h-12 w-full rounded-full"
|
|
341
|
+
>
|
|
342
|
+
{plan.current ? "Current plan" : (plan.ctaLabel ?? "Get started")}
|
|
343
|
+
</Button>
|
|
344
|
+
);
|
|
345
|
+
return (
|
|
346
|
+
<section
|
|
347
|
+
key={plan.id}
|
|
348
|
+
data-plan={plan.id}
|
|
349
|
+
aria-label={plan.name}
|
|
350
|
+
className={`flex flex-col rounded-3xl bg-surface-secondary p-5 ${
|
|
351
|
+
emphasised ? "ring-1 ring-foreground/25" : "ring-1 ring-separator"
|
|
352
|
+
}`}
|
|
353
|
+
>
|
|
354
|
+
<h3 className="text-base text-foreground">{plan.name}</h3>
|
|
355
|
+
<p className="mt-4 flex items-baseline gap-1">
|
|
356
|
+
<span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
|
|
357
|
+
{plan.price[period]}
|
|
358
|
+
</span>
|
|
359
|
+
<span className={c.suffix}>{plan.periodSuffix ?? "/month"}</span>
|
|
360
|
+
</p>
|
|
361
|
+
<div className="mt-5">{plan.current ? button : renderCheckout(button, plan, period)}</div>
|
|
362
|
+
{plan.intro ? <p className={`mt-6 text-sm ${c.suffix}`}>{plan.intro}</p> : null}
|
|
363
|
+
{plan.highlight ? (
|
|
364
|
+
<div className="mt-3">
|
|
365
|
+
<p className="text-2xl font-medium leading-tight text-foreground">{plan.highlight.title}</p>
|
|
366
|
+
<p className="text-sm text-foreground">{plan.highlight.description}</p>
|
|
367
|
+
</div>
|
|
368
|
+
) : null}
|
|
369
|
+
{plan.benefits?.length ? (
|
|
370
|
+
<ul className="mt-5 space-y-2.5 border-t border-separator pt-4">
|
|
371
|
+
{plan.benefits.map((benefit, index) => (
|
|
372
|
+
<li
|
|
373
|
+
key={index}
|
|
374
|
+
className={`flex items-start gap-2.5 text-sm leading-snug ${
|
|
375
|
+
benefit.muted ? c.suffix : "text-foreground"
|
|
376
|
+
}`}
|
|
377
|
+
>
|
|
378
|
+
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
|
|
379
|
+
{benefit.icon && benefit.icon !== "check" ? (
|
|
380
|
+
<AppMark app={benefit.icon} size={16} />
|
|
381
|
+
) : (
|
|
382
|
+
<IconCheck className="size-4" strokeWidth={2} />
|
|
383
|
+
)}
|
|
384
|
+
</span>
|
|
385
|
+
<span>{benefit.label}</span>
|
|
386
|
+
</li>
|
|
387
|
+
))}
|
|
388
|
+
</ul>
|
|
389
|
+
) : null}
|
|
390
|
+
</section>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{finePrint ? <p className={`px-4 pb-5 pt-4 text-xs sm:px-6 ${c.suffix}`}>{finePrint}</p> : <div className="pb-4" />}
|
|
396
|
+
</DialogContent>
|
|
397
|
+
</Dialog>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
@@ -113,3 +113,15 @@ export const pricingModalClasses = {
|
|
|
113
113
|
} as const;
|
|
114
114
|
|
|
115
115
|
export type PricingModalTone = keyof typeof pricingModalClasses;
|
|
116
|
+
|
|
117
|
+
/** Multi-plan `PricingModal` secondary text classes for the two tones. */
|
|
118
|
+
export const pricingMultiClasses = {
|
|
119
|
+
muted: {
|
|
120
|
+
suffix: "text-muted", // check-tokens-allow: text-muted
|
|
121
|
+
switchIdle: "text-muted", // check-tokens-allow: text-muted
|
|
122
|
+
},
|
|
123
|
+
"muted-foreground": {
|
|
124
|
+
suffix: "text-muted-foreground",
|
|
125
|
+
switchIdle: "text-muted-foreground",
|
|
126
|
+
},
|
|
127
|
+
} as const;
|