@vvedantb/ui 0.22.0 → 0.24.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 +53 -10
- package/package.json +1 -1
- package/shell/src/SidebarUpgradeCard.tsx +47 -0
- package/shell/src/index.ts +2 -0
- package/src/brand/AppIcons.tsx +72 -0
- package/src/brand/AppMark.tsx +19 -6
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +9 -0
- package/src/brand/svg.ts +118 -3
- package/src/brand/vProAppBenefits.ts +24 -0
- package/src/index.ts +5 -2
- package/src/marketing/PricingModal.tsx +74 -6
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
4
4
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), animated nav icons from 0.11.0 (`MemoryIcon`, `BoardIcon`, ... and `NavIconBase`; `SettingsIcon` `variant` from 0.12.0), artifact viewers from 0.13.0 (`ArtifactFrame`, `ArtifactList`, `ArtifactViewer`, ...), date pickers from 0.14.0 (`Calendar`, `DatePicker`, `DateTimePicker`, `MonthPicker`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
7
|
+
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `SidebarUpgradeCard`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), animated nav icons from 0.11.0 (`MemoryIcon`, `BoardIcon`, ... and `NavIconBase`; `SettingsIcon` `variant` from 0.12.0), artifact viewers from 0.13.0 (`ArtifactFrame`, `ArtifactList`, `ArtifactViewer`, ...), date pickers from 0.14.0 (`Calendar`, `DatePicker`, `DateTimePicker`, `MonthPicker`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
8
8
|
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
9
9
|
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
10
10
|
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `clear-input`, `motion`, `animations`, `nav-icons`, `compat-shadcn-rgb`, `border-beam` (opt-in, for `BorderBeam`), `markdown` (opt-in, for `Markdown`). CSS. `tokens.css` imports `scrollbar-thin`, `clear-input` and `motion`. |
|
|
@@ -179,6 +179,17 @@ function MentionComposer({ draft, setDraft, send }: Props) {
|
|
|
179
179
|
|
|
180
180
|
Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a `compat-shadcn-rgb.css` page, each with light and dark.
|
|
181
181
|
|
|
182
|
+
## Sidebar upgrade card
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { SidebarUpgradeCard } from "@vvedantb/ui";
|
|
186
|
+
|
|
187
|
+
{!isPro && <SidebarUpgradeCard onUpgrade={() => setPricingOpen(true)} />}
|
|
188
|
+
{!isPro && <SidebarUpgradeCard collapsed onUpgrade={() => setPricingOpen(true)} />}
|
|
189
|
+
```
|
|
190
|
+
|
|
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
|
+
|
|
182
193
|
## Sessions hover card
|
|
183
194
|
|
|
184
195
|
```tsx
|
|
@@ -865,6 +876,42 @@ links under a separator.
|
|
|
865
876
|
| `legalLinks` | `{ href, label }[]`, open in a new tab. |
|
|
866
877
|
| `variant` | `"muted"` (default, `text-muted`), `"muted-foreground"`, or `"touch"` (`text-muted-foreground`, `max-sm:hit-target` links, safe-area bottom padding). |
|
|
867
878
|
|
|
879
|
+
### `Icon*` app icons
|
|
880
|
+
|
|
881
|
+
Tabler-style plain app glyphs: `IconVibot`, `IconVhub`, `IconVmem`, `IconVerve`,
|
|
882
|
+
`IconVelth` and `IconProjectV`. No background, no padding. `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` is the height (default 24) and the width follows the glyph. `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.
|
|
899
|
+
Family app icons: `app` (`projectv`, `vibot`, `vhub`, `vmem`, `verve`, `velth`),
|
|
900
|
+
`variant`, `size`, `title`.
|
|
901
|
+
|
|
902
|
+
- `variant="mark"` (default) is the plain logo in `currentColor`. It is cropped
|
|
903
|
+
tight to the glyph (stroke marks include half the stroke width) with no
|
|
904
|
+
padding. `size` is the rendered height; the width follows the glyph's aspect
|
|
905
|
+
ratio, so it sits level with a wordmark. A string `size` (for example `"2rem"`)
|
|
906
|
+
sets both width and height to that value. `APP_MARK_VIEWBOX` holds the tight
|
|
907
|
+
viewBox per app; `appMarkSvg()` and `APP_MARK_SVG` use it.
|
|
908
|
+
- `variant="tile"` and `"tile-light"` keep the equalised, padded 1024 grid on a
|
|
909
|
+
rounded tile, always square. `appTileSvg`, `appAdaptiveSvg`, `appExtension128Svg`,
|
|
910
|
+
`APP_TILE_SVG`, `APP_TILE_LIGHT_SVG` and favicons are unchanged.
|
|
911
|
+
|
|
912
|
+
Use tiles only where the app shows on its background (app and store icons,
|
|
913
|
+
favicons). Use the plain mark wherever the logo has no background.
|
|
914
|
+
|
|
868
915
|
### `PricingModal`
|
|
869
916
|
|
|
870
917
|
Upgrade dialog with two layouts. Without `plans` it is the single-plan dialog
|
|
@@ -877,7 +924,7 @@ default) and `renderCheckout`.
|
|
|
877
924
|
|
|
878
925
|
#### Multi-plan (0.22.0)
|
|
879
926
|
|
|
880
|
-
Header: `apps` (an `AppName[]`, shown as `
|
|
927
|
+
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`),
|
|
881
928
|
`headline` (centre), a Monthly / Annual switch and a close button (top right).
|
|
882
929
|
Then one card per entry of `plans` (2 to 4 fit) and one `finePrint` line. Cards
|
|
883
930
|
stack on narrow screens.
|
|
@@ -890,6 +937,10 @@ price is the per-month price). `ctaLabel` defaults to "Get started". `featured`
|
|
|
890
937
|
(or `highlighted`) adds a subtle ring. A `current` plan shows a disabled,
|
|
891
938
|
outlined "Current plan" button and does not call `renderCheckout`.
|
|
892
939
|
|
|
940
|
+
`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.
|
|
941
|
+
|
|
942
|
+
`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/`.
|
|
943
|
+
|
|
893
944
|
The `featured` plan's button is a solid primary button (`Button` `variant="default"`). Other plans use `secondary`.
|
|
894
945
|
|
|
895
946
|
`renderCheckout(button, plan, period)` wraps each plan's button.
|
|
@@ -912,14 +963,6 @@ Each app passes its own plan names, prices and copy. The example and the screens
|
|
|
912
963
|
price: { month: "$15", year: "$10" },
|
|
913
964
|
featured: true,
|
|
914
965
|
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
966
|
},
|
|
924
967
|
]}
|
|
925
968
|
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.24.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",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Button } from "../../src/ui/button";
|
|
2
|
+
import { cn } from "../../src/utils/cn";
|
|
3
|
+
|
|
4
|
+
export type SidebarUpgradeCardProps = {
|
|
5
|
+
/** Opens the app's `PricingModal`. */
|
|
6
|
+
onUpgrade: () => void;
|
|
7
|
+
/** Narrow rail: a round "Pro" button (labelled "Upgrade to Pro") instead of the full card. Default `false`. */
|
|
8
|
+
collapsed?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const pillClass = "bg-foreground text-background hover:bg-foreground/85";
|
|
13
|
+
|
|
14
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
export function SidebarUpgradeCard({ onUpgrade, collapsed = false, className }: SidebarUpgradeCardProps) {
|
|
19
|
+
if (collapsed) {
|
|
20
|
+
return (
|
|
21
|
+
<Button
|
|
22
|
+
type="button"
|
|
23
|
+
size="sm"
|
|
24
|
+
aria-label="Upgrade to Pro"
|
|
25
|
+
title="Upgrade to Pro"
|
|
26
|
+
onClick={onUpgrade}
|
|
27
|
+
className={cn("rounded-full px-3", pillClass, className)}
|
|
28
|
+
>
|
|
29
|
+
Pro
|
|
30
|
+
</Button>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-slot="sidebar-upgrade-card"
|
|
36
|
+
className={cn(
|
|
37
|
+
"flex items-center justify-between gap-3 rounded-2xl bg-surface-secondary px-3 py-2.5 text-foreground",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<span className="min-w-0 truncate text-sm font-semibold">Upgrade to Pro</span>
|
|
42
|
+
<Button type="button" size="sm" onClick={onUpgrade} className={cn("shrink-0 rounded-full", pillClass)}>
|
|
43
|
+
Upgrade
|
|
44
|
+
</Button>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
package/shell/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { SidebarHeader } from "./SidebarHeader.js";
|
|
2
2
|
export type { SidebarHeaderProps } from "./SidebarHeader.js";
|
|
3
|
+
export { SidebarUpgradeCard } from "./SidebarUpgradeCard.js";
|
|
4
|
+
export type { SidebarUpgradeCardProps } from "./SidebarUpgradeCard.js";
|
|
3
5
|
export { PillTabs } from "./PillTabs.js";
|
|
4
6
|
export type { PillTabItem, PillTabsProps } from "./PillTabs.js";
|
|
5
7
|
export { cn } from "./cn.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { forwardRef, useId, type ForwardRefExoticComponent, type RefAttributes, type SVGProps } from "react";
|
|
2
|
+
import { APP_MARKS, APP_MARK_VIEWBOX, APP_NAMES, markAspect, markLinecap, markStrokeWidth, type AppName } from "./svg";
|
|
3
|
+
|
|
4
|
+
export type AppIconProps = Omit<SVGProps<SVGSVGElement>, "children" | "height" | "width" | "viewBox" | "stroke" | "color"> & {
|
|
5
|
+
/** Rendered height. Default 24. A number gives the width from the glyph's aspect ratio; a string sets width and height to the same value. */
|
|
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 viewBoxHeight(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 width = typeof size === "number" ? Math.round(size * markAspect(app) * 100) / 100 : size;
|
|
29
|
+
const renderedHeight = typeof size === "number" ? size : 24;
|
|
30
|
+
const strokeWidth = stroke === undefined ? markStrokeWidth(app) : Number(stroke) * (viewBoxHeight(app) / renderedHeight);
|
|
31
|
+
const glyphProps =
|
|
32
|
+
kind === "fill"
|
|
33
|
+
? { fill: color }
|
|
34
|
+
: { fill: "none", stroke: color, strokeWidth, strokeLinecap: markLinecap(app), strokeLinejoin: "round" };
|
|
35
|
+
return (
|
|
36
|
+
<svg
|
|
37
|
+
{...rest}
|
|
38
|
+
ref={ref}
|
|
39
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
40
|
+
width={width}
|
|
41
|
+
height={size}
|
|
42
|
+
viewBox={APP_MARK_VIEWBOX[app]}
|
|
43
|
+
preserveAspectRatio="xMidYMid meet"
|
|
44
|
+
data-app={app}
|
|
45
|
+
role={title ? "img" : undefined}
|
|
46
|
+
aria-hidden={title ? undefined : true}
|
|
47
|
+
aria-labelledby={title ? titleId : undefined}
|
|
48
|
+
>
|
|
49
|
+
{title ? <title id={titleId}>{title}</title> : null}
|
|
50
|
+
<g {...(glyphProps as SVGProps<SVGGElement>)} data-glyph={app}>
|
|
51
|
+
{paths.map((d) => (
|
|
52
|
+
<path key={d} d={d} />
|
|
53
|
+
))}
|
|
54
|
+
</g>
|
|
55
|
+
</svg>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
Icon.displayName = displayName;
|
|
59
|
+
return Icon;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const iconName = (app: AppName) => `Icon${app === "projectv" ? "ProjectV" : app[0]!.toUpperCase() + app.slice(1)}`;
|
|
63
|
+
|
|
64
|
+
/** Plain app glyph components by app name, for dynamic lookup. */
|
|
65
|
+
export const APP_ICONS = Object.fromEntries(APP_NAMES.map((app) => [app, createAppIcon(app, iconName(app))])) as Record<AppName, AppIconComponent>;
|
|
66
|
+
|
|
67
|
+
export const IconVibot = APP_ICONS.vibot;
|
|
68
|
+
export const IconVhub = APP_ICONS.vhub;
|
|
69
|
+
export const IconVmem = APP_ICONS.vmem;
|
|
70
|
+
export const IconVerve = APP_ICONS.verve;
|
|
71
|
+
export const IconVelth = APP_ICONS.velth;
|
|
72
|
+
export const IconProjectV = APP_ICONS.projectv;
|
package/src/brand/AppMark.tsx
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { useId, type SVGProps } from "react";
|
|
2
|
+
import { APP_ICONS } from "./AppIcons";
|
|
2
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):
|
|
8
|
+
/** `"mark"` (default): plain logo in `currentColor`, cropped tight to the glyph with no padding. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
|
|
8
9
|
variant?: AppMarkVariant;
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Default 24. For `"mark"` it sets the rendered height and the width follows the glyph's aspect ratio
|
|
12
|
+
* (a number gives an exact width; a string sets both to the same value and the glyph is centred).
|
|
13
|
+
* Tiles are always square, `size` by `size`.
|
|
14
|
+
*/
|
|
10
15
|
size?: number | string;
|
|
11
16
|
/** Accessible name. Without it the mark is `aria-hidden`. */
|
|
12
17
|
title?: string;
|
|
@@ -17,12 +22,19 @@ const TILE_TONES = {
|
|
|
17
22
|
"tile-light": { bg: "#fff", fg: "#000" },
|
|
18
23
|
} as const;
|
|
19
24
|
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* One of the six family app marks. The plain mark is cropped tight; tiles use the padded 1024 grid.
|
|
27
|
+
* @deprecated Use the per-app `Icon*` components (or `APP_ICONS[app]`) for plain marks. Tiles are for app and store icons only.
|
|
28
|
+
*/
|
|
21
29
|
export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: AppMarkProps) {
|
|
22
30
|
const titleId = useId();
|
|
31
|
+
if (variant === "mark") {
|
|
32
|
+
const Icon = APP_ICONS[app];
|
|
33
|
+
return <Icon {...rest} size={size} title={title} data-variant="mark" />;
|
|
34
|
+
}
|
|
23
35
|
const { kind, paths } = APP_MARKS[app];
|
|
24
|
-
const tone =
|
|
25
|
-
const color = tone
|
|
36
|
+
const tone = TILE_TONES[variant];
|
|
37
|
+
const color = tone.fg;
|
|
26
38
|
const glyphProps =
|
|
27
39
|
kind === "fill"
|
|
28
40
|
? { fill: color }
|
|
@@ -41,6 +53,7 @@ export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: Ap
|
|
|
41
53
|
width={size}
|
|
42
54
|
height={size}
|
|
43
55
|
viewBox={MARK_VIEWBOX}
|
|
56
|
+
preserveAspectRatio="xMidYMid meet"
|
|
44
57
|
data-app={app}
|
|
45
58
|
data-variant={variant}
|
|
46
59
|
role={title ? "img" : undefined}
|
|
@@ -48,7 +61,7 @@ export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: Ap
|
|
|
48
61
|
aria-labelledby={title ? titleId : undefined}
|
|
49
62
|
>
|
|
50
63
|
{title ? <title id={titleId}>{title}</title> : null}
|
|
51
|
-
|
|
64
|
+
<rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} />
|
|
52
65
|
{glyph}
|
|
53
66
|
</svg>
|
|
54
67
|
);
|
|
@@ -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,7 +1,9 @@
|
|
|
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_VIEWBOX,
|
|
5
7
|
APP_MARK_SVG,
|
|
6
8
|
APP_NAMES,
|
|
7
9
|
APP_TILE_LIGHT_SVG,
|
|
@@ -10,7 +12,14 @@ export {
|
|
|
10
12
|
appExtension128Svg,
|
|
11
13
|
appMarkSvg,
|
|
12
14
|
appTileSvg,
|
|
15
|
+
markAspect,
|
|
16
|
+
markBBox,
|
|
13
17
|
markLinecap,
|
|
14
18
|
markStrokeWidth,
|
|
19
|
+
pathBBox,
|
|
15
20
|
} from "./svg";
|
|
16
21
|
export type { AppMarkVariant, AppName, MarkShape } from "./svg";
|
|
22
|
+
export { FAMILY_APPS, familyApp } from "./familyApps";
|
|
23
|
+
export type { FamilyApp } from "./familyApps";
|
|
24
|
+
export { V_PRO_APP_BENEFITS } from "./vProAppBenefits";
|
|
25
|
+
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, follows `currentColor`. `tile`: white glyph on a black tile. `tile-light`: black glyph on a white tile. */
|
|
8
|
+
/** `mark`: glyph only, cropped tight, 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";
|
|
@@ -79,6 +79,121 @@ export const APP_MARKS: Record<AppName, MarkShape> = {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
type Point = [number, number];
|
|
83
|
+
type Box = [minX: number, minY: number, maxX: number, maxY: number];
|
|
84
|
+
|
|
85
|
+
/** Roots in (0, 1) of the derivative of a Bezier with 1D control values `v` (quadratic or cubic). */
|
|
86
|
+
function bezierExtrema(v: number[]): number[] {
|
|
87
|
+
const roots: number[] = [];
|
|
88
|
+
if (v.length === 3) {
|
|
89
|
+
const d = v[0]! - 2 * v[1]! + v[2]!;
|
|
90
|
+
if (d !== 0) roots.push((v[0]! - v[1]!) / d);
|
|
91
|
+
} else {
|
|
92
|
+
const a = -v[0]! + 3 * v[1]! - 3 * v[2]! + v[3]!;
|
|
93
|
+
const b = 2 * (v[0]! - 2 * v[1]! + v[2]!);
|
|
94
|
+
const c = v[1]! - v[0]!;
|
|
95
|
+
if (Math.abs(a) < 1e-12) {
|
|
96
|
+
if (b !== 0) roots.push(-c / b);
|
|
97
|
+
} else {
|
|
98
|
+
const disc = b * b - 4 * a * c;
|
|
99
|
+
if (disc >= 0) roots.push((-b + Math.sqrt(disc)) / (2 * a), (-b - Math.sqrt(disc)) / (2 * a));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return roots.filter((t) => t > 0 && t < 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bezierAt(v: number[], t: number): number {
|
|
106
|
+
const u = 1 - t;
|
|
107
|
+
const [p0, p1, p2, p3] = v as [number, number, number, number];
|
|
108
|
+
return v.length === 3 ? u * u * p0 + 2 * u * t * p1 + t * t * p2 : u ** 3 * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t ** 3 * p3;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extreme points of an SVG elliptical arc (endpoint form, no rotation), including both ends. */
|
|
112
|
+
function arcPoints(from: Point, [rx0, ry0, , large, sweep, x, y]: [number, number, number, number, number, number, number]): Point[] {
|
|
113
|
+
const [x0, y0] = from;
|
|
114
|
+
let rx = Math.abs(rx0);
|
|
115
|
+
let ry = Math.abs(ry0);
|
|
116
|
+
const dx = (x0 - x) / 2;
|
|
117
|
+
const dy = (y0 - y) / 2;
|
|
118
|
+
const lambda = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry);
|
|
119
|
+
if (lambda > 1) {
|
|
120
|
+
rx *= Math.sqrt(lambda);
|
|
121
|
+
ry *= Math.sqrt(lambda);
|
|
122
|
+
}
|
|
123
|
+
const num = rx * rx * ry * ry - rx * rx * dy * dy - ry * ry * dx * dx;
|
|
124
|
+
const den = rx * rx * dy * dy + ry * ry * dx * dx;
|
|
125
|
+
const k = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
|
|
126
|
+
const cx = k * ((rx * dy) / ry) + (x0 + x) / 2;
|
|
127
|
+
const cy = k * (-(ry * dx) / rx) + (y0 + y) / 2;
|
|
128
|
+
const a0 = Math.atan2((y0 - cy) / ry, (x0 - cx) / rx);
|
|
129
|
+
let delta = Math.atan2((y - cy) / ry, (x - cx) / rx) - a0;
|
|
130
|
+
if (sweep && delta < 0) delta += 2 * Math.PI;
|
|
131
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI;
|
|
132
|
+
const points: Point[] = [from, [x, y]];
|
|
133
|
+
for (let q = -6; q <= 6; q++) {
|
|
134
|
+
const a = (q * Math.PI) / 2;
|
|
135
|
+
const t = (a - a0) / delta;
|
|
136
|
+
if (t > 0 && t < 1) points.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
|
|
137
|
+
}
|
|
138
|
+
return points;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Exact bounding box of absolute `M`, `L`, `Q`, `C` and `A` path data (the only commands the marks use). Returns [minX, minY, maxX, maxY]. */
|
|
142
|
+
export function pathBBox(d: string): Box {
|
|
143
|
+
const tokens = d.match(/[MLQCA]|-?\d*\.?\d+(?:e-?\d+)?/g) ?? [];
|
|
144
|
+
const points: Point[] = [];
|
|
145
|
+
let cmd = "";
|
|
146
|
+
let cur: Point = [0, 0];
|
|
147
|
+
const argc: Record<string, number> = { M: 2, L: 2, Q: 4, C: 6, A: 7 };
|
|
148
|
+
for (let i = 0; i < tokens.length; ) {
|
|
149
|
+
if (/[A-Z]/.test(tokens[i]!)) cmd = tokens[i++]!;
|
|
150
|
+
const n = argc[cmd];
|
|
151
|
+
if (!n) throw new Error(`Unsupported path command: ${cmd}`);
|
|
152
|
+
const a = tokens.slice(i, i + n).map(Number);
|
|
153
|
+
i += n;
|
|
154
|
+
const end: Point = [a[n - 2]!, a[n - 1]!];
|
|
155
|
+
if (cmd === "Q" || cmd === "C") {
|
|
156
|
+
const xs = [cur[0], ...a.filter((_, j) => j % 2 === 0)];
|
|
157
|
+
const ys = [cur[1], ...a.filter((_, j) => j % 2 === 1)];
|
|
158
|
+
for (const t of new Set([...bezierExtrema(xs), ...bezierExtrema(ys)])) points.push([bezierAt(xs, t), bezierAt(ys, t)]);
|
|
159
|
+
} else if (cmd === "A") {
|
|
160
|
+
points.push(...arcPoints(cur, a as Parameters<typeof arcPoints>[1]));
|
|
161
|
+
}
|
|
162
|
+
points.push(end);
|
|
163
|
+
cur = end;
|
|
164
|
+
}
|
|
165
|
+
return [Math.min(...points.map((p) => p[0])), Math.min(...points.map((p) => p[1])), Math.max(...points.map((p) => p[0])), Math.max(...points.map((p) => p[1]))];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Tight box of a mark: union of its paths, grown by half the stroke width for stroke marks. Returns [minX, minY, maxX, maxY]. */
|
|
169
|
+
export function markBBox(app: AppName): Box {
|
|
170
|
+
const { kind, paths } = APP_MARKS[app];
|
|
171
|
+
const boxes = paths.map(pathBBox);
|
|
172
|
+
const pad = kind === "stroke" ? markStrokeWidth(app) / 2 : 0;
|
|
173
|
+
return [Math.min(...boxes.map((b) => b[0])) - pad, Math.min(...boxes.map((b) => b[1])) - pad, Math.max(...boxes.map((b) => b[2])) + pad, Math.max(...boxes.map((b) => b[3])) + pad];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tight viewBox per app (`minX minY width height` on the 1024 grid): the glyph box grown by half the
|
|
179
|
+
* stroke width for stroke marks, rounded outward to whole units. Checked against `markBBox` in tests.
|
|
180
|
+
* Used by the plain logo (`variant="mark"`, `appMarkSvg`). Tiles keep `MARK_VIEWBOX`.
|
|
181
|
+
*/
|
|
182
|
+
export const APP_MARK_VIEWBOX: Record<AppName, string> = {
|
|
183
|
+
projectv: "172 377 686 295",
|
|
184
|
+
vibot: "295 314 494 398",
|
|
185
|
+
vhub: "186 338 582 348",
|
|
186
|
+
vmem: "255 259 513 503",
|
|
187
|
+
verve: "260 319 504 413",
|
|
188
|
+
velth: "273 276 478 482",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Width over height of a mark's tight viewBox. */
|
|
192
|
+
export function markAspect(app: AppName): number {
|
|
193
|
+
const [, , w = 1, h = 1] = APP_MARK_VIEWBOX[app].split(" ").map(Number);
|
|
194
|
+
return w / h;
|
|
195
|
+
}
|
|
196
|
+
|
|
82
197
|
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
|
83
198
|
|
|
84
199
|
/** Attributes that paint the glyph in `color`. */
|
|
@@ -90,9 +205,9 @@ function pathElements(app: AppName): string {
|
|
|
90
205
|
return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
|
|
91
206
|
}
|
|
92
207
|
|
|
93
|
-
/** Glyph only, painted with `color` (default `currentColor`). */
|
|
208
|
+
/** Glyph only, cropped tight with no padding, painted with `color` (default `currentColor`). */
|
|
94
209
|
export function appMarkSvg(app: AppName, color = "currentColor"): string {
|
|
95
|
-
return `<svg ${NS} viewBox="${
|
|
210
|
+
return `<svg ${NS} viewBox="${APP_MARK_VIEWBOX[app]}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
|
|
96
211
|
}
|
|
97
212
|
|
|
98
213
|
/** Tile with a glyph. `radius` 0 gives a full-bleed square (apple-touch, maskable). */
|
|
@@ -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
|
@@ -181,6 +181,8 @@ export { toast } from "sonner";
|
|
|
181
181
|
// use the root `cn` (clsx + tailwind-merge).
|
|
182
182
|
export { SidebarHeader } from "../shell/src/SidebarHeader";
|
|
183
183
|
export type { SidebarHeaderProps } from "../shell/src/SidebarHeader";
|
|
184
|
+
export { SidebarUpgradeCard } from "../shell/src/SidebarUpgradeCard";
|
|
185
|
+
export type { SidebarUpgradeCardProps } from "../shell/src/SidebarUpgradeCard";
|
|
184
186
|
export { PillTabs } from "../shell/src/PillTabs";
|
|
185
187
|
export type { PillTabItem, PillTabsProps } from "../shell/src/PillTabs";
|
|
186
188
|
export { SessionsHoverCard } from "../shell/src/SessionsHoverCard";
|
|
@@ -288,20 +290,21 @@ export {
|
|
|
288
290
|
export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
|
|
289
291
|
export { SiteFooter } from "./marketing/SiteFooter";
|
|
290
292
|
export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
|
|
291
|
-
export { PricingModal } from "./marketing/PricingModal";
|
|
293
|
+
export { PricingModal, V_PRO_APP_BENEFITS } from "./marketing/PricingModal";
|
|
292
294
|
export type {
|
|
293
295
|
PricingModalLegacyProps,
|
|
294
296
|
PricingModalMultiProps,
|
|
295
297
|
PricingModalPeriod,
|
|
296
298
|
PricingModalProps,
|
|
297
299
|
PricingPlan,
|
|
300
|
+
PricingPlanAppBenefit,
|
|
298
301
|
PricingPlanBenefit,
|
|
299
302
|
} from "./marketing/PricingModal";
|
|
300
303
|
|
|
301
304
|
// App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
|
|
302
305
|
export * from "../app/src/index";
|
|
303
306
|
|
|
304
|
-
// Family app marks (AppMark and raw SVG strings for favicons).
|
|
307
|
+
// Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
|
|
305
308
|
export * from "./brand";
|
|
306
309
|
|
|
307
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"
|
|
@@ -351,7 +393,14 @@ function PricingModalMulti({
|
|
|
351
393
|
emphasised ? "ring-1 ring-foreground/25" : "ring-1 ring-separator"
|
|
352
394
|
}`}
|
|
353
395
|
>
|
|
354
|
-
|
|
396
|
+
{emphasised ? (
|
|
397
|
+
<div className="flex items-center gap-2 text-base text-foreground">
|
|
398
|
+
<IconProjectV size={16} className="shrink-0" />
|
|
399
|
+
<h3>{plan.name}</h3>
|
|
400
|
+
</div>
|
|
401
|
+
) : (
|
|
402
|
+
<h3 className="text-base text-foreground">{plan.name}</h3>
|
|
403
|
+
)}
|
|
355
404
|
<p className="mt-4 flex items-baseline gap-1">
|
|
356
405
|
<span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
|
|
357
406
|
{plan.price[period]}
|
|
@@ -375,9 +424,9 @@ function PricingModalMulti({
|
|
|
375
424
|
benefit.muted ? c.suffix : "text-foreground"
|
|
376
425
|
}`}
|
|
377
426
|
>
|
|
378
|
-
<span className="mt-0.5 flex
|
|
427
|
+
<span className="mt-0.5 flex h-4 w-5 shrink-0 items-center justify-center">
|
|
379
428
|
{benefit.icon && benefit.icon !== "check" ? (
|
|
380
|
-
<
|
|
429
|
+
<AppIcon app={benefit.icon} size={12} className="max-w-full" />
|
|
381
430
|
) : (
|
|
382
431
|
<IconCheck className="size-4" strokeWidth={2} />
|
|
383
432
|
)}
|
|
@@ -387,6 +436,25 @@ function PricingModalMulti({
|
|
|
387
436
|
))}
|
|
388
437
|
</ul>
|
|
389
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}
|
|
390
458
|
</section>
|
|
391
459
|
);
|
|
392
460
|
})}
|