@vvedantb/ui 0.7.2 → 0.9.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.
@@ -0,0 +1,89 @@
1
+ export type CompactRelativeTimeOptions = {
2
+ /** Clock in ms. Defaults to `Date.now()`. */
3
+ now?: number;
4
+ /**
5
+ * Returned for anything under a minute old, including future times. When
6
+ * unset the result is whole seconds (`"42s"`), and a future time gives zero
7
+ * or a negative count (`"0s"`, `"-300s"`).
8
+ */
9
+ justNow?: string;
10
+ /**
11
+ * `false` (default): elapsed time only. A day is 24 hours, a month 30 days,
12
+ * a year 12 of those months.
13
+ * `true`: days count local calendar days across a DST change, and months and
14
+ * years are calendar months, as `dayjs().diff(date, unit)` counts them.
15
+ */
16
+ calendar?: boolean;
17
+ };
18
+
19
+ const MS_PER_MINUTE = 60_000;
20
+ const MS_PER_DAY = 86_400_000;
21
+
22
+ /** Rounds toward zero; `-0` becomes `0`. */
23
+ function truncate(n: number): number {
24
+ return n < 0 ? Math.ceil(n) || 0 : Math.floor(n);
25
+ }
26
+
27
+ /** Local UTC offset in minutes, rounded to 15 minutes. */
28
+ function utcOffset(ms: number): number {
29
+ return -Math.round(new Date(ms).getTimezoneOffset() / 15) * 15;
30
+ }
31
+
32
+ /** Adds calendar months in local time and clamps the day to the target month. */
33
+ function addMonths(ms: number, months: number): number {
34
+ const date = new Date(ms);
35
+ const day = date.getDate();
36
+ date.setDate(1);
37
+ date.setMonth(date.getMonth() + months);
38
+ const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
39
+ date.setDate(Math.min(day, daysInMonth));
40
+ return date.getTime();
41
+ }
42
+
43
+ /** Fractional calendar months from `a` to `b`, negated (the moment.js formula). */
44
+ function monthDiff(a: number, b: number): number {
45
+ if (new Date(a).getDate() < new Date(b).getDate()) return -monthDiff(b, a);
46
+ const from = new Date(a);
47
+ const to = new Date(b);
48
+ const whole = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
49
+ const anchor = addMonths(a, whole);
50
+ const before = b - anchor < 0;
51
+ const anchor2 = addMonths(a, whole + (before ? -1 : 1));
52
+ return +(-(whole + (b - anchor) / (before ? anchor - anchor2 : anchor2 - anchor)) || 0);
53
+ }
54
+
55
+ /**
56
+ * Time since `date` as one compact unit: `"now"` or `"42s"`, then `"5m"`,
57
+ * `"3h"`, `"2d"`, `"4mo"`, `"1y"`. Strings are parsed with `new Date(string)`.
58
+ * An invalid date gives `"NaNy"`, or `"0mo"` with `calendar`.
59
+ */
60
+ export function formatCompactRelativeTime(
61
+ date: string | number | Date,
62
+ options: CompactRelativeTimeOptions = {},
63
+ ): string {
64
+ const now = options.now ?? Date.now();
65
+ const then = new Date(date).getTime();
66
+ const diff = now - then;
67
+
68
+ const seconds = truncate(diff / 1000);
69
+ if (seconds < 60) return options.justNow ?? `${seconds}s`;
70
+ const minutes = truncate(diff / MS_PER_MINUTE);
71
+ if (minutes < 60) return `${minutes}m`;
72
+ const hours = truncate(diff / (60 * MS_PER_MINUTE));
73
+ if (hours < 24) return `${hours}h`;
74
+
75
+ if (!options.calendar) {
76
+ const days = Math.floor(hours / 24);
77
+ if (days < 30) return `${days}d`;
78
+ const months = Math.floor(days / 30);
79
+ if (months < 12) return `${months}mo`;
80
+ return `${Math.floor(months / 12)}y`;
81
+ }
82
+
83
+ const zoneDelta = (utcOffset(then) - utcOffset(now)) * MS_PER_MINUTE;
84
+ const days = truncate((diff - zoneDelta) / MS_PER_DAY);
85
+ if (days < 30) return `${days}d`;
86
+ const months = monthDiff(now, then);
87
+ if (truncate(months) < 12) return `${truncate(months)}mo`;
88
+ return `${truncate(months / 12)}y`;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -35,6 +35,7 @@
35
35
  "types": "./tokens/src/tailwind-preset.d.mts",
36
36
  "default": "./tokens/src/tailwind-preset.mjs"
37
37
  },
