@vvedantb/ui 0.8.0 → 0.10.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 +90 -2
- package/app/README.md +259 -0
- package/app/src/convex/ConvexClerkProvider.tsx +37 -0
- package/app/src/convex/env.ts +76 -0
- package/app/src/convex/useEnsureUser.ts +24 -0
- package/app/src/index.ts +17 -0
- package/package.json +22 -13
- package/shell/src/SidebarAccountMenu.tsx +78 -2
- package/shell/src/SidebarRail.tsx +20 -0
- package/shell/src/index.ts +2 -2
- package/src/index.ts +53 -3
- package/src/marketing/PricingModal.tsx +126 -0
- package/src/marketing/SiteFooter.tsx +135 -0
- package/src/marketing/createLandingKit.tsx +214 -0
- package/src/marketing/landing.css +271 -0
- package/src/marketing/landing.tsx +217 -0
- package/src/marketing/marketing-classes.ts +115 -0
- package/tokens/README.md +98 -0
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
+
}
|
package/shell/src/index.ts
CHANGED
|
@@ -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 {
|
|
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,7 +177,52 @@ export {
|
|
|
172
177
|
} from "../shell/src/app-shell-classes";
|
|
173
178
|
export type { SidebarNavItemClassOptions } from "../shell/src/app-shell-classes";
|
|
174
179
|
|
|
175
|
-
//
|
|
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
|
+
|
|
225
|
+
// App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
|
|
176
226
|
export * from "../app/src/index";
|
|
177
227
|
|
|
178
228
|
// Native tokens (JS values that match tokens.css).
|
|
@@ -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 © {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
|
+
}
|