@vvedantb/ui 0.23.0 → 0.25.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 +35 -18
- package/package.json +1 -1
- package/shell/src/SidebarUpgradeCard.tsx +7 -12
- package/src/brand/AppIcons.tsx +71 -0
- package/src/brand/AppMark.tsx +17 -13
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +7 -0
- package/src/brand/svg.ts +22 -12
- package/src/brand/vProAppBenefits.ts +24 -0
- package/src/index.ts +3 -2
- package/src/marketing/PricingModal.tsx +66 -5
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ import { SidebarUpgradeCard } from "@vvedantb/ui";
|
|
|
188
188
|
{!isPro && <SidebarUpgradeCard collapsed onUpgrade={() => setPricingOpen(true)} />}
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
Shows the
|
|
191
|
+
Shows the title "Upgrade to Pro" and an "Upgrade" pill, with no mark. The title fits in full at 254px. `collapsed` renders a round button with the short text "Pro", labelled "Upgrade to Pro" for narrow rails. The card does not check the plan, so the app hides it for Pro users.
|
|
192
192
|
|
|
193
193
|
## Sessions hover card
|
|
194
194
|
|
|
@@ -876,18 +876,39 @@ links under a separator.
|
|
|
876
876
|
| `legalLinks` | `{ href, label }[]`, open in a new tab. |
|
|
877
877
|
| `variant` | `"muted"` (default, `text-muted`), `"muted-foreground"`, or `"touch"` (`text-muted-foreground`, `max-sm:hit-target` links, safe-area bottom padding). |
|
|
878
878
|
|
|
879
|
-
### `
|
|
879
|
+
### `Icon*` app icons
|
|
880
880
|
|
|
881
|
+
Tabler-style plain app glyphs: `IconVibot`, `IconVhub`, `IconVmem`, `IconVerve`,
|
|
882
|
+
`IconVelth` and `IconProjectV`. No background. Each sits on an equalised square viewBox, so all apps look the same size. `APP_ICONS[app]`
|
|
883
|
+
looks one up by `AppName`.
|
|
884
|
+
|
|
885
|
+
```tsx
|
|
886
|
+
import { IconVerve } from "@vvedantb/ui"
|
|
887
|
+
|
|
888
|
+
<IconVerve size={24} color="currentColor" />
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
`size` sets both width and height (default 24; a string is any CSS length). `color`
|
|
892
|
+
defaults to `currentColor`. `stroke` sets the stroke width in pixels for stroke
|
|
893
|
+
glyphs (fill glyphs ignore it). `title` gives an accessible name. The ref goes
|
|
894
|
+
to the `<svg>`.
|
|
895
|
+
|
|
896
|
+
### `AppMark` (deprecated)
|
|
897
|
+
|
|
898
|
+
Deprecated in favour of the `Icon*` components for plain marks. Tiles stay here.
|
|
881
899
|
Family app icons: `app` (`projectv`, `vibot`, `vhub`, `vmem`, `verve`, `velth`),
|
|
882
900
|
`variant`, `size`, `title`.
|
|
883
901
|
|
|
884
|
-
- `variant="mark"` (default) is the plain logo in `currentColor`. It
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
902
|
+
- `variant="mark"` (default) is the plain logo in `currentColor`. It sits
|
|
903
|
+
on a square viewBox. The five apps (`vibot`, `vhub`, `vmem`, `verve`, `velth`) share one
|
|
904
|
+
crop (`APP_MARK_CROP_VIEWBOX`, `173 197 629 629`): the union of their glyph boxes on the
|
|
905
|
+
1024 tile grid, centred, with about 4% padding, so relative glyph sizes match the tiles and
|
|
906
|
+
the widest glyph fills about 92% of the icon box. `projectv` is a brand mark, so it has its
|
|
907
|
+
own tight square (`PROJECTV_MARK_VIEWBOX`, `172 181 687 687`). `size` sets both width and
|
|
908
|
+
height; a string `size` (for example `"2rem"`) is any CSS length. `APP_MARK_VIEWBOX` holds the
|
|
909
|
+
viewBox for each app; `appMarkSvg()` and `APP_MARK_SVG` use it. `markAspect` returns
|
|
910
|
+
1 and is kept for API stability only.
|
|
911
|
+
- `variant="tile"` and `"tile-light"` use the same 1024 grid on a
|
|
891
912
|
rounded tile, always square. `appTileSvg`, `appAdaptiveSvg`, `appExtension128Svg`,
|
|
892
913
|
`APP_TILE_SVG`, `APP_TILE_LIGHT_SVG` and favicons are unchanged.
|
|
893
914
|
|
|
@@ -906,7 +927,7 @@ default) and `renderCheckout`.
|
|
|
906
927
|
|
|
907
928
|
#### Multi-plan (0.22.0)
|
|
908
929
|
|
|
909
|
-
Header: `apps` (an `AppName[]`, shown as plain `
|
|
930
|
+
Header: `apps` (an `AppName[]`, shown as plain `Icon*` logos at the top left; each links to the app's site in a new tab and shows its display name in a tooltip, from `FAMILY_APPS`),
|
|
910
931
|
`headline` (centre), a Monthly / Annual switch and a close button (top right).
|
|
911
932
|
Then one card per entry of `plans` (2 to 4 fit) and one `finePrint` line. Cards
|
|
912
933
|
stack on narrow screens.
|
|
@@ -919,6 +940,10 @@ price is the per-month price). `ctaLabel` defaults to "Get started". `featured`
|
|
|
919
940
|
(or `highlighted`) adds a subtle ring. A `current` plan shows a disabled,
|
|
920
941
|
outlined "Current plan" button and does not call `renderCheckout`.
|
|
921
942
|
|
|
943
|
+
`appBenefits` (`{ app, items }[]`, type `PricingPlanAppBenefit`) groups benefits per app, each under a small heading with that app's icon and display name. It renders after `benefits` and scrolls inside the card when long. Set `currentApp` on the modal to put that app's group first; the other groups keep the array order. The `featured` / `highlighted` plan uses `V_PRO_APP_BENEFITS` (the approved V Pro copy, in the order vibot, vmem, vhub, velth, verve) when it omits `appBenefits`, so apps usually pass only `currentApp`. Other plans show app benefits only when set.
|
|
944
|
+
|
|
945
|
+
`FAMILY_APPS` (and `familyApp(app)`), exported from the root and `@vvedantb/ui/brand`, is the single list of app display names and sites (`vibot`, `Verve`, `Velth`, `vmem`, `vhub`, `Project V`; URLs without a trailing slash). App names live only under `src/brand/`.
|
|
946
|
+
|
|
922
947
|
The `featured` plan's button is a solid primary button (`Button` `variant="default"`). Other plans use `secondary`.
|
|
923
948
|
|
|
924
949
|
`renderCheckout(button, plan, period)` wraps each plan's button.
|
|
@@ -941,14 +966,6 @@ Each app passes its own plan names, prices and copy. The example and the screens
|
|
|
941
966
|
price: { month: "$15", year: "$10" },
|
|
942
967
|
featured: true,
|
|
943
968
|
intro: "Everything in Free, and:",
|
|
944
|
-
highlight: { title: "Shared", description: "usage across every V app" },
|
|
945
|
-
benefits: [
|
|
946
|
-
{ label: "Higher limits", icon: "vibot" },
|
|
947
|
-
{ label: "Pro features", icon: "verve" },
|
|
948
|
-
{ label: "Pro features", icon: "velth" },
|
|
949
|
-
{ label: "More memory", icon: "vmem" },
|
|
950
|
-
{ label: "More storage", icon: "vhub" },
|
|
951
|
-
],
|
|
952
969
|
},
|
|
953
970
|
]}
|
|
954
971
|
renderCheckout={(button, plan, period) => (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { AppMark } from "../../src/brand/AppMark";
|
|
2
1
|
import { Button } from "../../src/ui/button";
|
|
3
2
|
import { cn } from "../../src/utils/cn";
|
|
4
3
|
|
|
5
4
|
export type SidebarUpgradeCardProps = {
|
|
6
5
|
/** Opens the app's `PricingModal`. */
|
|
7
6
|
onUpgrade: () => void;
|
|
8
|
-
/** Narrow rail:
|
|
7
|
+
/** Narrow rail: a round "Pro" button (labelled "Upgrade to Pro") instead of the full card. Default `false`. */
|
|
9
8
|
collapsed?: boolean;
|
|
10
9
|
className?: string;
|
|
11
10
|
};
|
|
@@ -13,22 +12,21 @@ export type SidebarUpgradeCardProps = {
|
|
|
13
12
|
const pillClass = "bg-foreground text-background hover:bg-foreground/85";
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
|
-
* Upgrade prompt for the sidebar:
|
|
17
|
-
* the plan; the app hides it when the user is already on Pro.
|
|
18
|
-
* the width follows the aspect ratio.
|
|
15
|
+
* Upgrade prompt for the sidebar: the title "Upgrade to Pro" and an "Upgrade" pill, with no mark. It does
|
|
16
|
+
* not check the plan; the app hides it when the user is already on Pro.
|
|
19
17
|
*/
|
|
20
18
|
export function SidebarUpgradeCard({ onUpgrade, collapsed = false, className }: SidebarUpgradeCardProps) {
|
|
21
19
|
if (collapsed) {
|
|
22
20
|
return (
|
|
23
21
|
<Button
|
|
24
22
|
type="button"
|
|
25
|
-
size="
|
|
23
|
+
size="sm"
|
|
26
24
|
aria-label="Upgrade to Pro"
|
|
27
25
|
title="Upgrade to Pro"
|
|
28
26
|
onClick={onUpgrade}
|
|
29
|
-
className={cn("rounded-full", pillClass, className)}
|
|
27
|
+
className={cn("rounded-full px-3", pillClass, className)}
|
|
30
28
|
>
|
|
31
|
-
|
|
29
|
+
Pro
|
|
32
30
|
</Button>
|
|
33
31
|
);
|
|
34
32
|
}
|
|
@@ -40,10 +38,7 @@ export function SidebarUpgradeCard({ onUpgrade, collapsed = false, className }:
|
|
|
40
38
|
className,
|
|
41
39
|
)}
|
|
42
40
|
>
|
|
43
|
-
<
|
|
44
|
-
<AppMark app="projectv" size={14} className="shrink-0" />
|
|
45
|
-
<span className="truncate">Upgrade to Pro</span>
|
|
46
|
-
</div>
|
|
41
|
+
<span className="min-w-0 truncate text-sm font-semibold">Upgrade to Pro</span>
|
|
47
42
|
<Button type="button" size="sm" onClick={onUpgrade} className={cn("shrink-0 rounded-full", pillClass)}>
|
|
48
43
|
Upgrade
|
|
49
44
|
</Button>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { forwardRef, useId, type ForwardRefExoticComponent, type RefAttributes, type SVGProps } from "react";
|
|
2
|
+
import { APP_MARKS, APP_MARK_VIEWBOX, APP_NAMES, markLinecap, markStrokeWidth, type AppName } from "./svg";
|
|
3
|
+
|
|
4
|
+
export type AppIconProps = Omit<SVGProps<SVGSVGElement>, "children" | "height" | "width" | "viewBox" | "stroke" | "color"> & {
|
|
5
|
+
/** Rendered width and height (the glyph is square). Default 24. A number is in pixels; a string is any CSS length. */
|
|
6
|
+
size?: number | string;
|
|
7
|
+
/** Glyph colour, for fill and stroke glyphs. Default `"currentColor"`. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Stroke width in rendered pixels, for stroke glyphs (Tabler style, for example `1.5`). Default: the glyph's own weight. Fill glyphs ignore it. */
|
|
10
|
+
stroke?: number | string;
|
|
11
|
+
/** Accessible name. Without it the icon is `aria-hidden`. */
|
|
12
|
+
title?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AppIconComponent = ForwardRefExoticComponent<AppIconProps & RefAttributes<SVGSVGElement>>;
|
|
16
|
+
|
|
17
|
+
function viewBoxSide(app: AppName): number {
|
|
18
|
+
return Number(APP_MARK_VIEWBOX[app].split(" ")[3]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createAppIcon(app: AppName, displayName: string): AppIconComponent {
|
|
22
|
+
const Icon = forwardRef<SVGSVGElement, AppIconProps>(function Icon(
|
|
23
|
+
{ size = 24, color = "currentColor", stroke, title, ...rest },
|
|
24
|
+
ref,
|
|
25
|
+
) {
|
|
26
|
+
const titleId = useId();
|
|
27
|
+
const { kind, paths } = APP_MARKS[app];
|
|
28
|
+
const renderedSize = typeof size === "number" ? size : 24;
|
|
29
|
+
const strokeWidth = stroke === undefined ? markStrokeWidth(app) : Number(stroke) * (viewBoxSide(app) / renderedSize);
|
|
30
|
+
const glyphProps =
|
|
31
|
+
kind === "fill"
|
|
32
|
+
? { fill: color }
|
|
33
|
+
: { fill: "none", stroke: color, strokeWidth, strokeLinecap: markLinecap(app), strokeLinejoin: "round" };
|
|
34
|
+
return (
|
|
35
|
+
<svg
|
|
36
|
+
{...rest}
|
|
37
|
+
ref={ref}
|
|
38
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
39
|
+
width={size}
|
|
40
|
+
height={size}
|
|
41
|
+
viewBox={APP_MARK_VIEWBOX[app]}
|
|
42
|
+
preserveAspectRatio="xMidYMid meet"
|
|
43
|
+
data-app={app}
|
|
44
|
+
role={title ? "img" : undefined}
|
|
45
|
+
aria-hidden={title ? undefined : true}
|
|
46
|
+
aria-labelledby={title ? titleId : undefined}
|
|
47
|
+
>
|
|
48
|
+
{title ? <title id={titleId}>{title}</title> : null}
|
|
49
|
+
<g {...(glyphProps as SVGProps<SVGGElement>)} data-glyph={app}>
|
|
50
|
+
{paths.map((d) => (
|
|
51
|
+
<path key={d} d={d} />
|
|
52
|
+
))}
|
|
53
|
+
</g>
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
Icon.displayName = displayName;
|
|
58
|
+
return Icon;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const iconName = (app: AppName) => `Icon${app === "projectv" ? "ProjectV" : app[0]!.toUpperCase() + app.slice(1)}`;
|
|
62
|
+
|
|
63
|
+
/** Plain app glyph components by app name, for dynamic lookup. */
|
|
64
|
+
export const APP_ICONS = Object.fromEntries(APP_NAMES.map((app) => [app, createAppIcon(app, iconName(app))])) as Record<AppName, AppIconComponent>;
|
|
65
|
+
|
|
66
|
+
export const IconVibot = APP_ICONS.vibot;
|
|
67
|
+
export const IconVhub = APP_ICONS.vhub;
|
|
68
|
+
export const IconVmem = APP_ICONS.vmem;
|
|
69
|
+
export const IconVerve = APP_ICONS.verve;
|
|
70
|
+
export const IconVelth = APP_ICONS.velth;
|
|
71
|
+
export const IconProjectV = APP_ICONS.projectv;
|
package/src/brand/AppMark.tsx
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { useId, type SVGProps } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { APP_ICONS } from "./AppIcons";
|
|
3
|
+
import { APP_MARKS, MARK_VIEWBOX, TILE_RADIUS, markLinecap, markStrokeWidth, type AppMarkVariant, type AppName } from "./svg";
|
|
3
4
|
|
|
4
5
|
export type AppMarkProps = Omit<SVGProps<SVGSVGElement>, "children" | "height" | "width" | "viewBox"> & {
|
|
5
6
|
/** Which family app's mark to draw. */
|
|
6
7
|
app: AppName;
|
|
7
|
-
/** `"mark"` (default): plain logo in `currentColor
|
|
8
|
+
/** `"mark"` (default): plain logo in `currentColor` on an equalised square viewBox, so every app looks the same size. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
|
|
8
9
|
variant?: AppMarkVariant;
|
|
9
10
|
/**
|
|
10
|
-
* Default 24.
|
|
11
|
-
* (a number gives an exact width; a string sets both to the same value and the glyph is centred).
|
|
12
|
-
* Tiles are always square, `size` by `size`.
|
|
11
|
+
* Default 24. Sets both width and height, for marks and tiles alike (always square). A string is any CSS length.
|
|
13
12
|
*/
|
|
14
13
|
size?: number | string;
|
|
15
14
|
/** Accessible name. Without it the mark is `aria-hidden`. */
|
|
@@ -21,14 +20,19 @@ const TILE_TONES = {
|
|
|
21
20
|
"tile-light": { bg: "#fff", fg: "#000" },
|
|
22
21
|
} as const;
|
|
23
22
|
|
|
24
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* One of the six family app marks. The plain mark and the tiles share the same square 1024 viewBox, so a bare glyph matches the tile glyph optically.
|
|
25
|
+
* @deprecated Use the per-app `Icon*` components (or `APP_ICONS[app]`) for plain marks. Tiles are for app and store icons only.
|
|
26
|
+
*/
|
|
25
27
|
export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: AppMarkProps) {
|
|
26
28
|
const titleId = useId();
|
|
29
|
+
if (variant === "mark") {
|
|
30
|
+
const Icon = APP_ICONS[app];
|
|
31
|
+
return <Icon {...rest} size={size} title={title} data-variant="mark" />;
|
|
32
|
+
}
|
|
27
33
|
const { kind, paths } = APP_MARKS[app];
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const tone = variant === "mark" ? undefined : TILE_TONES[variant];
|
|
31
|
-
const color = tone?.fg ?? "currentColor";
|
|
34
|
+
const tone = TILE_TONES[variant];
|
|
35
|
+
const color = tone.fg;
|
|
32
36
|
const glyphProps =
|
|
33
37
|
kind === "fill"
|
|
34
38
|
? { fill: color }
|
|
@@ -44,9 +48,9 @@ export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: Ap
|
|
|
44
48
|
<svg
|
|
45
49
|
{...rest}
|
|
46
50
|
xmlns="http://www.w3.org/2000/svg"
|
|
47
|
-
width={
|
|
51
|
+
width={size}
|
|
48
52
|
height={size}
|
|
49
|
-
viewBox={
|
|
53
|
+
viewBox={MARK_VIEWBOX}
|
|
50
54
|
preserveAspectRatio="xMidYMid meet"
|
|
51
55
|
data-app={app}
|
|
52
56
|
data-variant={variant}
|
|
@@ -55,7 +59,7 @@ export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: Ap
|
|
|
55
59
|
aria-labelledby={title ? titleId : undefined}
|
|
56
60
|
>
|
|
57
61
|
{title ? <title id={titleId}>{title}</title> : null}
|
|
58
|
-
|
|
62
|
+
<rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} />
|
|
59
63
|
{glyph}
|
|
60
64
|
</svg>
|
|
61
65
|
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AppName } from "./svg";
|
|
2
|
+
|
|
3
|
+
export type FamilyApp = {
|
|
4
|
+
app: AppName;
|
|
5
|
+
/** Display name. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Home page of the app. */
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Single source for family app display names and sites, in display order. */
|
|
12
|
+
export const FAMILY_APPS: readonly FamilyApp[] = [
|
|
13
|
+
{ app: "vibot", name: "vibot", url: "https://vibot.projectv.uk" },
|
|
14
|
+
{ app: "verve", name: "Verve", url: "https://verve.projectv.uk" },
|
|
15
|
+
{ app: "velth", name: "Velth", url: "https://velth.projectv.uk" },
|
|
16
|
+
{ app: "vmem", name: "vmem", url: "https://vmem.projectv.uk" },
|
|
17
|
+
{ app: "vhub", name: "vhub", url: "https://vhub.projectv.uk" },
|
|
18
|
+
{ app: "projectv", name: "Project V", url: "https://projectv.uk" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Entry of `FAMILY_APPS` for an app. */
|
|
22
|
+
export function familyApp(app: AppName): FamilyApp {
|
|
23
|
+
return FAMILY_APPS.find((entry) => entry.app === app)!;
|
|
24
|
+
}
|
package/src/brand/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
export * from "./AppIcons";
|
|
1
2
|
export { AppMark } from "./AppMark";
|
|
2
3
|
export type { AppMarkProps } from "./AppMark";
|
|
3
4
|
export {
|
|
4
5
|
APP_MARKS,
|
|
6
|
+
APP_MARK_CROP_VIEWBOX,
|
|
5
7
|
APP_MARK_VIEWBOX,
|
|
8
|
+
PROJECTV_MARK_VIEWBOX,
|
|
6
9
|
APP_MARK_SVG,
|
|
7
10
|
APP_NAMES,
|
|
8
11
|
APP_TILE_LIGHT_SVG,
|
|
@@ -18,3 +21,7 @@ export {
|
|
|
18
21
|
pathBBox,
|
|
19
22
|
} from "./svg";
|
|
20
23
|
export type { AppMarkVariant, AppName, MarkShape } from "./svg";
|
|
24
|
+
export { FAMILY_APPS, familyApp } from "./familyApps";
|
|
25
|
+
export type { FamilyApp } from "./familyApps";
|
|
26
|
+
export { V_PRO_APP_BENEFITS } from "./vProAppBenefits";
|
|
27
|
+
export type { PricingPlanAppBenefit } from "./vProAppBenefits";
|
package/src/brand/svg.ts
CHANGED
|
@@ -5,7 +5,7 @@ export const APP_NAMES = ["projectv", "vibot", "vhub", "vmem", "verve", "velth"]
|
|
|
5
5
|
|
|
6
6
|
export type AppName = (typeof APP_NAMES)[number];
|
|
7
7
|
|
|
8
|
-
/** `mark`: glyph only
|
|
8
|
+
/** `mark`: glyph only on an equalised square viewBox, follows `currentColor`. `tile`: white glyph on a black tile. `tile-light`: black glyph on a white tile. */
|
|
9
9
|
export type AppMarkVariant = "mark" | "tile" | "tile-light";
|
|
10
10
|
|
|
11
11
|
export const MARK_VIEWBOX = "0 0 1024 1024";
|
|
@@ -175,20 +175,30 @@ export function markBBox(app: AppName): Box {
|
|
|
175
175
|
|
|
176
176
|
|
|
177
177
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
178
|
+
* Shared crop for the five app glyphs (`vibot`, `vhub`, `vmem`, `verve`, `velth`) in the plain logo (`variant="mark"`,
|
|
179
|
+
* `appMarkSvg`, `Icon*`): the union of their `markBBox` results on the 1024 tile grid (x 186 to 789, y 259.96 to 761.7),
|
|
180
|
+
* centred in a square of side max(width, height) x 1.04 (about 4% padding), then rounded outward to whole units.
|
|
181
|
+
* `projectv` is left out so its wide brand mark does not shrink the app glyphs. Not the full tile. Checked in tests.
|
|
181
182
|
*/
|
|
183
|
+
export const APP_MARK_CROP_VIEWBOX = "173 197 629 629";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Own tight square for `projectv` (a brand mark, not an app): `markBBox("projectv")` (x 172 to 858, y 377 to 672),
|
|
187
|
+
* centred on the shorter axis and rounded outward to whole units, with no extra padding.
|
|
188
|
+
*/
|
|
189
|
+
export const PROJECTV_MARK_VIEWBOX = "172 181 687 687";
|
|
190
|
+
|
|
191
|
+
/** Plain-mark viewBox per app: the shared crop for the five apps, a tight square for `projectv`. Always square. */
|
|
182
192
|
export const APP_MARK_VIEWBOX: Record<AppName, string> = {
|
|
183
|
-
projectv:
|
|
184
|
-
vibot:
|
|
185
|
-
vhub:
|
|
186
|
-
vmem:
|
|
187
|
-
verve:
|
|
188
|
-
velth:
|
|
193
|
+
projectv: PROJECTV_MARK_VIEWBOX,
|
|
194
|
+
vibot: APP_MARK_CROP_VIEWBOX,
|
|
195
|
+
vhub: APP_MARK_CROP_VIEWBOX,
|
|
196
|
+
vmem: APP_MARK_CROP_VIEWBOX,
|
|
197
|
+
verve: APP_MARK_CROP_VIEWBOX,
|
|
198
|
+
velth: APP_MARK_CROP_VIEWBOX,
|
|
189
199
|
};
|
|
190
200
|
|
|
191
|
-
/** Width over height of a mark's
|
|
201
|
+
/** Width over height of a mark's viewBox. Always about 1, because the viewBoxes are square. Kept for API stability; do not use it for sizing. */
|
|
192
202
|
export function markAspect(app: AppName): number {
|
|
193
203
|
const [, , w = 1, h = 1] = APP_MARK_VIEWBOX[app].split(" ").map(Number);
|
|
194
204
|
return w / h;
|
|
@@ -205,7 +215,7 @@ function pathElements(app: AppName): string {
|
|
|
205
215
|
return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
|
|
206
216
|
}
|
|
207
217
|
|
|
208
|
-
/** Glyph only
|
|
218
|
+
/** Glyph only on its equalised square viewBox, painted with `color` (default `currentColor`). */
|
|
209
219
|
export function appMarkSvg(app: AppName, color = "currentColor"): string {
|
|
210
220
|
return `<svg ${NS} viewBox="${APP_MARK_VIEWBOX[app]}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
|
|
211
221
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AppName } from "./svg";
|
|
2
|
+
|
|
3
|
+
/** Benefits for one app, shown under that app's icon and name. */
|
|
4
|
+
export type PricingPlanAppBenefit = {
|
|
5
|
+
app: Exclude<AppName, "projectv">;
|
|
6
|
+
items: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Approved per-app copy for V Pro, in display order. */
|
|
10
|
+
export const V_PRO_APP_BENEFITS: readonly PricingPlanAppBenefit[] = [
|
|
11
|
+
{
|
|
12
|
+
app: "vibot",
|
|
13
|
+
items: [
|
|
14
|
+
"1000 messages / day",
|
|
15
|
+
"100 image gens / day",
|
|
16
|
+
"100 deep & 20 extreme searches / day",
|
|
17
|
+
"Smarter AI for chats, documents & sub-agents",
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{ app: "vmem", items: ["Unlimited memories", "Higher AI search & connected-tool limits"] },
|
|
21
|
+
{ app: "vhub", items: ["Unlimited scripts", "More connections, secrets & API keys"] },
|
|
22
|
+
{ app: "velth", items: ["Unlimited camera scans", "Smarter AI health insights"] },
|
|
23
|
+
{ app: "verve", items: ["Page version history", "Higher AI rewrite & summary limits"] },
|
|
24
|
+
];
|
package/src/index.ts
CHANGED
|
@@ -290,20 +290,21 @@ export {
|
|
|
290
290
|
export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
|
|
291
291
|
export { SiteFooter } from "./marketing/SiteFooter";
|
|
292
292
|
export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
|
|
293
|
-
export { PricingModal } from "./marketing/PricingModal";
|
|
293
|
+
export { PricingModal, V_PRO_APP_BENEFITS } from "./marketing/PricingModal";
|
|
294
294
|
export type {
|
|
295
295
|
PricingModalLegacyProps,
|
|
296
296
|
PricingModalMultiProps,
|
|
297
297
|
PricingModalPeriod,
|
|
298
298
|
PricingModalProps,
|
|
299
299
|
PricingPlan,
|
|
300
|
+
PricingPlanAppBenefit,
|
|
300
301
|
PricingPlanBenefit,
|
|
301
302
|
} from "./marketing/PricingModal";
|
|
302
303
|
|
|
303
304
|
// App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
|
|
304
305
|
export * from "../app/src/index";
|
|
305
306
|
|
|
306
|
-
// Family app marks (AppMark and raw SVG strings for favicons).
|
|
307
|
+
// Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
|
|
307
308
|
export * from "./brand";
|
|
308
309
|
|
|
309
310
|
// Native tokens (JS values that match tokens.css).
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import { IconCheck, IconX } from "@tabler/icons-react";
|
|
3
|
-
import {
|
|
4
|
-
import type { AppName } from "../brand/svg";
|
|
3
|
+
import { APP_ICONS, IconProjectV, V_PRO_APP_BENEFITS, familyApp, type AppIconProps, type AppName, type PricingPlanAppBenefit } from "../brand";
|
|
5
4
|
import { Button } from "../ui/button";
|
|
6
5
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
|
7
|
+
|
|
8
|
+
export { V_PRO_APP_BENEFITS };
|
|
9
|
+
export type { PricingPlanAppBenefit };
|
|
7
10
|
import { VisuallyHidden } from "../ui/visually-hidden";
|
|
8
11
|
import { pricingModalClasses, pricingMultiClasses, type PricingModalTone } from "./marketing-classes";
|
|
9
12
|
|
|
@@ -75,6 +78,11 @@ export type PricingPlan = {
|
|
|
75
78
|
/** Large title with small text under it. */
|
|
76
79
|
highlight?: { title: string; description: string };
|
|
77
80
|
benefits?: readonly PricingPlanBenefit[];
|
|
81
|
+
/**
|
|
82
|
+
* Grouped per app, shown after `benefits`. The `currentApp` group comes first, the rest keep the array order.
|
|
83
|
+
* The `featured` / `highlighted` plan defaults to `V_PRO_APP_BENEFITS` when this is omitted, so apps usually pass only `currentApp`. Other plans show none unless set.
|
|
84
|
+
*/
|
|
85
|
+
appBenefits?: readonly PricingPlanAppBenefit[];
|
|
78
86
|
/** The plan the user is on: the button reads "Current plan" and is disabled. */
|
|
79
87
|
current?: boolean;
|
|
80
88
|
/** Subtle ring on the card. `highlighted` is an alias. */
|
|
@@ -95,6 +103,8 @@ export type PricingModalMultiProps = {
|
|
|
95
103
|
plans: readonly PricingPlan[];
|
|
96
104
|
/** App marks at the top left. */
|
|
97
105
|
apps?: readonly AppName[];
|
|
106
|
+
/** App the dialog is shown in. Its `appBenefits` group comes first. */
|
|
107
|
+
currentApp?: Exclude<AppName, "projectv">;
|
|
98
108
|
/** Centred headline, for example "One plan. Every V app." */
|
|
99
109
|
headline?: ReactNode;
|
|
100
110
|
/** Small print under the cards. */
|
|
@@ -111,6 +121,16 @@ export type PricingModalMultiProps = {
|
|
|
111
121
|
export type PricingModalProps = PricingModalLegacyProps | PricingModalMultiProps;
|
|
112
122
|
|
|
113
123
|
/** Upgrade dialog. With `plans`: wide multi-plan layout. Without: the single-plan layout. */
|
|
124
|
+
function AppIcon({ app, ...props }: AppIconProps & { app: AppName }) {
|
|
125
|
+
const Icon = APP_ICONS[app];
|
|
126
|
+
return <Icon {...props} />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sortAppBenefits(groups: readonly PricingPlanAppBenefit[], currentApp?: AppName) {
|
|
130
|
+
// Stable sort: the current app moves to the front, the rest keep their order.
|
|
131
|
+
return [...groups].sort((a, b) => Number(b.app === currentApp) - Number(a.app === currentApp));
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
export function PricingModal(props: PricingModalProps) {
|
|
115
135
|
return props.plans ? <PricingModalMulti {...props} /> : <PricingModalSingle {...props} />;
|
|
116
136
|
}
|
|
@@ -259,6 +279,7 @@ function PricingModalMulti({
|
|
|
259
279
|
description,
|
|
260
280
|
plans,
|
|
261
281
|
apps,
|
|
282
|
+
currentApp,
|
|
262
283
|
headline,
|
|
263
284
|
finePrint,
|
|
264
285
|
billingPeriod,
|
|
@@ -293,7 +314,27 @@ function PricingModalMulti({
|
|
|
293
314
|
|
|
294
315
|
<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
316
|
<div className="flex items-center gap-2 text-foreground" data-testid="pricing-apps">
|
|
296
|
-
|
|
317
|
+
<TooltipProvider delayDuration={200}>
|
|
318
|
+
{apps?.map((app) => {
|
|
319
|
+
const { name, url } = familyApp(app);
|
|
320
|
+
return (
|
|
321
|
+
<Tooltip key={app}>
|
|
322
|
+
<TooltipTrigger asChild>
|
|
323
|
+
<a
|
|
324
|
+
href={url}
|
|
325
|
+
target="_blank"
|
|
326
|
+
rel="noopener noreferrer"
|
|
327
|
+
aria-label={name}
|
|
328
|
+
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-foreground/50"
|
|
329
|
+
>
|
|
330
|
+
<AppIcon app={app} size={20} />
|
|
331
|
+
</a>
|
|
332
|
+
</TooltipTrigger>
|
|
333
|
+
<TooltipContent portal>{name}</TooltipContent>
|
|
334
|
+
</Tooltip>
|
|
335
|
+
);
|
|
336
|
+
})}
|
|
337
|
+
</TooltipProvider>
|
|
297
338
|
</div>
|
|
298
339
|
<div className="flex items-center justify-end gap-2 sm:order-last">
|
|
299
340
|
<div role="group" aria-label="Billing period" className="inline-flex rounded-full bg-surface-tertiary p-0.5">
|
|
@@ -332,6 +373,7 @@ function PricingModalMulti({
|
|
|
332
373
|
<div className={`grid grid-cols-1 gap-3 px-4 sm:px-6 ${multiColumns[count]}`}>
|
|
333
374
|
{plans.map((plan) => {
|
|
334
375
|
const emphasised = plan.featured ?? plan.highlighted;
|
|
376
|
+
const appBenefits = plan.appBenefits ?? (emphasised ? V_PRO_APP_BENEFITS : undefined);
|
|
335
377
|
const button = (
|
|
336
378
|
<Button
|
|
337
379
|
size="lg"
|
|
@@ -353,7 +395,7 @@ function PricingModalMulti({
|
|
|
353
395
|
>
|
|
354
396
|
{emphasised ? (
|
|
355
397
|
<div className="flex items-center gap-2 text-base text-foreground">
|
|
356
|
-
<
|
|
398
|
+
<IconProjectV size={16} className="shrink-0" />
|
|
357
399
|
<h3>{plan.name}</h3>
|
|
358
400
|
</div>
|
|
359
401
|
) : (
|
|
@@ -384,7 +426,7 @@ function PricingModalMulti({
|
|
|
384
426
|
>
|
|
385
427
|
<span className="mt-0.5 flex h-4 w-5 shrink-0 items-center justify-center">
|
|
386
428
|
{benefit.icon && benefit.icon !== "check" ? (
|
|
387
|
-
<
|
|
429
|
+
<AppIcon app={benefit.icon} size={12} className="max-w-full" />
|
|
388
430
|
) : (
|
|
389
431
|
<IconCheck className="size-4" strokeWidth={2} />
|
|
390
432
|
)}
|
|
@@ -394,6 +436,25 @@ function PricingModalMulti({
|
|
|
394
436
|
))}
|
|
395
437
|
</ul>
|
|
396
438
|
) : null}
|
|
439
|
+
{appBenefits?.length ? (
|
|
440
|
+
<div className="mt-4 grid gap-x-6 gap-y-2.5 border-t border-separator pt-3 sm:grid-cols-2">
|
|
441
|
+
{sortAppBenefits(appBenefits, currentApp).map((group) => (
|
|
442
|
+
<div key={group.app} data-app-benefits={group.app}>
|
|
443
|
+
<p className="flex items-center gap-2 text-xs font-medium text-foreground">
|
|
444
|
+
<AppIcon app={group.app} size={12} className="shrink-0" />
|
|
445
|
+
{familyApp(group.app).name}
|
|
446
|
+
</p>
|
|
447
|
+
<ul className="mt-1 space-y-0.5 pl-5">
|
|
448
|
+
{group.items.map((item, index) => (
|
|
449
|
+
<li key={index} className="text-sm leading-snug text-foreground">
|
|
450
|
+
{item}
|
|
451
|
+
</li>
|
|
452
|
+
))}
|
|
453
|
+
</ul>
|
|
454
|
+
</div>
|
|
455
|
+
))}
|
|
456
|
+
</div>
|
|
457
|
+
) : null}
|
|
397
458
|
</section>
|
|
398
459
|
);
|
|
399
460
|
})}
|