@viliha/vui-ui 1.1.7 → 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/src/record-view.tsx +10 -5
- 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,184 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { RequiredMark } from "@viliha/vui-ui/required-mark";
|
|
5
|
+
|
|
6
|
+
/** Multicolor Google "G" (brand mark — colors are intentional). */
|
|
7
|
+
export function GoogleIcon({ className }: { className?: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<svg viewBox="0 0 24 24" className={cn("size-4", className)} aria-hidden="true">
|
|
10
|
+
<path
|
|
11
|
+
fill="#4285F4"
|
|
12
|
+
d="M23.06 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h6.2a5.3 5.3 0 0 1-2.3 3.48v2.89h3.72c2.18-2 3.44-4.96 3.44-8.38Z"
|
|
13
|
+
/>
|
|
14
|
+
<path
|
|
15
|
+
fill="#34A853"
|
|
16
|
+
d="M12 24c3.1 0 5.7-1.03 7.6-2.79l-3.72-2.88c-1.03.69-2.35 1.1-3.88 1.1-2.98 0-5.5-2.01-6.4-4.72H1.75v2.97A11.5 11.5 0 0 0 12 24Z"
|
|
17
|
+
/>
|
|
18
|
+
<path
|
|
19
|
+
fill="#FBBC05"
|
|
20
|
+
d="M5.6 14.71a6.9 6.9 0 0 1 0-4.42V7.32H1.75a11.5 11.5 0 0 0 0 10.36l3.85-2.97Z"
|
|
21
|
+
/>
|
|
22
|
+
<path
|
|
23
|
+
fill="#EA4335"
|
|
24
|
+
d="M12 4.75c1.68 0 3.19.58 4.38 1.71l3.28-3.28C17.7 1.19 15.1 0 12 0 7.44 0 3.5 2.62 1.75 6.42l3.85 2.97C6.5 6.76 9.02 4.75 12 4.75Z"
|
|
25
|
+
/>
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "or" separator between provider buttons and the email form. */
|
|
31
|
+
export function OrDivider({ label = "or" }: { label?: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex items-center gap-3 py-1 text-muted-foreground">
|
|
34
|
+
<span className="h-px flex-1 bg-border" />
|
|
35
|
+
<span>{label}</span>
|
|
36
|
+
<span className="h-px flex-1 bg-border" />
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Labeled form field with optional required marker / hint / error text. */
|
|
42
|
+
export function Field({
|
|
43
|
+
label,
|
|
44
|
+
htmlFor,
|
|
45
|
+
hint,
|
|
46
|
+
error,
|
|
47
|
+
required,
|
|
48
|
+
children,
|
|
49
|
+
}: {
|
|
50
|
+
label: string;
|
|
51
|
+
htmlFor?: string;
|
|
52
|
+
hint?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
required?: boolean;
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-1.5">
|
|
59
|
+
<label
|
|
60
|
+
htmlFor={htmlFor}
|
|
61
|
+
className="flex items-center gap-1 font-medium leading-relaxed"
|
|
62
|
+
>
|
|
63
|
+
{label}
|
|
64
|
+
{required && <RequiredMark />}
|
|
65
|
+
</label>
|
|
66
|
+
{children}
|
|
67
|
+
{error ? (
|
|
68
|
+
<p className="text-destructive">{error}</p>
|
|
69
|
+
) : hint ? (
|
|
70
|
+
<p className="text-muted-foreground">{hint}</p>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Circular icon badge used on confirmation screens ("check your email"). */
|
|
77
|
+
export function IconBadge({ children }: { children: React.ReactNode }) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="mx-auto grid size-12 place-items-center rounded-full bg-primary/10 text-primary">
|
|
80
|
+
{children}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sectioned auth card — the same header / body / footer treatment as the app's
|
|
87
|
+
* dialogs (bordered sections), so every auth screen is consistent and reusable.
|
|
88
|
+
* Compose: <AuthCard><AuthCardHeader …/><AuthCardBody>…</AuthCardBody>
|
|
89
|
+
* <AuthCardFooter>…</AuthCardFooter></AuthCard>.
|
|
90
|
+
*/
|
|
91
|
+
export function AuthCard({
|
|
92
|
+
className,
|
|
93
|
+
children,
|
|
94
|
+
}: {
|
|
95
|
+
className?: string;
|
|
96
|
+
children: React.ReactNode;
|
|
97
|
+
}) {
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className={cn(
|
|
101
|
+
"overflow-hidden rounded-lg border border-border bg-background shadow-sm",
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Bordered header section: optional icon badge, title, optional description. */
|
|
111
|
+
export function AuthCardHeader({
|
|
112
|
+
title,
|
|
113
|
+
description,
|
|
114
|
+
icon,
|
|
115
|
+
}: {
|
|
116
|
+
title: string;
|
|
117
|
+
description?: React.ReactNode;
|
|
118
|
+
icon?: React.ReactNode;
|
|
119
|
+
}) {
|
|
120
|
+
return (
|
|
121
|
+
<div className="border-b border-border bg-muted/40 px-5 py-4 text-center">
|
|
122
|
+
{icon && (
|
|
123
|
+
<div className="mb-3">
|
|
124
|
+
<IconBadge>{icon}</IconBadge>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
<h1 className="text-base font-semibold tracking-tight">{title}</h1>
|
|
128
|
+
{description && (
|
|
129
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Content section. */
|
|
136
|
+
export function AuthCardBody({
|
|
137
|
+
className,
|
|
138
|
+
children,
|
|
139
|
+
}: {
|
|
140
|
+
className?: string;
|
|
141
|
+
children: React.ReactNode;
|
|
142
|
+
}) {
|
|
143
|
+
return (
|
|
144
|
+
<div className={cn("space-y-4 px-5 py-6 leading-relaxed", className)}>
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Secondary nav row in a footer (e.g. "New here? Sign up") — set off from the
|
|
151
|
+
* primary action by its own top divider for a clean, consistent hierarchy. */
|
|
152
|
+
export function AuthCardAside({
|
|
153
|
+
className,
|
|
154
|
+
children,
|
|
155
|
+
}: {
|
|
156
|
+
className?: string;
|
|
157
|
+
children: React.ReactNode;
|
|
158
|
+
}) {
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
className={cn(
|
|
162
|
+
"mt-4 space-y-3 border-t border-border pt-4 text-center leading-relaxed text-muted-foreground",
|
|
163
|
+
className,
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Bordered actions/footer section. */
|
|
172
|
+
export function AuthCardFooter({
|
|
173
|
+
className,
|
|
174
|
+
children,
|
|
175
|
+
}: {
|
|
176
|
+
className?: string;
|
|
177
|
+
children: React.ReactNode;
|
|
178
|
+
}) {
|
|
179
|
+
return (
|
|
180
|
+
<div className={cn("border-t border-border bg-muted/40 px-5 py-4", className)}>
|
|
181
|
+
{children}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
import { Breadcrumbs as UiBreadcrumbs } from "@viliha/vui-ui/breadcrumbs";
|
|
7
|
+
import { crumbsFor } from "@/app/_components/route-meta";
|
|
8
|
+
|
|
9
|
+
/** Route-derived breadcrumb trail. Delegates rendering to the shared
|
|
10
|
+
`Breadcrumbs` component so every page uses one consistent style. */
|
|
11
|
+
export function Breadcrumbs() {
|
|
12
|
+
const pathname = usePathname();
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const crumbs = crumbsFor(pathname);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<UiBreadcrumbs
|
|
18
|
+
onBack={() => router.back()}
|
|
19
|
+
linkComponent={Link}
|
|
20
|
+
crumbs={crumbs.map((c) => ({
|
|
21
|
+
label: c.label,
|
|
22
|
+
href: c.isLast ? undefined : c.href,
|
|
23
|
+
}))}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CommandPalette,
|
|
8
|
+
type CommandAction,
|
|
9
|
+
} from "@viliha/vui-ui/command-palette";
|
|
10
|
+
import { organizations, employees } from "@/lib/demo-data";
|
|
11
|
+
import {
|
|
12
|
+
branches,
|
|
13
|
+
departments,
|
|
14
|
+
markets,
|
|
15
|
+
businesses,
|
|
16
|
+
regions,
|
|
17
|
+
countries,
|
|
18
|
+
cities,
|
|
19
|
+
currencies,
|
|
20
|
+
languages,
|
|
21
|
+
} from "@/lib/mock-data";
|
|
22
|
+
import { companies, people, opportunities } from "@/lib/crm-data";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Global search — a ⌘⌥K palette that searches across *records* (organizations,
|
|
26
|
+
* people, opportunities, reference data…), as opposed to Quick actions (⌘K),
|
|
27
|
+
* which navigates between *pages*. Both reuse the same headless `CommandPalette`
|
|
28
|
+
* from @viliha/vui-ui; only the action list differs.
|
|
29
|
+
*
|
|
30
|
+
* The record index below is the demo's stand-in for a real search backend —
|
|
31
|
+
* swap `INDEX` for results from your API and everything else stays the same.
|
|
32
|
+
* Each result navigates to where the record lives (its list page, or the edit
|
|
33
|
+
* route when there is one).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
type Indexed = {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
group: string;
|
|
40
|
+
/** Extra text the search matches beyond the label. */
|
|
41
|
+
keywords: string;
|
|
42
|
+
/** Where selecting the result navigates. */
|
|
43
|
+
href: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Flat, searchable index of every demo record. Built once at module load. */
|
|
47
|
+
const INDEX: Indexed[] = [
|
|
48
|
+
...organizations.map((o) => ({
|
|
49
|
+
id: `org-${o.id}`,
|
|
50
|
+
label: o.name,
|
|
51
|
+
group: "Organizations",
|
|
52
|
+
keywords: `${o.url} ${o.email} ${o.country}`,
|
|
53
|
+
href: `/organizations/edit?id=${o.id}`,
|
|
54
|
+
})),
|
|
55
|
+
...employees.map((e) => ({
|
|
56
|
+
id: `emp-${e.id}`,
|
|
57
|
+
label: `${e.firstName} ${e.lastName}`,
|
|
58
|
+
group: "Employees",
|
|
59
|
+
keywords: `${e.code} ${e.email} ${e.department} ${e.organization}`,
|
|
60
|
+
href: "/employees",
|
|
61
|
+
})),
|
|
62
|
+
...branches.map((b) => ({
|
|
63
|
+
id: `br-${b.id}`,
|
|
64
|
+
label: b.name,
|
|
65
|
+
group: "Branches",
|
|
66
|
+
keywords: `${b.code} ${b.city} ${b.organization} ${b.email}`,
|
|
67
|
+
href: "/branches",
|
|
68
|
+
})),
|
|
69
|
+
...departments.map((d) => ({
|
|
70
|
+
id: `dep-${d.id}`,
|
|
71
|
+
label: d.title,
|
|
72
|
+
group: "Departments",
|
|
73
|
+
keywords: `${d.code} ${d.organization}`,
|
|
74
|
+
href: "/departments",
|
|
75
|
+
})),
|
|
76
|
+
...markets.map((m) => ({
|
|
77
|
+
id: `mkt-${m.id}`,
|
|
78
|
+
label: m.name,
|
|
79
|
+
group: "Markets",
|
|
80
|
+
keywords: m.organization,
|
|
81
|
+
href: "/markets",
|
|
82
|
+
})),
|
|
83
|
+
...businesses.map((b) => ({
|
|
84
|
+
id: `biz-${b.id}`,
|
|
85
|
+
label: b.title,
|
|
86
|
+
group: "Businesses",
|
|
87
|
+
keywords: `${b.code} ${b.description}`,
|
|
88
|
+
href: "/businesses",
|
|
89
|
+
})),
|
|
90
|
+
...companies.map((c) => ({
|
|
91
|
+
id: `co-${c.id}`,
|
|
92
|
+
label: c.name,
|
|
93
|
+
group: "Companies",
|
|
94
|
+
keywords: `${c.domain} ${c.industry} ${c.city} ${c.country}`,
|
|
95
|
+
href: "/crm/companies",
|
|
96
|
+
})),
|
|
97
|
+
...people.map((p) => ({
|
|
98
|
+
id: `pp-${p.id}`,
|
|
99
|
+
label: `${p.firstName} ${p.lastName}`,
|
|
100
|
+
group: "People",
|
|
101
|
+
keywords: `${p.email} ${p.jobTitle} ${p.company} ${p.city}`,
|
|
102
|
+
href: "/crm/people",
|
|
103
|
+
})),
|
|
104
|
+
...opportunities.map((o) => ({
|
|
105
|
+
id: `opp-${o.id}`,
|
|
106
|
+
label: o.name,
|
|
107
|
+
group: "Opportunities",
|
|
108
|
+
keywords: `${o.company} ${o.owner} ${o.stage}`,
|
|
109
|
+
href: "/crm/opportunities",
|
|
110
|
+
})),
|
|
111
|
+
...regions.map((r) => ({
|
|
112
|
+
id: `rg-${r.id}`,
|
|
113
|
+
label: r.name,
|
|
114
|
+
group: "Regions",
|
|
115
|
+
keywords: r.code,
|
|
116
|
+
href: "/system/regions",
|
|
117
|
+
})),
|
|
118
|
+
...countries.map((c) => ({
|
|
119
|
+
id: `ct-${c.id}`,
|
|
120
|
+
label: c.name,
|
|
121
|
+
group: "Countries",
|
|
122
|
+
keywords: `${c.code} ${c.region}`,
|
|
123
|
+
href: "/system/countries",
|
|
124
|
+
})),
|
|
125
|
+
...cities.map((c) => ({
|
|
126
|
+
id: `cy-${c.id}`,
|
|
127
|
+
label: c.name,
|
|
128
|
+
group: "Cities",
|
|
129
|
+
keywords: `${c.state} ${c.country}`,
|
|
130
|
+
href: "/system/cities",
|
|
131
|
+
})),
|
|
132
|
+
...currencies.map((c) => ({
|
|
133
|
+
id: `cur-${c.id}`,
|
|
134
|
+
label: c.name,
|
|
135
|
+
group: "Currencies",
|
|
136
|
+
keywords: `${c.code} ${c.symbol}`,
|
|
137
|
+
href: "/system/currencies",
|
|
138
|
+
})),
|
|
139
|
+
...languages.map((l) => ({
|
|
140
|
+
id: `lng-${l.id}`,
|
|
141
|
+
label: l.name,
|
|
142
|
+
group: "Languages",
|
|
143
|
+
keywords: l.code,
|
|
144
|
+
href: "/system/languages",
|
|
145
|
+
})),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
type Ctx = { open: () => void };
|
|
149
|
+
const GlobalSearchContext = React.createContext<Ctx | null>(null);
|
|
150
|
+
|
|
151
|
+
export function useGlobalSearch(): Ctx {
|
|
152
|
+
const ctx = React.useContext(GlobalSearchContext);
|
|
153
|
+
if (!ctx)
|
|
154
|
+
throw new Error(
|
|
155
|
+
"useGlobalSearch must be used within <GlobalSearchProvider>",
|
|
156
|
+
);
|
|
157
|
+
return ctx;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Holds the open state, mounts the palette once, and wires the global ⌘⌥K
|
|
161
|
+
* shortcut. Wrap the app shell with this. */
|
|
162
|
+
export function GlobalSearchProvider({
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
children: React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const [open, setOpen] = React.useState(false);
|
|
168
|
+
const router = useRouter();
|
|
169
|
+
|
|
170
|
+
React.useEffect(() => {
|
|
171
|
+
const onKey = (e: KeyboardEvent) => {
|
|
172
|
+
// ⌘⌥K / Ctrl+Alt+K — ⌘K (no Alt) is Quick actions. e.code, because macOS
|
|
173
|
+
// Option remaps e.key to a composed character.
|
|
174
|
+
if ((e.metaKey || e.ctrlKey) && e.altKey && e.code === "KeyK") {
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
setOpen((v) => !v);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
window.addEventListener("keydown", onKey);
|
|
180
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const actions = React.useMemo<CommandAction[]>(
|
|
184
|
+
() =>
|
|
185
|
+
INDEX.map((r) => ({
|
|
186
|
+
id: r.id,
|
|
187
|
+
label: r.label,
|
|
188
|
+
group: r.group,
|
|
189
|
+
keywords: r.keywords,
|
|
190
|
+
onSelect: () => router.push(r.href),
|
|
191
|
+
})),
|
|
192
|
+
[router],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const value = React.useMemo(() => ({ open: () => setOpen(true) }), []);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<GlobalSearchContext.Provider value={value}>
|
|
199
|
+
{children}
|
|
200
|
+
<CommandPalette
|
|
201
|
+
open={open}
|
|
202
|
+
onClose={() => setOpen(false)}
|
|
203
|
+
actions={actions}
|
|
204
|
+
placeholder="Search organizations, people, records…"
|
|
205
|
+
emptyMessage="No records match — try a name, code or email."
|
|
206
|
+
/>
|
|
207
|
+
</GlobalSearchContext.Provider>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
type LogoProps = {
|
|
6
|
+
/** Kept for call-site compatibility; the mark is the same either way. */
|
|
7
|
+
variant?: "mark" | "wordmark";
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Optional custom logo: drop an image in /public and set NEXT_PUBLIC_LOGO_URL
|
|
12
|
+
* (e.g. "/logo.svg"). Unset falls back to the built-in mark below. */
|
|
13
|
+
const LOGO_URL = process.env.NEXT_PUBLIC_LOGO_URL;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* App logo — your image via NEXT_PUBLIC_LOGO_URL, else a self-contained rounded
|
|
17
|
+
* blue badge with a stylised "V". Scales with the `className` size (default 24px).
|
|
18
|
+
*/
|
|
19
|
+
export function Logo({ className }: LogoProps) {
|
|
20
|
+
if (LOGO_URL) {
|
|
21
|
+
return (
|
|
22
|
+
<Image
|
|
23
|
+
src={LOGO_URL}
|
|
24
|
+
alt="Logo"
|
|
25
|
+
width={24}
|
|
26
|
+
height={24}
|
|
27
|
+
className={cn("h-6 w-6 object-contain", className)}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
<svg
|
|
33
|
+
viewBox="0 0 24 24"
|
|
34
|
+
role="img"
|
|
35
|
+
aria-label="Vui Starter"
|
|
36
|
+
className={cn("h-6 w-6", className)}
|
|
37
|
+
>
|
|
38
|
+
<rect width="24" height="24" rx="6" fill="var(--brand-indigo)" />
|
|
39
|
+
<path
|
|
40
|
+
d="M6.5 7.25 L12 16.75 L17.5 7.25"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="#ffffff"
|
|
43
|
+
strokeWidth="2.4"
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AvatarIcon as UserCircle,
|
|
3
|
+
BackpackIcon as Briefcase,
|
|
4
|
+
BarChartIcon as BarChart3,
|
|
5
|
+
BookmarkIcon as Flag,
|
|
6
|
+
CalendarIcon,
|
|
7
|
+
ChatBubbleIcon,
|
|
8
|
+
CheckCircledIcon as BadgeCheck,
|
|
9
|
+
CubeIcon as Building,
|
|
10
|
+
ExclamationTriangleIcon as AlertTriangle,
|
|
11
|
+
CubeIcon as Building2,
|
|
12
|
+
DashboardIcon as LayoutGrid,
|
|
13
|
+
EnterIcon as LogIn,
|
|
14
|
+
GearIcon as Settings,
|
|
15
|
+
GlobeIcon as Globe,
|
|
16
|
+
HomeIcon as Home,
|
|
17
|
+
HomeIcon as Landmark,
|
|
18
|
+
IdCardIcon as Contact,
|
|
19
|
+
LockClosedIcon as Lock,
|
|
20
|
+
MixIcon as Blocks,
|
|
21
|
+
Pencil2Icon as FormInput,
|
|
22
|
+
PersonIcon as Users,
|
|
23
|
+
QuestionMarkCircledIcon as HelpCircle,
|
|
24
|
+
RowsIcon as StepsIcon,
|
|
25
|
+
SewingPinFilledIcon as MapPin,
|
|
26
|
+
Share2Icon as Network,
|
|
27
|
+
TargetIcon as Target,
|
|
28
|
+
TextIcon as Languages,
|
|
29
|
+
TokensIcon as Coins,
|
|
30
|
+
} from "@radix-ui/react-icons";
|
|
31
|
+
|
|
32
|
+
/** Single source of truth for navigation. The sidebar renders it, and the
|
|
33
|
+
* breadcrumbs derive their behaviour from it (see SECTION_INDEX) — so adding a
|
|
34
|
+
* section or moving a page only ever needs editing here. */
|
|
35
|
+
|
|
36
|
+
/** Shared icon component type (all Radix icons share this shape). */
|
|
37
|
+
export type IconType = typeof Home;
|
|
38
|
+
export type NavLink = { label: string; href: string; icon: IconType; color?: string };
|
|
39
|
+
/** A collapsible parent with nested links (a "subsection"). */
|
|
40
|
+
export type NavGroup = {
|
|
41
|
+
label: string;
|
|
42
|
+
icon: IconType;
|
|
43
|
+
color?: string;
|
|
44
|
+
children: NavLink[];
|
|
45
|
+
};
|
|
46
|
+
export type NavEntry = NavLink | NavGroup;
|
|
47
|
+
export type NavSection = { title?: string; items: NavEntry[] };
|
|
48
|
+
|
|
49
|
+
export function isGroup(entry: NavEntry): entry is NavGroup {
|
|
50
|
+
return (entry as NavGroup).children !== undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const NAV: NavSection[] = [
|
|
54
|
+
{
|
|
55
|
+
items: [
|
|
56
|
+
{ label: "Home", href: "/dashboard", icon: Home, color: "text-blue-500" },
|
|
57
|
+
{ label: "Charts", href: "/charts", icon: BarChart3, color: "text-fuchsia-500" },
|
|
58
|
+
{
|
|
59
|
+
label: "Auth",
|
|
60
|
+
icon: Lock,
|
|
61
|
+
color: "text-rose-500",
|
|
62
|
+
children: [
|
|
63
|
+
{ label: "Sign in", href: "/auth/signin", icon: LogIn, color: "text-blue-500" },
|
|
64
|
+
{ label: "Sign up", href: "/auth/signup", icon: Users, color: "text-emerald-500" },
|
|
65
|
+
{ label: "Forgot password", href: "/auth/forgot-password", icon: HelpCircle, color: "text-amber-500" },
|
|
66
|
+
{ label: "Reset password", href: "/auth/reset-password", icon: Lock, color: "text-violet-500" },
|
|
67
|
+
{ label: "Verify code", href: "/auth/verify", icon: BadgeCheck, color: "text-teal-500" },
|
|
68
|
+
// Links to a non-existent path so it renders the real 404 (not-found.tsx).
|
|
69
|
+
{ label: "404 page", href: "/404", icon: AlertTriangle, color: "text-slate-500" },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
title: "shadcn/ui",
|
|
76
|
+
items: [
|
|
77
|
+
{ label: "Components", href: "/components", icon: Blocks, color: "text-indigo-500" },
|
|
78
|
+
{ label: "Forms", href: "/forms", icon: FormInput, color: "text-teal-500" },
|
|
79
|
+
{ label: "Steps", href: "/steps", icon: StepsIcon, color: "text-violet-500" },
|
|
80
|
+
{ label: "Calendar", href: "/calendar", icon: CalendarIcon, color: "text-rose-500" },
|
|
81
|
+
{ label: "Chat", href: "/chat", icon: ChatBubbleIcon, color: "text-sky-500" },
|
|
82
|
+
{ label: "Support", href: "/support", icon: HelpCircle, color: "text-emerald-500" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
title: "Records",
|
|
87
|
+
items: [
|
|
88
|
+
{ label: "Organizations", href: "/organizations", icon: Building2, color: "text-blue-500" },
|
|
89
|
+
{ label: "Branches", href: "/branches", icon: Network, color: "text-violet-500" },
|
|
90
|
+
{ label: "Departments", href: "/departments", icon: LayoutGrid, color: "text-amber-500" },
|
|
91
|
+
{ label: "Employees", href: "/employees", icon: Users, color: "text-cyan-500" },
|
|
92
|
+
{ label: "Markets", href: "/markets", icon: MapPin, color: "text-rose-500" },
|
|
93
|
+
{ label: "Businesses", href: "/businesses", icon: Briefcase, color: "text-emerald-500" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: "Workspace",
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
label: "CRM",
|
|
101
|
+
icon: UserCircle,
|
|
102
|
+
color: "text-indigo-500",
|
|
103
|
+
children: [
|
|
104
|
+
{ label: "Companies", href: "/crm/companies", icon: Building, color: "text-blue-500" },
|
|
105
|
+
{ label: "People", href: "/crm/people", icon: Contact, color: "text-sky-500" },
|
|
106
|
+
{ label: "Opportunities", href: "/crm/opportunities", icon: Target, color: "text-orange-500" },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: "System",
|
|
111
|
+
icon: Settings,
|
|
112
|
+
color: "text-slate-500",
|
|
113
|
+
children: [
|
|
114
|
+
{ label: "Regions", href: "/system/regions", icon: Globe, color: "text-teal-500" },
|
|
115
|
+
{ label: "Countries", href: "/system/countries", icon: Flag, color: "text-red-500" },
|
|
116
|
+
{ label: "Cities", href: "/system/cities", icon: Landmark, color: "text-amber-500" },
|
|
117
|
+
{ label: "Currencies", href: "/system/currencies", icon: Coins, color: "text-green-500" },
|
|
118
|
+
{ label: "Languages", href: "/system/languages", icon: Languages, color: "text-purple-500" },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/** Group parents have no index page of their own, so a breadcrumb (or any link)
|
|
126
|
+
* for one would 404. Derived automatically from NAV: each group maps its parent
|
|
127
|
+
* path → its first child, e.g. "/crm" → "/crm/companies". Add a group to NAV and
|
|
128
|
+
* its breadcrumb resolves correctly with zero extra wiring. */
|
|
129
|
+
export const SECTION_INDEX: Record<string, string> = (() => {
|
|
130
|
+
const map: Record<string, string> = {};
|
|
131
|
+
for (const section of NAV) {
|
|
132
|
+
for (const entry of section.items) {
|
|
133
|
+
const first = isGroup(entry) ? entry.children[0] : undefined;
|
|
134
|
+
if (first) map[first.href.slice(0, first.href.lastIndexOf("/")) || "/"] = first.href;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return map;
|
|
138
|
+
})();
|