38
+ "./landing.css": "./src/marketing/landing.css",
38
39
  "./package.json": "./package.json"
39
40
  },
40
41
  "types": "./src/index.ts",
@@ -50,13 +51,14 @@
50
51
  "!**/*.test.tsx"
51
52
  ],
52
53
  "sideEffects": [
53
- "./src/ui/sonner.css"
54
+ "./src/ui/sonner.css",
55
+ "./src/marketing/landing.css"
54
56
  ],
55
57
  "scripts": {
56
58
  "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
57
59
  "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
58
60
  "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
59
- "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
61
+ "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
60
62
  "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
61
63
  "analyze": "node tokens/verify/analyze.mjs",
62
64
  "prepack": "node tokens/scripts/check-exports.mjs"
@@ -140,10 +142,12 @@
140
142
  "class-variance-authority": "^0.7.1",
141
143
  "clsx": "^2.1.1",
142
144
  "cmdk": "^1.1.1",
145
+ "dayjs": "1.11.19",
143
146
  "embla-carousel-react": "^8.6.0",
144
147
  "happy-dom": "^18.0.0",
145
148
  "motion": "^12.0.0",
146
149
  "nanoid": "^5.1.0",
150
+ "next-themes": "0.4.6",
147
151
  "radix-ui": "^1.4.0",
148
152
  "react": "^19.1.0",
149
153
  "react-dom": "^19.1.0",
@@ -1,14 +1,19 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
2
+ import { IconLogout, IconMoon, IconSun } from "@tabler/icons-react";
3
+ import { ConfirmSkipHint, skipConfirmTitle } from "../../app/src/confirm/ConfirmSkipHint";
4
+ import { useRequestConfirm } from "../../app/src/confirm/useRequestConfirm";
2
5
  import { Button, type ButtonProps } from "../../src/ui/button";
3
6
  import {
4
7
  DropdownMenu,
5
8
  DropdownMenuContent,
9
+ DropdownMenuItem,
6
10
  DropdownMenuLabel,
7
11
  DropdownMenuSeparator,
8
12
  DropdownMenuTrigger,
9
13
  } from "../../src/ui/dropdown-menu";
10
14
  import { cn } from "../../src/utils/cn";
11
15
  import { accountEmailClass, railTileClass, railTileStateClass } from "./app-shell-classes";
16
+ import { SignOutDialog } from "./SignOutDialog";
12
17
 
13
18
  type ContentProps = ComponentPropsWithoutRef<typeof DropdownMenuContent>;
14
19
 
@@ -45,6 +50,20 @@ export type SidebarAccountMenuProps = {
45
50
  >;
46
51
  /** Menu items after the label and separator. */
47
52
  children?: ReactNode;
53
+ /** Adds a "Light mode" / "Dark mode" item after `children`. */
54
+ themeToggle?: { isDark: boolean; onToggle: () => void };
55
+ /**
56
+ * Adds a separator and a danger "Sign out" item last. Selecting it opens
57
+ * `SignOutDialog` (Alt-click skips it, with `ConfirmSkipHint`). The menu owns
58
+ * the dialog state; `onSignOut` failing re-enables the dialog.
59
+ */
60
+ signOut?: {
61
+ onSignOut: () => Promise<void>;
62
+ /** Second line of the dialog. */
63
+ description?: string;
64
+ };
65
+ /** `size` of the theme and sign-out item icons. Default: the icon's own size (24). */
66
+ itemIconSize?: number;
48
67
  };
49
68
 
50
69
  /** Account menu on a rail tile: avatar trigger, identity label, then the app's items. Composed from `Sidebar.Account*` parts. */
@@ -62,8 +81,25 @@ export function SidebarAccountMenu({
62
81
  triggerProps,
63
82
  contentProps,
64
83
  children,
84
+ themeToggle,
85
+ signOut,
86
+ itemIconSize,
65
87
  }: SidebarAccountMenuProps) {
66
- return (
88
+ const [signOutOpen, setSignOutOpen] = useState(false);
89
+ const [signingOut, setSigningOut] = useState(false);
90
+ const { request } = useRequestConfirm();
91
+
92
+ const handleSignOut = async () => {
93
+ if (!signOut) return;
94
+ setSigningOut(true);
95
+ try {
96
+ await signOut.onSignOut();
97
+ } catch {
98
+ setSigningOut(false);
99
+ }
100
+ };
101
+
102
+ const menu = (
67
103
  <AccountMenu badge={badge}>
68
104
  <AccountTrigger {...triggerProps} userName={name} imageUrl={imageUrl} />
69
105
  <AccountContent
@@ -77,9 +113,49 @@ export function SidebarAccountMenu({
77
113
  <AccountLabel userName={name} email={email} imageUrl={imageUrl} stacked={stackedLabel} />
78
114
  <AccountSeparator />
79
115
  {children}
116
+ {themeToggle ? (
117
+ <DropdownMenuItem onSelect={() => themeToggle.onToggle()}>
118
+ {themeToggle.isDark ? <IconSun size={itemIconSize} /> : <IconMoon size={itemIconSize} />}
119
+ {themeToggle.isDark ? "Light mode" : "Dark mode"}
120
+ </DropdownMenuItem>
121
+ ) : null}
122
+ {signOut ? (
123
+ <>
124
+ <DropdownMenuSeparator />
125
+ <DropdownMenuItem
126
+ onSelect={() =>
127
+ request(
128
+ () => setSignOutOpen(true),
129
+ () => {
130
+ void handleSignOut();
131
+ },
132
+ )
133
+ }
134
+ className="text-danger focus:text-danger data-[highlighted]:text-danger"
135
+ title={skipConfirmTitle("Sign out")}
136
+ >
137
+ <IconLogout size={itemIconSize} />
138
+ Sign out
139
+ <ConfirmSkipHint />
140
+ </DropdownMenuItem>
141
+ </>
142
+ ) : null}
80
143
  </AccountContent>
81
144
  </AccountMenu>
82
145
  );
146
+ if (!signOut) return menu;
147
+ return (
148
+ <>
149
+ {menu}
150
+ <SignOutDialog
151
+ open={signOutOpen}
152
+ onOpenChange={setSignOutOpen}
153
+ pending={signingOut}
154
+ onConfirm={handleSignOut}
155
+ description={signOut.description}
156
+ />
157
+ </>
158
+ );
83
159
  }
84
160
 
85
161
  export type SidebarAccountMenuRootProps = ComponentPropsWithoutRef<typeof DropdownMenu> & {
@@ -1,6 +1,7 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  import { Slot, Slottable } from "@radix-ui/react-slot";
3
3
  import * as TabsPrimitive from "@radix-ui/react-tabs";
4
+ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpandFilled } from "@tabler/icons-react";
4
5
  import { cn } from "../../src/utils/cn";
5
6
  import { railTileClass, railTileStateClass } from "./app-shell-classes";
6
7
  import { SidebarIconTooltip } from "./SidebarIconTooltip";
@@ -199,3 +200,22 @@ export type RailDividerProps = { className?: string };
199
200
  export function RailDivider({ className }: RailDividerProps) {
200
201
  return <RailSeparator className={className} />;
201
202
  }
203
+
204
+ export type RailCollapseTileProps = {
205
+ /** Panel is collapsed: shows the expand icon and "Show sidebar". */
206
+ collapsed: boolean;
207
+ onToggle: () => void;
208
+ };
209
+
210
+ /** Rail tile that shows or hides the sidebar panel. */
211
+ export function RailCollapseTile({ collapsed, onToggle }: RailCollapseTileProps) {
212
+ return (
213
+ <RailItem label={collapsed ? "Show sidebar" : "Hide sidebar"} onClick={onToggle}>
214
+ {collapsed ? (
215
+ <IconLayoutSidebarLeftExpandFilled className="size-[22px]" />
216
+ ) : (
217
+ <IconLayoutSidebarLeftCollapse className="size-[22px]" />
218
+ )}
219
+ </RailItem>
220
+ );
221
+ }
@@ -22,8 +22,8 @@ export { AppSidebar } from "./AppSidebar.js";
22
22
  export type { AppSidebarProps } from "./AppSidebar.js";
23
23
  // Composable sidebar parts (`Sidebar.Root`, `Sidebar.Rail`, ...) and their prop types.
24
24
  export * from "./Sidebar.js";
25
- export { SidebarRail, RailTile, RailDivider } from "./SidebarRail.js";
26
- export type { SidebarRailProps, RailTileProps, RailDividerProps } from "./SidebarRail.js";
25
+ export { SidebarRail, RailTile, RailDivider, RailCollapseTile } from "./SidebarRail.js";
26
+ export type { SidebarRailProps, RailTileProps, RailDividerProps, RailCollapseTileProps } from "./SidebarRail.js";
27
27
  export { SidebarResizeHandle, useAppShellResize } from "./SidebarResize.js";
28
28
  export type {
29
29
  AppShellResize,
package/src/index.ts CHANGED
@@ -135,8 +135,13 @@ export { AppSidebar } from "../shell/src/AppSidebar";
135
135
  export type { AppSidebarProps } from "../shell/src/AppSidebar";
136
136
  // Composable sidebar parts (`Sidebar.Root`, `Sidebar.Rail`, ...) and their prop types.
137
137
  export * from "../shell/src/Sidebar";
138
- export { SidebarRail, RailTile, RailDivider } from "../shell/src/SidebarRail";
139
- export type { SidebarRailProps, RailTileProps, RailDividerProps } from "../shell/src/SidebarRail";
138
+ export { SidebarRail, RailTile, RailDivider, RailCollapseTile } from "../shell/src/SidebarRail";
139
+ export type {
140
+ SidebarRailProps,
141
+ RailTileProps,
142
+ RailDividerProps,
143
+ RailCollapseTileProps,
144
+ } from "../shell/src/SidebarRail";
140
145
  export { SidebarResizeHandle, useAppShellResize } from "../shell/src/SidebarResize";
141
146
  export type {
142
147
  AppShellResize,
@@ -172,6 +177,51 @@ export {
172
177
  } from "../shell/src/app-shell-classes";
173
178
  export type { SidebarNavItemClassOptions } from "../shell/src/app-shell-classes";
174
179
 
180
+ // Marketing: landing kit, site footer and pricing dialog. Router- and Motion-free;
181
+ // `createLandingKit` takes the app's `motion`. Styles: `@vvedantb/ui/landing.css`.
182
+ export {
183
+ LandingFeatureCard,
184
+ LandingFooter,
185
+ LandingLattice,
186
+ LandingNavMenu,
187
+ LandingPageLayout,
188
+ LandingSection,
189
+ LandingSectionHeading,
190
+ landingItemVariants,
191
+ } from "./marketing/landing";
192
+ export type {
193
+ LandingFeature,
194
+ LandingFooterProps,
195
+ LandingLatticeProps,
196
+ LandingLink,
197
+ LandingNavMenuProps,
198
+ LandingPageLayoutProps,
199
+ LandingSectionHeadingProps,
200
+ LandingSectionProps,
201
+ } from "./marketing/landing";
202
+ export { createLandingKit } from "./marketing/createLandingKit";
203
+ export type {
204
+ LandingCtaProps,
205
+ LandingHeroProps,
206
+ LandingKit,
207
+ LandingKitOptions,
208
+ LandingMotion,
209
+ LandingNavProps,
210
+ LandingRevealItemProps,
211
+ LandingRevealProps,
212
+ } from "./marketing/createLandingKit";
213
+ export {
214
+ landingFooterButtonClass,
215
+ landingFooterLinkClass,
216
+ landingPanelClass,
217
+ landingShellClass,
218
+ } from "./marketing/marketing-classes";
219
+ export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
220
+ export { SiteFooter } from "./marketing/SiteFooter";
221
+ export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
222
+ export { PricingModal } from "./marketing/PricingModal";
223
+ export type { PricingModalProps } from "./marketing/PricingModal";
224
+
175
225
  // App helpers: confirm skip, update watcher, settings chrome, stable auth.
176
226
  export * from "../app/src/index";
177
227
 
@@ -0,0 +1,126 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { IconCheck, IconX } from "@tabler/icons-react";
3
+ import { Button } from "../ui/button";
4
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
5
+ import { VisuallyHidden } from "../ui/visually-hidden";
6
+ import { pricingModalClasses, type PricingModalTone } from "./marketing-classes";
7
+
8
+ export type PricingModalProps = {
9
+ open: boolean;
10
+ /** Called when the dialog asks to close and by the `closeButton`. */
11
+ onClose: () => void;
12
+ /** Accessible dialog title, for example "Pro". Not visible. */
13
+ title: string;
14
+ /** Accessible description. Not visible. When set, the title is an `sr-only` `DialogTitle` followed by this. */
15
+ description?: string;
16
+ /** Logo before the wordmark: `dark` shows in dark mode, `light` in light mode. 28px. */
17
+ logo: { dark: string; light: string };
18
+ /** Text after the logo, before the PRO badge. */
19
+ wordmark: string;
20
+ /** For example "$9". */
21
+ price: string;
22
+ tagline: ReactNode;
23
+ /** Extra font class on the tagline, placed after `max-w-[28ch]`. */
24
+ taglineFontClassName?: string;
25
+ /** Plan features under "What you get". */
26
+ features: readonly string[];
27
+ /** Label of the full-width checkout button. Default "Get Pro". */
28
+ ctaLabel?: string;
29
+ /** Wraps the checkout button, for example in the billing provider's checkout and signed-in gates. */
30
+ renderCheckout: (button: ReactElement) => ReactNode;
31
+ /** Second, compact close button in the top-right corner. Default `false`. */
32
+ closeButton?: boolean;
33
+ /** Secondary text colour class. Default `"muted"`. */
34
+ tone?: PricingModalTone;
35
+ };
36
+
37
+ /** Single-plan upgrade dialog: brand header with price and tagline, checkout button and feature list. */
38
+ export function PricingModal({
39
+ open,
40
+ onClose,
41
+ title,
42
+ description,
43
+ logo,
44
+ wordmark,
45
+ price,
46
+ tagline,
47
+ taglineFontClassName,
48
+ features,
49
+ ctaLabel = "Get Pro",
50
+ renderCheckout,
51
+ closeButton = false,
52
+ tone = "muted",
53
+ }: PricingModalProps) {
54
+ const c = pricingModalClasses[tone];
55
+ const taglineClass = ["mt-4 max-w-[28ch]", taglineFontClassName, c.tagline].filter(Boolean).join(" ");
56
+ return (
57
+ <Dialog open={open} onOpenChange={(next) => !next && onClose()}>
58
+ <DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
59
+ {description ? (
60
+ <>
61
+ <DialogTitle className="sr-only">{title}</DialogTitle>
62
+ <DialogDescription className="sr-only">{description}</DialogDescription>
63
+ </>
64
+ ) : (
65
+ <VisuallyHidden>
66
+ <DialogTitle>{title}</DialogTitle>
67
+ </VisuallyHidden>
68
+ )}
69
+
70
+ {closeButton ? (
71
+ <button
72
+ onClick={onClose}
73
+ className="absolute right-4 top-4 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[background-color,transform] duration-100 ease-out active:scale-[0.96] hover:bg-surface-tertiary hover:text-foreground"
74
+ >
75
+ <IconX className="size-4" />
76
+ <span className="sr-only">Close</span>
77
+ </button>
78
+ ) : null}
79
+
80
+ <div className="bg-surface-secondary px-8 pb-8 pt-10 dark:bg-surface-secondary/40">
81
+ <div className="flex items-center">
82
+ <img src={logo.dark} alt="" width={28} height={28} className="hidden dark:block" />
83
+ <img src={logo.light} alt="" width={28} height={28} className="block dark:hidden" />
84
+ <span className="font-michroma text-xl tracking-wider text-foreground">{wordmark}</span>
85
+ <span className="ml-2 rounded-md bg-segment px-2 py-0.5 font-michroma text-[10px] tracking-[0.25em] text-segment-foreground">
86
+ PRO
87
+ </span>
88
+ </div>
89
+
90
+ <div className="mt-8 flex items-end gap-2">
91
+ <span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
92
+ {price}
93
+ </span>
94
+ <span className={c.period}>/ month</span>
95
+ </div>
96
+
97
+ <p className={taglineClass}>{tagline}</p>
98
+ </div>
99
+
100
+ <div className="px-8 pb-8 pt-6">
101
+ {renderCheckout(
102
+ <Button size="lg" className="h-11 w-full rounded-xl">
103
+ {ctaLabel}
104
+ </Button>,
105
+ )}
106
+
107
+ <p className={c.note}>Cancel anytime. Billed monthly.</p>
108
+
109
+ <div className="mt-8">
110
+ <p className={c.featuresHeading}>What you get</p>
111
+ <ul className="space-y-3">
112
+ {features.map((feature) => (
113
+ <li key={feature} className="flex items-start gap-3">
114
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md bg-default dark:bg-default/80">
115
+ <IconCheck className="size-3 text-foreground" strokeWidth={2.5} />
116
+ </span>
117
+ <span className="text-sm leading-snug text-foreground">{feature}</span>
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ </div>
122
+ </div>
123
+ </DialogContent>
124
+ </Dialog>
125
+ );
126
+ }
@@ -0,0 +1,135 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { Button } from "../ui/button";
3
+ import { Separator } from "../ui/separator";
4
+ import { siteFooterClasses, type SiteFooterVariant } from "./marketing-classes";
5
+
6
+ export type SiteFooterLink = {
7
+ href: string;
8
+ label: string;
9
+ /** Opens in a new tab (`target="_blank" rel="noopener noreferrer"`). */
10
+ external?: boolean;
11
+ /** 20px round image before the label. */
12
+ image?: { src: string; alt: string };
13
+ };
14
+
15
+ export type SiteFooterSocial = {
16
+ href: string;
17
+ /** Rendered with the variant's `w-4 h-4` muted class, for example a Tabler brand icon. */
18
+ icon: ComponentType<{ className?: string }>;
19
+ /** `aria-label` on the link. */
20
+ label?: string;
21
+ };
22
+
23
+ export type SiteFooterProps = {
24
+ /** Wordmark text above the tagline. */
25
+ brand: ReactNode;
26
+ tagline: ReactNode;
27
+ /** Icon buttons under the tagline. Always open in a new tab. */
28
+ socials: readonly SiteFooterSocial[];
29
+ /** Link columns to the right of the brand block. */
30
+ columns: readonly { heading: string; links: readonly SiteFooterLink[] }[];
31
+ /** Name in "Copyright © {year} {name}". */
32
+ name: string;
33
+ /** Default: the current year. */
34
+ year?: number;
35
+ /** Bottom-right links, for example Privacy Policy and Terms of Service. Always open in a new tab. */
36
+ legalLinks: readonly { href: string; label: string }[];
37
+ /**
38
+ * `"muted"` (default) and `"muted-foreground"` pick the secondary text
39
+ * colour class. `"touch"` uses `text-muted-foreground`, `max-sm:hit-target`
40
+ * links and a safe-area bottom padding.
41
+ */
42
+ variant?: SiteFooterVariant;
43
+ };
44
+
45
+ /** Marketing site footer: brand, socials, link columns, then copyright and legal links under a separator. */
46
+ export function SiteFooter({
47
+ brand,
48
+ tagline,
49
+ socials,
50
+ columns,
51
+ name,
52
+ year = new Date().getFullYear(),
53
+ legalLinks,
54
+ variant = "muted",
55
+ }: SiteFooterProps) {
56
+ const c = siteFooterClasses[variant];
57
+ return (
58
+ <footer className={c.footer}>
59
+ <div className="relative z-10">
60
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
61
+ <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-8 lg:gap-12">
62
+ <div className="flex-1 lg:flex-none">
63
+ <div className="text-lg font-michroma font-normal tracking-wider mb-3 md:mb-4">{brand}</div>
64
+ <p className={c.tagline}>{tagline}</p>
65
+ <div className="flex gap-3 md:gap-4">
66
+ {socials.map(({ href, icon: Icon, label }) => (
67
+ <a key={href} href={href} target="_blank" rel="noopener noreferrer" aria-label={label}>
68
+ <Button variant="ghost" size="icon" className={c.socialButton}>
69
+ <Icon className={c.socialIcon} />
70
+ </Button>
71
+ </a>
72
+ ))}
73
+ </div>
74
+ </div>
75
+
76
+ <div className="flex flex-col sm:flex-row gap-8 md:gap-12 lg:gap-16 xl:gap-20">
77
+ {columns.map((column) => (
78
+ <div key={column.heading} className="flex-1 sm:flex-none">
79
+ <h4 className={c.heading}>{column.heading}</h4>
80
+ <div className={c.links}>
81
+ {column.links.map((link) => (
82
+ <a
83
+ key={link.label}
84
+ href={link.href}
85
+ {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
86
+ className={link.image ? c.imageLink : c.link}
87
+ >
88
+ {link.image ? (
89
+ <img
90
+ src={link.image.src}
91
+ alt={link.image.alt}
92
+ width={20}
93
+ height={20}
94
+ className="rounded-full object-cover w-5 h-5 md:w-5 md:h-5"
95
+ />
96
+ ) : null}
97
+ {link.label}
98
+ </a>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </div>
105
+
106
+ <Separator className="my-6 md:my-8 bg-separator" />
107
+
108
+ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 sm:gap-0">
109
+ <div className="flex flex-col gap-1 text-center sm:text-left">
110
+ <p className={c.copyright}>
111
+ Copyright &copy; {year}
112
+ {` ${name}`}
113
+ </p>
114
+ <p className={c.rights}>All rights reserved.</p>
115
+ </div>
116
+
117
+ <div className="flex flex-col gap-1 text-center sm:text-right">
118
+ {legalLinks.map((link) => (
119
+ <a
120
+ key={link.href}
121
+ href={link.href}
122
+ target="_blank"
123
+ rel="noopener noreferrer"
124
+ className={c.legalLink}
125
+ >
126
+ {link.label}
127
+ </a>
128
+ ))}
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </footer>
134
+ );
135
+ }