@viliha/vui-ui 1.1.8 → 1.2.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/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 +484 -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,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CubeIcon as Building2,
|
|
3
|
+
DotFilledIcon as CircleDot,
|
|
4
|
+
EnvelopeClosedIcon as Mail,
|
|
5
|
+
GlobeIcon as Globe,
|
|
6
|
+
PersonIcon as Users,
|
|
7
|
+
SewingPinFilledIcon as MapPin,
|
|
8
|
+
Share2Icon as Network,
|
|
9
|
+
} from "@radix-ui/react-icons";
|
|
10
|
+
|
|
11
|
+
import { Badge } from "@viliha/vui-ui/badge";
|
|
12
|
+
import { type IconType, type RecordField } from "@viliha/vui-ui/record-view";
|
|
13
|
+
import {
|
|
14
|
+
type DemoOrganization,
|
|
15
|
+
type OrganizationStatus,
|
|
16
|
+
} from "@/lib/demo-data";
|
|
17
|
+
|
|
18
|
+
// Shared organization view config — reused by the table and the create/edit routes.
|
|
19
|
+
export const ORG_TITLE = "Organizations";
|
|
20
|
+
export const ORG_SINGULAR = "Organization";
|
|
21
|
+
export const ORG_ICON: IconType = Building2;
|
|
22
|
+
export const ORG_FORM_DESCRIPTION =
|
|
23
|
+
"Organizations are the top-level tenants in the system. Each one groups its branches, departments and employees, and owns its billing and locale settings. Fill in the details below to create or update a record.";
|
|
24
|
+
|
|
25
|
+
const statusBadge: Record<
|
|
26
|
+
OrganizationStatus,
|
|
27
|
+
{ label: string; variant: "success" | "warning" | "destructive" }
|
|
28
|
+
> = {
|
|
29
|
+
active: { label: "Active", variant: "success" },
|
|
30
|
+
trial: { label: "Trial", variant: "warning" },
|
|
31
|
+
suspended: { label: "Suspended", variant: "destructive" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const fields: RecordField<DemoOrganization>[] = [
|
|
35
|
+
{ key: "name", label: "Name", description: "The organization's legal or trading name, shown across the app.", editable: true, required: true, group: "General", hideInTable: true },
|
|
36
|
+
{ key: "url", label: "Domain", description: "Primary web domain, e.g. acme.com — used to group users and match emails.", icon: Globe, editable: true, copyable: true, width: 200, group: "General" },
|
|
37
|
+
{ key: "email", label: "Email", description: "Main contact address for billing and account notices.", icon: Mail, editable: true, required: true, copyable: true, width: 220, group: "General" },
|
|
38
|
+
{ key: "country", label: "Country", description: "Headquarters country. Drives default currency, tax and locale.", icon: MapPin, editable: true, group: "General" },
|
|
39
|
+
{
|
|
40
|
+
key: "branches",
|
|
41
|
+
label: "Branches",
|
|
42
|
+
description: "Number of physical or regional offices under this organization.",
|
|
43
|
+
icon: Network,
|
|
44
|
+
group: "System",
|
|
45
|
+
render: (row) => <span className="tabular-nums">{row.branches}</span>,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: "employees",
|
|
49
|
+
label: "Employees",
|
|
50
|
+
description: "Approximate headcount. Used for reporting and plan sizing.",
|
|
51
|
+
icon: Users,
|
|
52
|
+
group: "System",
|
|
53
|
+
render: (row) => (
|
|
54
|
+
<span className="tabular-nums">{row.employees.toLocaleString()}</span>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "status",
|
|
59
|
+
label: "Status",
|
|
60
|
+
description: "Account lifecycle: Trial while evaluating, Active once live, Suspended to disable access.",
|
|
61
|
+
icon: CircleDot,
|
|
62
|
+
group: "System",
|
|
63
|
+
options: [
|
|
64
|
+
{ value: "active", label: "Active" },
|
|
65
|
+
{ value: "trial", label: "Trial" },
|
|
66
|
+
{ value: "suspended", label: "Suspended" },
|
|
67
|
+
],
|
|
68
|
+
render: (row) => {
|
|
69
|
+
const status = statusBadge[row.status];
|
|
70
|
+
return <Badge variant={status.variant}>{status.label}</Badge>;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function getPrimary(row: DemoOrganization) {
|
|
76
|
+
return {
|
|
77
|
+
title: row.name,
|
|
78
|
+
subtitle: row.url,
|
|
79
|
+
initials: row.initials || row.name.slice(0, 2).toUpperCase(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeEmptyRow(): DemoOrganization {
|
|
84
|
+
return {
|
|
85
|
+
id: Date.now(),
|
|
86
|
+
name: "",
|
|
87
|
+
url: "",
|
|
88
|
+
email: "",
|
|
89
|
+
countryCode: "",
|
|
90
|
+
country: "",
|
|
91
|
+
branches: 0,
|
|
92
|
+
employees: 0,
|
|
93
|
+
status: "trial",
|
|
94
|
+
initials: "",
|
|
95
|
+
updated: "just now",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
4
|
+
|
|
5
|
+
import { RecordView } from "@viliha/vui-ui/record-view";
|
|
6
|
+
import { orgStore, useOrganizations } from "@/lib/org-store";
|
|
7
|
+
import {
|
|
8
|
+
fields,
|
|
9
|
+
getPrimary,
|
|
10
|
+
makeEmptyRow,
|
|
11
|
+
ORG_FORM_DESCRIPTION,
|
|
12
|
+
ORG_ICON,
|
|
13
|
+
ORG_SINGULAR,
|
|
14
|
+
ORG_TITLE,
|
|
15
|
+
} from "./organizations-config";
|
|
16
|
+
|
|
17
|
+
export function OrganizationsTable() {
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
const pathname = usePathname();
|
|
20
|
+
const rows = useOrganizations();
|
|
21
|
+
return (
|
|
22
|
+
<RecordView
|
|
23
|
+
persistKey={pathname}
|
|
24
|
+
title={ORG_TITLE}
|
|
25
|
+
singular={ORG_SINGULAR}
|
|
26
|
+
icon={ORG_ICON}
|
|
27
|
+
formMode="page"
|
|
28
|
+
formColumns={1}
|
|
29
|
+
onHome={() => router.push("/dashboard")}
|
|
30
|
+
onCreate={() => router.push("/organizations/new")}
|
|
31
|
+
onView={(id) => router.push(`/organizations/edit?id=${id}`)}
|
|
32
|
+
onEdit={(id) => router.push(`/organizations/edit?id=${id}`)}
|
|
33
|
+
formDescription={ORG_FORM_DESCRIPTION}
|
|
34
|
+
fields={fields}
|
|
35
|
+
initialData={rows}
|
|
36
|
+
data={rows}
|
|
37
|
+
onDataChange={orgStore.set}
|
|
38
|
+
getPrimary={getPrimary}
|
|
39
|
+
makeEmptyRow={makeEmptyRow}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { pageMeta } from "@/lib/seo";
|
|
2
|
+
|
|
3
|
+
import { OrganizationsTable } from "./organizations-table";
|
|
4
|
+
|
|
5
|
+
export const metadata = pageMeta("/organizations");
|
|
6
|
+
|
|
7
|
+
export default function OrganizationsPage() {
|
|
8
|
+
return (
|
|
9
|
+
<main className="h-full">
|
|
10
|
+
<OrganizationsTable />
|
|
11
|
+
</main>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { pageMeta } from "@/lib/seo";
|
|
2
|
+
|
|
3
|
+
// The page is a Client Component and can't export metadata, so it lives here.
|
|
4
|
+
export const metadata = pageMeta("/settings");
|
|
5
|
+
|
|
6
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
return children;
|
|
8
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
BellIcon,
|
|
6
|
+
DesktopIcon,
|
|
7
|
+
GearIcon,
|
|
8
|
+
MoonIcon,
|
|
9
|
+
SunIcon,
|
|
10
|
+
} from "@radix-ui/react-icons";
|
|
11
|
+
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import { Avatar, AvatarFallback } from "@viliha/vui-ui/avatar";
|
|
14
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
15
|
+
import { Checkbox } from "@viliha/vui-ui/checkbox";
|
|
16
|
+
import { Input } from "@viliha/vui-ui/input";
|
|
17
|
+
import { Select } from "@viliha/vui-ui/select";
|
|
18
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
19
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
20
|
+
|
|
21
|
+
type Theme = "light" | "dark" | "system";
|
|
22
|
+
|
|
23
|
+
const THEME_OPTIONS: { value: Theme; label: string; icon: typeof SunIcon }[] = [
|
|
24
|
+
{ value: "light", label: "Light", icon: SunIcon },
|
|
25
|
+
{ value: "dark", label: "Dark", icon: MoonIcon },
|
|
26
|
+
{ value: "system", label: "System", icon: DesktopIcon },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function applyTheme(theme: Theme) {
|
|
30
|
+
const dark =
|
|
31
|
+
theme === "dark" ||
|
|
32
|
+
(theme === "system" &&
|
|
33
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
34
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
35
|
+
try {
|
|
36
|
+
if (theme === "system") localStorage.removeItem("theme");
|
|
37
|
+
else localStorage.setItem("theme", theme);
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore storage failures
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Section({
|
|
44
|
+
title,
|
|
45
|
+
description,
|
|
46
|
+
className,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
title: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
className?: string;
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<section
|
|
56
|
+
className={cn("overflow-hidden rounded-lg border border-border", className)}
|
|
57
|
+
>
|
|
58
|
+
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
|
59
|
+
<h2 className="font-semibold text-[var(--button-primary)]">{title}</h2>
|
|
60
|
+
{description && (
|
|
61
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
<div className="p-4">{children}</div>
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Field({
|
|
70
|
+
label,
|
|
71
|
+
htmlFor,
|
|
72
|
+
children,
|
|
73
|
+
}: {
|
|
74
|
+
label: string;
|
|
75
|
+
htmlFor?: string;
|
|
76
|
+
children: React.ReactNode;
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="grid gap-1.5 sm:grid-cols-[180px_1fr] sm:items-center sm:gap-4">
|
|
80
|
+
<label htmlFor={htmlFor} className="text-sm text-muted-foreground">
|
|
81
|
+
{label}
|
|
82
|
+
</label>
|
|
83
|
+
<div className="min-w-0 max-w-sm">{children}</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function SettingsPage() {
|
|
89
|
+
const [theme, setTheme] = React.useState<Theme>("system");
|
|
90
|
+
const [name, setName] = React.useState("Admin User");
|
|
91
|
+
const [email, setEmail] = React.useState("admin@viliha.example");
|
|
92
|
+
const [role, setRole] = React.useState("administrator");
|
|
93
|
+
const [notifyEmail, setNotifyEmail] = React.useState(true);
|
|
94
|
+
const [notifyDesktop, setNotifyDesktop] = React.useState(false);
|
|
95
|
+
const [notifyWeekly, setNotifyWeekly] = React.useState(true);
|
|
96
|
+
const [saved, setSaved] = React.useState(false);
|
|
97
|
+
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
try {
|
|
100
|
+
const stored = localStorage.getItem("theme") as Theme | null;
|
|
101
|
+
setTheme(stored ?? "system");
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
function chooseTheme(next: Theme) {
|
|
108
|
+
setTheme(next);
|
|
109
|
+
applyTheme(next);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function save() {
|
|
113
|
+
setSaved(true);
|
|
114
|
+
window.setTimeout(() => setSaved(false), 1600);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex h-full flex-col">
|
|
119
|
+
<SetPageTitle title="Settings" icon={GearIcon} />
|
|
120
|
+
|
|
121
|
+
{/* Breadcrumb bar — matches the record form pages. */}
|
|
122
|
+
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
|
123
|
+
<Breadcrumbs />
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Content — padded, bordered card with a fixed footer (matches the add form). */}
|
|
127
|
+
<div className="min-h-0 flex-1 overflow-hidden p-4">
|
|
128
|
+
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
129
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
|
130
|
+
<div className="w-full space-y-4">
|
|
131
|
+
<Section title="Profile" description="Your personal information.">
|
|
132
|
+
<div className="space-y-4 sm:max-w-xl">
|
|
133
|
+
<div className="flex items-center gap-3">
|
|
134
|
+
<Avatar className="size-12">
|
|
135
|
+
<AvatarFallback>AU</AvatarFallback>
|
|
136
|
+
</Avatar>
|
|
137
|
+
<Button>Change avatar</Button>
|
|
138
|
+
</div>
|
|
139
|
+
<Field label="Name" htmlFor="name">
|
|
140
|
+
<Input
|
|
141
|
+
id="name"
|
|
142
|
+
value={name}
|
|
143
|
+
onChange={(e) => setName(e.target.value)}
|
|
144
|
+
/>
|
|
145
|
+
</Field>
|
|
146
|
+
<Field label="Email" htmlFor="email">
|
|
147
|
+
<Input
|
|
148
|
+
id="email"
|
|
149
|
+
type="email"
|
|
150
|
+
value={email}
|
|
151
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
152
|
+
/>
|
|
153
|
+
</Field>
|
|
154
|
+
<Field label="Role" htmlFor="role">
|
|
155
|
+
<Select
|
|
156
|
+
id="role"
|
|
157
|
+
ariaLabel="Role"
|
|
158
|
+
value={role}
|
|
159
|
+
onValueChange={setRole}
|
|
160
|
+
options={[
|
|
161
|
+
{ value: "administrator", label: "Administrator" },
|
|
162
|
+
{ value: "manager", label: "Manager" },
|
|
163
|
+
{ value: "member", label: "Member" },
|
|
164
|
+
{ value: "viewer", label: "Viewer" },
|
|
165
|
+
]}
|
|
166
|
+
/>
|
|
167
|
+
</Field>
|
|
168
|
+
</div>
|
|
169
|
+
</Section>
|
|
170
|
+
|
|
171
|
+
<Section title="Appearance" description="Choose how the app looks.">
|
|
172
|
+
<div className="grid grid-cols-3 gap-3">
|
|
173
|
+
{THEME_OPTIONS.map((opt) => {
|
|
174
|
+
const active = theme === opt.value;
|
|
175
|
+
const Icon = opt.icon;
|
|
176
|
+
return (
|
|
177
|
+
<button
|
|
178
|
+
key={opt.value}
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={() => chooseTheme(opt.value)}
|
|
181
|
+
aria-pressed={active}
|
|
182
|
+
className={cn(
|
|
183
|
+
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors",
|
|
184
|
+
active
|
|
185
|
+
? "border-ring bg-accent"
|
|
186
|
+
: "border-border hover:bg-accent/50",
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<Icon className="size-5" />
|
|
190
|
+
<span className="text-sm">{opt.label}</span>
|
|
191
|
+
</button>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
</Section>
|
|
196
|
+
|
|
197
|
+
<Section
|
|
198
|
+
title="Notifications"
|
|
199
|
+
description="Pick what you want to hear about."
|
|
200
|
+
>
|
|
201
|
+
<div className="space-y-3">
|
|
202
|
+
<label
|
|
203
|
+
htmlFor="notify-email"
|
|
204
|
+
className="flex items-center gap-2 text-sm"
|
|
205
|
+
>
|
|
206
|
+
<Checkbox
|
|
207
|
+
id="notify-email"
|
|
208
|
+
checked={notifyEmail}
|
|
209
|
+
onChange={(e) => setNotifyEmail(e.target.checked)}
|
|
210
|
+
/>
|
|
211
|
+
<BellIcon className="size-3.5 text-muted-foreground" />
|
|
212
|
+
Email notifications
|
|
213
|
+
</label>
|
|
214
|
+
<label
|
|
215
|
+
htmlFor="notify-desktop"
|
|
216
|
+
className="flex items-center gap-2 text-sm"
|
|
217
|
+
>
|
|
218
|
+
<Checkbox
|
|
219
|
+
id="notify-desktop"
|
|
220
|
+
checked={notifyDesktop}
|
|
221
|
+
onChange={(e) => setNotifyDesktop(e.target.checked)}
|
|
222
|
+
/>
|
|
223
|
+
<DesktopIcon className="size-3.5 text-muted-foreground" />
|
|
224
|
+
Desktop notifications
|
|
225
|
+
</label>
|
|
226
|
+
<label
|
|
227
|
+
htmlFor="notify-weekly"
|
|
228
|
+
className="flex items-center gap-2 text-sm"
|
|
229
|
+
>
|
|
230
|
+
<Checkbox
|
|
231
|
+
id="notify-weekly"
|
|
232
|
+
checked={notifyWeekly}
|
|
233
|
+
onChange={(e) => setNotifyWeekly(e.target.checked)}
|
|
234
|
+
/>
|
|
235
|
+
Weekly summary email
|
|
236
|
+
</label>
|
|
237
|
+
</div>
|
|
238
|
+
</Section>
|
|
239
|
+
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
{/* Fixed footer — matches the add form's action bar. */}
|
|
243
|
+
<div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
|
|
244
|
+
{saved && (
|
|
245
|
+
<span className="text-sm text-muted-foreground">Saved ✓</span>
|
|
246
|
+
)}
|
|
247
|
+
<Button variant="primary" onClick={save}>
|
|
248
|
+
Save changes
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
ArrowLeftIcon,
|
|
6
|
+
ArrowRightIcon,
|
|
7
|
+
CheckIcon,
|
|
8
|
+
CubeIcon as Building,
|
|
9
|
+
LockClosedIcon,
|
|
10
|
+
RowsIcon,
|
|
11
|
+
} from "@radix-ui/react-icons";
|
|
12
|
+
|
|
13
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
14
|
+
import { Input } from "@viliha/vui-ui/input";
|
|
15
|
+
import { Select } from "@viliha/vui-ui/select";
|
|
16
|
+
import { Steps, type Step } from "@viliha/vui-ui/steps";
|
|
17
|
+
import { RequiredMark } from "@viliha/vui-ui/required-mark";
|
|
18
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
19
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
20
|
+
|
|
21
|
+
/** Label + control on one row (label left), matching the RecordForm add-form. */
|
|
22
|
+
function Field({
|
|
23
|
+
label,
|
|
24
|
+
htmlFor,
|
|
25
|
+
required,
|
|
26
|
+
hint,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
label: string;
|
|
30
|
+
htmlFor?: string;
|
|
31
|
+
required?: boolean;
|
|
32
|
+
hint?: string;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="grid gap-1.5 sm:grid-cols-[140px_1fr] sm:items-center sm:gap-4">
|
|
37
|
+
<label
|
|
38
|
+
htmlFor={htmlFor}
|
|
39
|
+
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
|
40
|
+
>
|
|
41
|
+
{label}
|
|
42
|
+
{required && <RequiredMark />}
|
|
43
|
+
</label>
|
|
44
|
+
<div className="min-w-0">
|
|
45
|
+
{children}
|
|
46
|
+
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const STEPS: Step[] = [
|
|
53
|
+
{ label: "Organization", description: "Business details" },
|
|
54
|
+
{ label: "Account", description: "Your credentials" },
|
|
55
|
+
{ label: "Review", description: "Confirm details" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const PLANS = [
|
|
59
|
+
{ value: "free", label: "Free" },
|
|
60
|
+
{ value: "pro", label: "Pro" },
|
|
61
|
+
{ value: "enterprise", label: "Enterprise" },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function slugify(value: string): string {
|
|
65
|
+
return value
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.trim()
|
|
68
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
69
|
+
.replace(/^-+|-+$/g, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function SectionBar({
|
|
73
|
+
title,
|
|
74
|
+
icon: Icon,
|
|
75
|
+
}: {
|
|
76
|
+
title: string;
|
|
77
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<h3 className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 font-semibold text-[var(--button-primary)]">
|
|
81
|
+
<Icon className="size-4 text-[var(--button-primary)]" />
|
|
82
|
+
{title}
|
|
83
|
+
</h3>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A working multi-step wizard built on the Steps indicator. */
|
|
88
|
+
function WizardDemo() {
|
|
89
|
+
const [step, setStep] = React.useState(0);
|
|
90
|
+
const [name, setName] = React.useState("");
|
|
91
|
+
const [slug, setSlug] = React.useState("");
|
|
92
|
+
const [slugTouched, setSlugTouched] = React.useState(false);
|
|
93
|
+
const [plan, setPlan] = React.useState("free");
|
|
94
|
+
const [email, setEmail] = React.useState("");
|
|
95
|
+
const [done, setDone] = React.useState(false);
|
|
96
|
+
|
|
97
|
+
const onName = (value: string) => {
|
|
98
|
+
setName(value);
|
|
99
|
+
if (!slugTouched) setSlug(slugify(value));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const isLast = step === STEPS.length - 1;
|
|
103
|
+
const canNext =
|
|
104
|
+
step === 0 ? name.trim() !== "" : step === 1 ? email.trim() !== "" : true;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
108
|
+
{/* Stepper — fixed at the top */}
|
|
109
|
+
<div className="shrink-0 border-b border-border p-4 md:p-6">
|
|
110
|
+
<Steps steps={STEPS} current={step} />
|
|
111
|
+
</div>
|
|
112
|
+
{/* Scrollable form body — scrolls when the step has many fields */}
|
|
113
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
|
114
|
+
{done ? (
|
|
115
|
+
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
|
116
|
+
<span className="grid size-11 place-items-center rounded-full bg-accent">
|
|
117
|
+
<CheckIcon className="size-5 text-[var(--button-primary)]" />
|
|
118
|
+
</span>
|
|
119
|
+
<p className="font-medium">Account created</p>
|
|
120
|
+
<p className="text-sm text-muted-foreground">
|
|
121
|
+
{name || "Your organization"} is ready on the {plan} plan.
|
|
122
|
+
</p>
|
|
123
|
+
<Button
|
|
124
|
+
className="mt-2"
|
|
125
|
+
onClick={() => {
|
|
126
|
+
setDone(false);
|
|
127
|
+
setStep(0);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
Start over
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<div className="overflow-hidden rounded-lg border border-border">
|
|
135
|
+
{step === 0 && (
|
|
136
|
+
<>
|
|
137
|
+
<SectionBar title="Basic Information" icon={Building} />
|
|
138
|
+
<div className="space-y-4 p-4">
|
|
139
|
+
<Field label="Name" htmlFor="s-name" required>
|
|
140
|
+
<Input
|
|
141
|
+
id="s-name"
|
|
142
|
+
value={name}
|
|
143
|
+
onChange={(e) => onName(e.target.value)}
|
|
144
|
+
placeholder="Organization name"
|
|
145
|
+
/>
|
|
146
|
+
</Field>
|
|
147
|
+
<Field
|
|
148
|
+
label="Slug"
|
|
149
|
+
htmlFor="s-slug"
|
|
150
|
+
required
|
|
151
|
+
hint="Unique identifier, auto-generated from name"
|
|
152
|
+
>
|
|
153
|
+
<Input
|
|
154
|
+
id="s-slug"
|
|
155
|
+
value={slug}
|
|
156
|
+
onChange={(e) => {
|
|
157
|
+
setSlugTouched(true);
|
|
158
|
+
setSlug(e.target.value);
|
|
159
|
+
}}
|
|
160
|
+
placeholder="organization-slug"
|
|
161
|
+
className="font-mono"
|
|
162
|
+
/>
|
|
163
|
+
</Field>
|
|
164
|
+
<Field label="Plan" htmlFor="s-plan" required>
|
|
165
|
+
<Select
|
|
166
|
+
id="s-plan"
|
|
167
|
+
ariaLabel="Plan"
|
|
168
|
+
value={plan}
|
|
169
|
+
onValueChange={setPlan}
|
|
170
|
+
options={PLANS}
|
|
171
|
+
/>
|
|
172
|
+
</Field>
|
|
173
|
+
</div>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
{step === 1 && (
|
|
177
|
+
<>
|
|
178
|
+
<SectionBar title="Your credentials" icon={LockClosedIcon} />
|
|
179
|
+
<div className="space-y-4 p-4">
|
|
180
|
+
<Field label="Email" htmlFor="s-email" required>
|
|
181
|
+
<Input
|
|
182
|
+
id="s-email"
|
|
183
|
+
type="email"
|
|
184
|
+
value={email}
|
|
185
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
186
|
+
placeholder="you@company.com"
|
|
187
|
+
/>
|
|
188
|
+
</Field>
|
|
189
|
+
</div>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
{step === 2 && (
|
|
193
|
+
<>
|
|
194
|
+
<SectionBar title="Confirm details" icon={CheckIcon} />
|
|
195
|
+
<dl className="divide-y divide-border p-4 pt-0">
|
|
196
|
+
{[
|
|
197
|
+
["Name", name || "—"],
|
|
198
|
+
["Slug", slug || "—"],
|
|
199
|
+
["Plan", PLANS.find((p) => p.value === plan)?.label ?? plan],
|
|
200
|
+
["Email", email || "—"],
|
|
201
|
+
].map(([label, value]) => (
|
|
202
|
+
<div key={label} className="flex items-center gap-3 py-2.5">
|
|
203
|
+
<dt className="w-28 shrink-0 text-muted-foreground">
|
|
204
|
+
{label}
|
|
205
|
+
</dt>
|
|
206
|
+
<dd className="min-w-0 flex-1 break-words">{value}</dd>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</dl>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{!done && (
|
|
217
|
+
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/40 px-4 py-3">
|
|
218
|
+
<Button onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0}>
|
|
219
|
+
<ArrowLeftIcon className="size-4" />
|
|
220
|
+
Back
|
|
221
|
+
</Button>
|
|
222
|
+
<Button
|
|
223
|
+
variant="primary"
|
|
224
|
+
disabled={!canNext}
|
|
225
|
+
onClick={() => (isLast ? setDone(true) : setStep((s) => s + 1))}
|
|
226
|
+
>
|
|
227
|
+
{isLast ? (
|
|
228
|
+
<>
|
|
229
|
+
<CheckIcon className="size-4" />
|
|
230
|
+
Create account
|
|
231
|
+
</>
|
|
232
|
+
) : (
|
|
233
|
+
<>
|
|
234
|
+
Next
|
|
235
|
+
<ArrowRightIcon className="size-4" />
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default function StepsPage() {
|
|
246
|
+
return (
|
|
247
|
+
<div className="flex h-full flex-col">
|
|
248
|
+
<SetPageTitle title="Steps" icon={RowsIcon} />
|
|
249
|
+
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
|
|
250
|
+
<Breadcrumbs />
|
|
251
|
+
<span className="hidden truncate text-muted-foreground md:block">
|
|
252
|
+
Multi-step wizard built on the <code>Steps</code> component.
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="min-h-0 flex-1 overflow-hidden p-4">
|
|
257
|
+
<div className="mx-auto h-full w-full max-w-3xl">
|
|
258
|
+
<WizardDemo />
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: "Support",
|
|
5
|
+
description: "A support desk demo — ticket list, detail, status/priority, replies.",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function SupportLayout({
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|