@viliha/vui-ui 1.1.8 → 1.3.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/AGENT.md +79 -9
- package/README.md +60 -0
- package/bin/vui.mjs +211 -0
- package/package.json +10 -2
- package/src/record-view.tsx +59 -17
- package/template/.env.example +20 -0
- package/template/app/(app)/branches/branches-table.tsx +69 -0
- package/template/app/(app)/branches/page.tsx +13 -0
- package/template/app/(app)/businesses/businesses-table.tsx +39 -0
- package/template/app/(app)/businesses/page.tsx +13 -0
- package/template/app/(app)/calendar/page.tsx +725 -0
- package/template/app/(app)/charts/charts-content.tsx +190 -0
- package/template/app/(app)/charts/layout.tsx +8 -0
- package/template/app/(app)/charts/page.tsx +44 -0
- package/template/app/(app)/chat/layout.tsx +14 -0
- package/template/app/(app)/chat/page.tsx +311 -0
- package/template/app/(app)/components/layout.tsx +8 -0
- package/template/app/(app)/components/page.tsx +464 -0
- package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
- package/template/app/(app)/crm/companies/page.tsx +13 -0
- package/template/app/(app)/crm/opportunities/opportunities-board.tsx +359 -0
- package/template/app/(app)/crm/opportunities/page.tsx +13 -0
- package/template/app/(app)/crm/people/page.tsx +13 -0
- package/template/app/(app)/crm/people/people-table.tsx +50 -0
- package/template/app/(app)/dashboard/layout.tsx +8 -0
- package/template/app/(app)/dashboard/page.tsx +239 -0
- package/template/app/(app)/departments/departments-table.tsx +55 -0
- package/template/app/(app)/departments/page.tsx +13 -0
- package/template/app/(app)/employees/employees-table.tsx +64 -0
- package/template/app/(app)/employees/page.tsx +13 -0
- package/template/app/(app)/forms/layout.tsx +8 -0
- package/template/app/(app)/forms/page.tsx +388 -0
- package/template/app/(app)/layout.tsx +74 -0
- package/template/app/(app)/markets/markets-table.tsx +76 -0
- package/template/app/(app)/markets/page.tsx +13 -0
- package/template/app/(app)/organizations/edit/page.tsx +72 -0
- package/template/app/(app)/organizations/new/page.tsx +46 -0
- package/template/app/(app)/organizations/organizations-config.tsx +97 -0
- package/template/app/(app)/organizations/organizations-table.tsx +42 -0
- package/template/app/(app)/organizations/page.tsx +13 -0
- package/template/app/(app)/settings/layout.tsx +8 -0
- package/template/app/(app)/settings/page.tsx +255 -0
- package/template/app/(app)/steps/page.tsx +263 -0
- package/template/app/(app)/support/layout.tsx +14 -0
- package/template/app/(app)/support/page.tsx +345 -0
- package/template/app/(app)/system/cities/cities-table.tsx +34 -0
- package/template/app/(app)/system/cities/page.tsx +13 -0
- package/template/app/(app)/system/countries/countries-table.tsx +34 -0
- package/template/app/(app)/system/countries/page.tsx +13 -0
- package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
- package/template/app/(app)/system/currencies/page.tsx +13 -0
- package/template/app/(app)/system/languages/languages-table.tsx +32 -0
- package/template/app/(app)/system/languages/page.tsx +13 -0
- package/template/app/(app)/system/regions/page.tsx +13 -0
- package/template/app/(app)/system/regions/regions-table.tsx +32 -0
- package/template/app/(app)/users/page.tsx +13 -0
- package/template/app/(app)/users/users-table.tsx +97 -0
- package/template/app/_components/app-sidebar.tsx +565 -0
- package/template/app/_components/auth.tsx +184 -0
- package/template/app/_components/breadcrumbs.tsx +26 -0
- package/template/app/_components/global-search.tsx +209 -0
- package/template/app/_components/logo.tsx +49 -0
- package/template/app/_components/nav-config.ts +138 -0
- package/template/app/_components/open-tabs.tsx +530 -0
- package/template/app/_components/quick-actions.tsx +140 -0
- package/template/app/_components/route-meta.ts +137 -0
- package/template/app/_components/set-page-title.tsx +15 -0
- package/template/app/_components/showcase.tsx +29 -0
- package/template/app/_components/stat-card.tsx +56 -0
- package/template/app/_components/theme-toggle.tsx +46 -0
- package/template/app/_components/top-bar.tsx +109 -0
- package/template/app/_components/user-menu.tsx +109 -0
- package/template/app/_components/wordmark.tsx +45 -0
- package/template/app/apple-icon.png +0 -0
- package/template/app/auth/forgot-password/page.tsx +102 -0
- package/template/app/auth/layout.tsx +43 -0
- package/template/app/auth/page.tsx +13 -0
- package/template/app/auth/reset-password/page.tsx +108 -0
- package/template/app/auth/signin/page.tsx +238 -0
- package/template/app/auth/signup/page.tsx +159 -0
- package/template/app/auth/verify/page.tsx +86 -0
- package/template/app/error.tsx +56 -0
- package/template/app/globals.css +6 -0
- package/template/app/icon.png +0 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +103 -0
- package/template/app/not-found.tsx +29 -0
- package/template/app/onboarding/layout.tsx +31 -0
- package/template/app/onboarding/page.tsx +399 -0
- package/template/app/page.tsx +13 -0
- package/template/app/register-business/layout.tsx +30 -0
- package/template/app/register-business/page.tsx +234 -0
- package/template/components/ui/accordion.tsx +66 -0
- package/template/components/ui/alert-dialog.tsx +196 -0
- package/template/components/ui/alert.tsx +66 -0
- package/template/components/ui/aspect-ratio.tsx +11 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +48 -0
- package/template/components/ui/breadcrumb.tsx +109 -0
- package/template/components/ui/button.tsx +64 -0
- package/template/components/ui/calendar.tsx +220 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/collapsible.tsx +33 -0
- package/template/components/ui/command.tsx +184 -0
- package/template/components/ui/dialog.tsx +158 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/hover-card.tsx +44 -0
- package/template/components/ui/input-otp.tsx +77 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/popover.tsx +89 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/radio-group.tsx +45 -0
- package/template/components/ui/scroll-area.tsx +58 -0
- package/template/components/ui/select.tsx +190 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +143 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/slider.tsx +63 -0
- package/template/components/ui/sonner.tsx +40 -0
- package/template/components/ui/switch.tsx +35 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +91 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +83 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +57 -0
- package/template/lib/auth-demo.ts +94 -0
- package/template/lib/crm-data.ts +80 -0
- package/template/lib/demo-data.ts +267 -0
- package/template/lib/mock-data.ts +169 -0
- package/template/lib/org-store.ts +50 -0
- package/template/lib/seo.ts +188 -0
- package/template/lib/utils.ts +3 -0
- package/template/next.config.mjs +13 -0
- package/template/postcss.config.mjs +7 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import {
|
|
6
|
+
ExclamationTriangleIcon as AlertTriangle,
|
|
7
|
+
ReloadIcon as Reload,
|
|
8
|
+
} from "@radix-ui/react-icons";
|
|
9
|
+
|
|
10
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
11
|
+
|
|
12
|
+
/** Root error boundary — shown when a route throws. Keep it self-contained
|
|
13
|
+
* (it renders in place of the page, outside the app shell). */
|
|
14
|
+
export default function Error({
|
|
15
|
+
error,
|
|
16
|
+
reset,
|
|
17
|
+
}: {
|
|
18
|
+
error: Error & { digest?: string };
|
|
19
|
+
reset: () => void;
|
|
20
|
+
}) {
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
// Wire to your error reporter (Sentry, etc.) here.
|
|
23
|
+
console.error(error);
|
|
24
|
+
}, [error]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
|
28
|
+
<div className="grid size-14 place-items-center rounded-full bg-destructive/10 text-destructive">
|
|
29
|
+
<AlertTriangle className="size-7" />
|
|
30
|
+
</div>
|
|
31
|
+
<p className="text-6xl font-semibold tracking-tight text-muted-foreground">
|
|
32
|
+
500
|
|
33
|
+
</p>
|
|
34
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
35
|
+
Something went wrong
|
|
36
|
+
</h1>
|
|
37
|
+
<p className="max-w-sm text-muted-foreground">
|
|
38
|
+
An unexpected error occurred. Try again, or head back home.
|
|
39
|
+
{error.digest && (
|
|
40
|
+
<span className="mt-1 block font-mono text-xs">
|
|
41
|
+
Ref: {error.digest}
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
</p>
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
<Button variant="primary" onClick={reset}>
|
|
47
|
+
<Reload className="size-4" />
|
|
48
|
+
Try again
|
|
49
|
+
</Button>
|
|
50
|
+
<Link href="/dashboard">
|
|
51
|
+
<Button variant="outline">Back to Home</Button>
|
|
52
|
+
</Link>
|
|
53
|
+
</div>
|
|
54
|
+
</main>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="24" height="24" rx="6" fill="#0866ff" />
|
|
3
|
+
<path d="M6.5 7.25 L12 16.75 L17.5 7.25" fill="none" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Inter, JetBrains_Mono } from "next/font/google";
|
|
3
|
+
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
import { themeInitScript } from "./_components/theme-toggle";
|
|
6
|
+
import { SITE } from "@/lib/seo";
|
|
7
|
+
|
|
8
|
+
const inter = Inter({
|
|
9
|
+
variable: "--font-inter",
|
|
10
|
+
subsets: ["latin"],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const jetbrainsMono = JetBrains_Mono({
|
|
14
|
+
variable: "--font-jetbrains-mono",
|
|
15
|
+
subsets: ["latin"],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const metadata: Metadata = {
|
|
19
|
+
metadataBase: new URL(SITE.url),
|
|
20
|
+
title: {
|
|
21
|
+
default: `${SITE.name} — ${SITE.tagline}`,
|
|
22
|
+
template: `%s · ${SITE.name}`,
|
|
23
|
+
},
|
|
24
|
+
description: SITE.description,
|
|
25
|
+
applicationName: SITE.name,
|
|
26
|
+
authors: [{ name: SITE.author }],
|
|
27
|
+
creator: SITE.author,
|
|
28
|
+
keywords: [
|
|
29
|
+
"react admin template",
|
|
30
|
+
"admin dashboard",
|
|
31
|
+
"crm",
|
|
32
|
+
"design system",
|
|
33
|
+
"component library",
|
|
34
|
+
"shadcn",
|
|
35
|
+
"next.js",
|
|
36
|
+
"tailwind css",
|
|
37
|
+
"open source",
|
|
38
|
+
],
|
|
39
|
+
openGraph: {
|
|
40
|
+
type: "website",
|
|
41
|
+
siteName: SITE.name,
|
|
42
|
+
url: SITE.url,
|
|
43
|
+
title: `${SITE.name} — ${SITE.tagline}`,
|
|
44
|
+
description: SITE.description,
|
|
45
|
+
images: [{ url: SITE.ogImage, alt: SITE.name }],
|
|
46
|
+
},
|
|
47
|
+
twitter: {
|
|
48
|
+
card: "summary_large_image",
|
|
49
|
+
title: `${SITE.name} — ${SITE.tagline}`,
|
|
50
|
+
description: SITE.description,
|
|
51
|
+
images: [SITE.ogImage],
|
|
52
|
+
},
|
|
53
|
+
robots: { index: true, follow: true },
|
|
54
|
+
alternates: { canonical: "/" },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Structured data (schema.org). Indexable pages set their own canonical, so
|
|
58
|
+
* this describes the site + product once, at the root. */
|
|
59
|
+
const jsonLd = {
|
|
60
|
+
"@context": "https://schema.org",
|
|
61
|
+
"@graph": [
|
|
62
|
+
{
|
|
63
|
+
"@type": "WebSite",
|
|
64
|
+
name: SITE.name,
|
|
65
|
+
url: SITE.url,
|
|
66
|
+
description: SITE.description,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"@type": "SoftwareApplication",
|
|
70
|
+
name: SITE.name,
|
|
71
|
+
applicationCategory: "DeveloperApplication",
|
|
72
|
+
operatingSystem: "Web",
|
|
73
|
+
description: SITE.description,
|
|
74
|
+
url: SITE.url,
|
|
75
|
+
author: { "@type": "Person", name: SITE.author },
|
|
76
|
+
isAccessibleForFree: true,
|
|
77
|
+
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default function RootLayout({
|
|
83
|
+
children,
|
|
84
|
+
}: Readonly<{
|
|
85
|
+
children: React.ReactNode;
|
|
86
|
+
}>) {
|
|
87
|
+
return (
|
|
88
|
+
<html lang="en" data-scroll-behavior="smooth" suppressHydrationWarning>
|
|
89
|
+
<head>
|
|
90
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
91
|
+
<script
|
|
92
|
+
type="application/ld+json"
|
|
93
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
94
|
+
/>
|
|
95
|
+
</head>
|
|
96
|
+
<body
|
|
97
|
+
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { MagnifyingGlassIcon as Search } from "@radix-ui/react-icons";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
5
|
+
|
|
6
|
+
export default function NotFound() {
|
|
7
|
+
return (
|
|
8
|
+
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
|
9
|
+
<div className="grid size-14 place-items-center rounded-full bg-muted text-muted-foreground">
|
|
10
|
+
<Search className="size-7" />
|
|
11
|
+
</div>
|
|
12
|
+
<p className="text-6xl font-semibold tracking-tight text-muted-foreground">
|
|
13
|
+
404
|
|
14
|
+
</p>
|
|
15
|
+
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
|
16
|
+
<p className="max-w-sm text-muted-foreground">
|
|
17
|
+
The page you’re looking for doesn’t exist or may have moved.
|
|
18
|
+
</p>
|
|
19
|
+
<div className="flex items-center gap-2">
|
|
20
|
+
<Link href="/dashboard">
|
|
21
|
+
<Button variant="primary">Back to Home</Button>
|
|
22
|
+
</Link>
|
|
23
|
+
<Link href="/auth/signin">
|
|
24
|
+
<Button variant="outline">Sign in</Button>
|
|
25
|
+
</Link>
|
|
26
|
+
</div>
|
|
27
|
+
</main>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
import { Logo } from "@/app/_components/logo";
|
|
4
|
+
import { ThemeToggle } from "@/app/_components/theme-toggle";
|
|
5
|
+
|
|
6
|
+
// Onboarding is a post-signup flow — keep it out of the index.
|
|
7
|
+
export const metadata: Metadata = { robots: { index: false, follow: false } };
|
|
8
|
+
|
|
9
|
+
export default function OnboardingLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: Readonly<{
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}>) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex min-h-screen flex-col bg-muted/30">
|
|
16
|
+
<header className="flex items-center justify-between border-b border-border bg-background px-4 py-2.5">
|
|
17
|
+
<div className="flex items-center gap-2">
|
|
18
|
+
<Logo className="h-7 w-7" />
|
|
19
|
+
<span className="bg-gradient-to-r from-brand-indigo to-brand-violet bg-clip-text text-sm font-bold tracking-tight text-transparent">
|
|
20
|
+
Vui Starter
|
|
21
|
+
</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="flex items-center gap-3 text-muted-foreground">
|
|
24
|
+
<span>Set up your workspace</span>
|
|
25
|
+
<ThemeToggle />
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
<main className="flex flex-1 justify-center px-4 py-8">{children}</main>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import {
|
|
6
|
+
ArrowLeftIcon as ArrowLeft,
|
|
7
|
+
CheckIcon as Check,
|
|
8
|
+
CopyIcon as Copy,
|
|
9
|
+
CubeIcon as Building2,
|
|
10
|
+
LockClosedIcon as KeyRound,
|
|
11
|
+
PersonIcon as UserRound,
|
|
12
|
+
PersonIcon as Users,
|
|
13
|
+
PlusIcon as Plus,
|
|
14
|
+
TrashIcon as Trash2,
|
|
15
|
+
UploadIcon as Upload,
|
|
16
|
+
} from "@radix-ui/react-icons";
|
|
17
|
+
|
|
18
|
+
import { cn } from "@/lib/utils";
|
|
19
|
+
import { Badge } from "@viliha/vui-ui/badge";
|
|
20
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
21
|
+
import { Input } from "@viliha/vui-ui/input";
|
|
22
|
+
import { Select } from "@viliha/vui-ui/select";
|
|
23
|
+
import { Field } from "@/app/_components/auth";
|
|
24
|
+
import { PLANS, TEAM_ROLES, TIMEZONES } from "@/lib/auth-demo";
|
|
25
|
+
|
|
26
|
+
const STEPS = [
|
|
27
|
+
{ key: "profile", label: "Profile", icon: UserRound },
|
|
28
|
+
{ key: "company", label: "Company", icon: Building2 },
|
|
29
|
+
{ key: "token", label: "API token", icon: KeyRound },
|
|
30
|
+
{ key: "plan", label: "Plan", icon: Check },
|
|
31
|
+
{ key: "team", label: "Team", icon: Users },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export default function OnboardingPage() {
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const [step, setStep] = React.useState(0);
|
|
37
|
+
|
|
38
|
+
const [profile, setProfile] = React.useState({
|
|
39
|
+
firstName: "",
|
|
40
|
+
lastName: "",
|
|
41
|
+
email: "you@company.com",
|
|
42
|
+
});
|
|
43
|
+
const [company, setCompany] = React.useState({ name: "", timezone: "UTC" });
|
|
44
|
+
const [token, setToken] = React.useState<string | null>(null);
|
|
45
|
+
const [copied, setCopied] = React.useState(false);
|
|
46
|
+
const [plan, setPlan] = React.useState("growth");
|
|
47
|
+
const [invites, setInvites] = React.useState<
|
|
48
|
+
{ email: string; role: string }[]
|
|
49
|
+
>([{ email: "", role: "Member" }]);
|
|
50
|
+
|
|
51
|
+
const last = STEPS.length - 1;
|
|
52
|
+
const next = () => (step === last ? finish() : setStep((s) => s + 1));
|
|
53
|
+
const back = () => setStep((s) => Math.max(0, s - 1));
|
|
54
|
+
function finish() {
|
|
55
|
+
router.push("/dashboard");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function generateToken() {
|
|
59
|
+
const rand =
|
|
60
|
+
Math.random().toString(36).slice(2, 12) +
|
|
61
|
+
Math.random().toString(36).slice(2, 12);
|
|
62
|
+
setToken(`vui_sk_${rand}`);
|
|
63
|
+
}
|
|
64
|
+
async function copyToken() {
|
|
65
|
+
if (!token) return;
|
|
66
|
+
try {
|
|
67
|
+
await navigator.clipboard.writeText(token);
|
|
68
|
+
setCopied(true);
|
|
69
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
70
|
+
} catch {
|
|
71
|
+
// clipboard unavailable — no-op
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const canContinue =
|
|
76
|
+
step === 0
|
|
77
|
+
? profile.firstName.trim() !== "" && profile.lastName.trim() !== ""
|
|
78
|
+
: step === 1
|
|
79
|
+
? company.name.trim() !== ""
|
|
80
|
+
: true;
|
|
81
|
+
const skippable = step === 2 || step === 4; // API token, Team
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="w-full max-w-xl">
|
|
85
|
+
<Stepper current={step} />
|
|
86
|
+
|
|
87
|
+
<div className="mt-6 rounded-xl border border-border bg-background p-6 shadow-sm">
|
|
88
|
+
{step === 0 && (
|
|
89
|
+
<Step
|
|
90
|
+
title="Set up your profile"
|
|
91
|
+
>
|
|
92
|
+
<div className="grid grid-cols-2 gap-3">
|
|
93
|
+
<Field label="First name" htmlFor="fn">
|
|
94
|
+
<Input
|
|
95
|
+
id="fn"
|
|
96
|
+
value={profile.firstName}
|
|
97
|
+
onChange={(e) =>
|
|
98
|
+
setProfile((p) => ({ ...p, firstName: e.target.value }))
|
|
99
|
+
}
|
|
100
|
+
placeholder="Ada"
|
|
101
|
+
/>
|
|
102
|
+
</Field>
|
|
103
|
+
<Field label="Last name" htmlFor="ln">
|
|
104
|
+
<Input
|
|
105
|
+
id="ln"
|
|
106
|
+
value={profile.lastName}
|
|
107
|
+
onChange={(e) =>
|
|
108
|
+
setProfile((p) => ({ ...p, lastName: e.target.value }))
|
|
109
|
+
}
|
|
110
|
+
placeholder="Lovelace"
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
</div>
|
|
114
|
+
<Field label="Admin email" htmlFor="email">
|
|
115
|
+
<Input
|
|
116
|
+
id="email"
|
|
117
|
+
type="email"
|
|
118
|
+
value={profile.email}
|
|
119
|
+
readOnly
|
|
120
|
+
disabled
|
|
121
|
+
className="cursor-not-allowed"
|
|
122
|
+
/>
|
|
123
|
+
</Field>
|
|
124
|
+
</Step>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{step === 1 && (
|
|
128
|
+
<Step
|
|
129
|
+
title="Company information"
|
|
130
|
+
>
|
|
131
|
+
<Field label="Company name" htmlFor="co">
|
|
132
|
+
<Input
|
|
133
|
+
id="co"
|
|
134
|
+
value={company.name}
|
|
135
|
+
onChange={(e) =>
|
|
136
|
+
setCompany((c) => ({ ...c, name: e.target.value }))
|
|
137
|
+
}
|
|
138
|
+
placeholder="Acme Inc."
|
|
139
|
+
/>
|
|
140
|
+
</Field>
|
|
141
|
+
<div className="space-y-1">
|
|
142
|
+
<span className="font-medium">Logo</span>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="flex w-full items-center gap-3 rounded-md border border-dashed border-input px-3 py-2.5 text-left text-muted-foreground hover:bg-muted/50"
|
|
146
|
+
>
|
|
147
|
+
<span className="grid size-8 place-items-center rounded-md bg-muted">
|
|
148
|
+
<Upload className="size-4" />
|
|
149
|
+
</span>
|
|
150
|
+
Upload a logo (optional)
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
<Field label="Timezone" htmlFor="tz">
|
|
154
|
+
<Select
|
|
155
|
+
id="tz"
|
|
156
|
+
ariaLabel="Timezone"
|
|
157
|
+
value={company.timezone}
|
|
158
|
+
onValueChange={(v) =>
|
|
159
|
+
setCompany((c) => ({ ...c, timezone: v }))
|
|
160
|
+
}
|
|
161
|
+
options={TIMEZONES.map((t) => ({ value: t, label: t }))}
|
|
162
|
+
/>
|
|
163
|
+
</Field>
|
|
164
|
+
</Step>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{step === 2 && (
|
|
168
|
+
<Step title="Generate your API token">
|
|
169
|
+
<div className="rounded-md border border-border p-3">
|
|
170
|
+
{!token ? (
|
|
171
|
+
<div className="flex items-center justify-between gap-3">
|
|
172
|
+
<span className="font-medium">API token</span>
|
|
173
|
+
<Button size="sm" variant="outline" onClick={generateToken}>
|
|
174
|
+
<KeyRound className="size-3.5" />
|
|
175
|
+
Generate
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
) : (
|
|
179
|
+
<div className="flex items-center gap-2">
|
|
180
|
+
<code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 font-mono">
|
|
181
|
+
{token}
|
|
182
|
+
</code>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={copyToken}
|
|
186
|
+
aria-label="Copy API token"
|
|
187
|
+
className="grid size-7 shrink-0 place-items-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
188
|
+
>
|
|
189
|
+
{copied ? (
|
|
190
|
+
<Check className="size-3.5 text-emerald-600" />
|
|
191
|
+
) : (
|
|
192
|
+
<Copy className="size-3.5" />
|
|
193
|
+
)}
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</Step>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{step === 3 && (
|
|
202
|
+
<Step
|
|
203
|
+
title="Choose your plan"
|
|
204
|
+
>
|
|
205
|
+
<div className="space-y-2">
|
|
206
|
+
{PLANS.map((p) => {
|
|
207
|
+
const selected = plan === p.id;
|
|
208
|
+
return (
|
|
209
|
+
<button
|
|
210
|
+
key={p.id}
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => setPlan(p.id)}
|
|
213
|
+
aria-pressed={selected}
|
|
214
|
+
className={cn(
|
|
215
|
+
"w-full rounded-lg border p-3 text-left transition-colors",
|
|
216
|
+
selected
|
|
217
|
+
? "border-primary bg-primary/5 ring-1 ring-primary"
|
|
218
|
+
: "border-border hover:bg-muted/50",
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
<div className="flex items-center justify-between">
|
|
222
|
+
<div className="flex items-center gap-2">
|
|
223
|
+
<span className="text-sm font-semibold">
|
|
224
|
+
{p.name}
|
|
225
|
+
</span>
|
|
226
|
+
{p.popular && (
|
|
227
|
+
<Badge variant="default">Most popular</Badge>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
<span className="text-muted-foreground">
|
|
231
|
+
<span className="font-semibold text-foreground">
|
|
232
|
+
{p.price}
|
|
233
|
+
</span>{" "}
|
|
234
|
+
{p.cadence}
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
|
238
|
+
{p.features.map((f) => (
|
|
239
|
+
<li
|
|
240
|
+
key={f}
|
|
241
|
+
className="flex items-center gap-1 text-muted-foreground"
|
|
242
|
+
>
|
|
243
|
+
<Check className="size-3.5 text-primary" />
|
|
244
|
+
{f}
|
|
245
|
+
</li>
|
|
246
|
+
))}
|
|
247
|
+
</ul>
|
|
248
|
+
</button>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</Step>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{step === 4 && (
|
|
256
|
+
<Step
|
|
257
|
+
title="Invite your team"
|
|
258
|
+
>
|
|
259
|
+
<div className="space-y-2">
|
|
260
|
+
{invites.map((row, i) => (
|
|
261
|
+
<div key={i} className="flex items-center gap-2">
|
|
262
|
+
<Input
|
|
263
|
+
type="email"
|
|
264
|
+
value={row.email}
|
|
265
|
+
onChange={(e) =>
|
|
266
|
+
setInvites((rows) =>
|
|
267
|
+
rows.map((r, j) =>
|
|
268
|
+
j === i ? { ...r, email: e.target.value } : r,
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
placeholder="teammate@company.com"
|
|
273
|
+
aria-label={`Invite email ${i + 1}`}
|
|
274
|
+
/>
|
|
275
|
+
<Select
|
|
276
|
+
value={row.role}
|
|
277
|
+
onValueChange={(v) =>
|
|
278
|
+
setInvites((rows) =>
|
|
279
|
+
rows.map((r, j) => (j === i ? { ...r, role: v } : r)),
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
ariaLabel={`Invite role ${i + 1}`}
|
|
283
|
+
className="w-32 shrink-0"
|
|
284
|
+
options={TEAM_ROLES.map((r) => ({ value: r, label: r }))}
|
|
285
|
+
/>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
onClick={() =>
|
|
289
|
+
setInvites((rows) =>
|
|
290
|
+
rows.length > 1 ? rows.filter((_, j) => j !== i) : rows,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
aria-label="Remove invite"
|
|
294
|
+
className="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
|
295
|
+
disabled={invites.length === 1}
|
|
296
|
+
>
|
|
297
|
+
<Trash2 className="size-4" />
|
|
298
|
+
</button>
|
|
299
|
+
</div>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() =>
|
|
305
|
+
setInvites((rows) => [...rows, { email: "", role: "Member" }])
|
|
306
|
+
}
|
|
307
|
+
className="mt-2 inline-flex items-center gap-1.5 font-medium text-primary hover:underline"
|
|
308
|
+
>
|
|
309
|
+
<Plus className="size-3.5" />
|
|
310
|
+
Add another
|
|
311
|
+
</button>
|
|
312
|
+
</Step>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{/* Navigation */}
|
|
316
|
+
<div className="mt-6 flex items-center justify-between gap-2">
|
|
317
|
+
<Button
|
|
318
|
+
variant="ghost"
|
|
319
|
+
onClick={back}
|
|
320
|
+
disabled={step === 0}
|
|
321
|
+
className={cn(step === 0 && "invisible")}
|
|
322
|
+
>
|
|
323
|
+
<ArrowLeft className="size-4" />
|
|
324
|
+
Back
|
|
325
|
+
</Button>
|
|
326
|
+
<div className="flex items-center gap-2">
|
|
327
|
+
{skippable && (
|
|
328
|
+
<Button variant="ghost" onClick={next}>
|
|
329
|
+
Skip
|
|
330
|
+
</Button>
|
|
331
|
+
)}
|
|
332
|
+
<Button onClick={next} disabled={!canContinue}>
|
|
333
|
+
{step === last ? "Finish & enter workspace" : "Continue"}
|
|
334
|
+
</Button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function Stepper({ current }: { current: number }) {
|
|
343
|
+
return (
|
|
344
|
+
<ol className="flex items-center">
|
|
345
|
+
{STEPS.map((s, i) => {
|
|
346
|
+
const done = i < current;
|
|
347
|
+
const active = i === current;
|
|
348
|
+
const Icon = s.icon;
|
|
349
|
+
return (
|
|
350
|
+
<li key={s.key} className="flex flex-1 items-center last:flex-none">
|
|
351
|
+
<div className="flex flex-col items-center gap-1">
|
|
352
|
+
<span
|
|
353
|
+
className={cn(
|
|
354
|
+
"grid size-7 place-items-center rounded-full border transition-colors",
|
|
355
|
+
done && "border-primary bg-primary text-primary-foreground",
|
|
356
|
+
active && "border-primary text-primary",
|
|
357
|
+
!done && !active && "border-border text-muted-foreground",
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
{done ? <Check className="size-4" /> : <Icon className="size-4" />}
|
|
361
|
+
</span>
|
|
362
|
+
<span
|
|
363
|
+
className={cn(
|
|
364
|
+
"hidden sm:block",
|
|
365
|
+
active ? "font-medium text-foreground" : "text-muted-foreground",
|
|
366
|
+
)}
|
|
367
|
+
>
|
|
368
|
+
{s.label}
|
|
369
|
+
</span>
|
|
370
|
+
</div>
|
|
371
|
+
{i < STEPS.length - 1 && (
|
|
372
|
+
<span
|
|
373
|
+
className={cn(
|
|
374
|
+
"mx-2 h-px flex-1 self-start mt-3.5",
|
|
375
|
+
i < current ? "bg-primary" : "bg-border",
|
|
376
|
+
)}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
</li>
|
|
380
|
+
);
|
|
381
|
+
})}
|
|
382
|
+
</ol>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function Step({
|
|
387
|
+
title,
|
|
388
|
+
children,
|
|
389
|
+
}: {
|
|
390
|
+
title: string;
|
|
391
|
+
children: React.ReactNode;
|
|
392
|
+
}) {
|
|
393
|
+
return (
|
|
394
|
+
<div>
|
|
395
|
+
<h1 className="text-base font-semibold tracking-tight">{title}</h1>
|
|
396
|
+
<div className="mt-4 space-y-3">{children}</div>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
/** The app opens on the dashboard. Client redirect (works with static export). */
|
|
7
|
+
export default function RootPage() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
React.useEffect(() => {
|
|
10
|
+
router.replace("/dashboard");
|
|
11
|
+
}, [router]);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
import { Logo } from "@/app/_components/logo";
|
|
4
|
+
import { ThemeToggle } from "@/app/_components/theme-toggle";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "Register your business",
|
|
8
|
+
robots: { index: false, follow: false },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RegisterBusinessLayout({
|
|
12
|
+
children,
|
|
13
|
+
}: Readonly<{
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}>) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex min-h-screen flex-col bg-muted/30">
|
|
18
|
+
<header className="flex items-center justify-between border-b border-border bg-background px-4 py-2.5">
|
|
19
|
+
<div className="flex items-center gap-2">
|
|
20
|
+
<Logo className="h-7 w-7" />
|
|
21
|
+
<span className="bg-gradient-to-r from-brand-indigo to-brand-violet bg-clip-text text-sm font-bold tracking-tight text-transparent">
|
|
22
|
+
Vui Starter
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
<ThemeToggle />
|
|
26
|
+
</header>
|
|
27
|
+
<main className="flex flex-1 justify-center px-4 py-8">{children}</main>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|