@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,388 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useForm } from "react-hook-form";
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { format } from "date-fns";
|
|
8
|
+
import { CalendarIcon, Check, ChevronsUpDown } from "lucide-react";
|
|
9
|
+
import { Pencil2Icon } from "@radix-ui/react-icons";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
14
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
15
|
+
import { Demo } from "@/app/_components/showcase";
|
|
16
|
+
|
|
17
|
+
import { Button } from "@/components/ui/button";
|
|
18
|
+
import { Calendar } from "@/components/ui/calendar";
|
|
19
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
20
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
21
|
+
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
22
|
+
import { Input } from "@/components/ui/input";
|
|
23
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
|
24
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
25
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
26
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
27
|
+
import { Slider } from "@/components/ui/slider";
|
|
28
|
+
import { Switch } from "@/components/ui/switch";
|
|
29
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
30
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
31
|
+
|
|
32
|
+
const COUNTRIES = [
|
|
33
|
+
{ value: "us", label: "United States" },
|
|
34
|
+
{ value: "gb", label: "United Kingdom" },
|
|
35
|
+
{ value: "in", label: "India" },
|
|
36
|
+
{ value: "de", label: "Germany" },
|
|
37
|
+
{ value: "jp", label: "Japan" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const schema = z.object({
|
|
41
|
+
username: z.string().min(2, "At least 2 characters."),
|
|
42
|
+
email: z.string().email("Enter a valid email."),
|
|
43
|
+
bio: z.string().max(160, "160 characters max.").optional(),
|
|
44
|
+
role: z.string().min(1, "Pick a role."),
|
|
45
|
+
country: z.string().min(1, "Pick a country."),
|
|
46
|
+
dob: z.date({ message: "Pick a date." }),
|
|
47
|
+
plan: z.enum(["free", "pro", "team"], { message: "Choose a plan." }),
|
|
48
|
+
volume: z.number().min(0).max(100),
|
|
49
|
+
marketing: z.boolean(),
|
|
50
|
+
terms: z.boolean().refine((v) => v, "You must accept the terms."),
|
|
51
|
+
pin: z.string().length(6, "Enter all 6 digits."),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
type FormValues = z.infer<typeof schema>;
|
|
55
|
+
|
|
56
|
+
export default function FormsPage() {
|
|
57
|
+
const [comboOpen, setComboOpen] = React.useState(false);
|
|
58
|
+
|
|
59
|
+
const form = useForm<FormValues>({
|
|
60
|
+
resolver: zodResolver(schema),
|
|
61
|
+
defaultValues: {
|
|
62
|
+
username: "",
|
|
63
|
+
email: "",
|
|
64
|
+
bio: "",
|
|
65
|
+
role: "",
|
|
66
|
+
country: "",
|
|
67
|
+
plan: undefined,
|
|
68
|
+
volume: 50,
|
|
69
|
+
marketing: true,
|
|
70
|
+
terms: false,
|
|
71
|
+
pin: "",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function onSubmit(values: FormValues) {
|
|
76
|
+
toast("Form submitted", {
|
|
77
|
+
description: (
|
|
78
|
+
<pre className="mt-1 max-h-48 overflow-auto rounded bg-muted p-2 text-xs">
|
|
79
|
+
{JSON.stringify(values, null, 2)}
|
|
80
|
+
</pre>
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex h-full flex-col">
|
|
87
|
+
<SetPageTitle title="Forms" icon={Pencil2Icon} />
|
|
88
|
+
<Toaster />
|
|
89
|
+
|
|
90
|
+
{/* Action header */}
|
|
91
|
+
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
|
|
92
|
+
<Breadcrumbs />
|
|
93
|
+
<span className="hidden truncate text-muted-foreground md:block">
|
|
94
|
+
shadcn/ui Form — react-hook-form + Zod, validated inline.
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Content */}
|
|
99
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
100
|
+
<div className="flex flex-col gap-4 p-4">
|
|
101
|
+
<div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm leading-relaxed text-muted-foreground">
|
|
102
|
+
One <code className="font-mono text-foreground">Form</code> wiring
|
|
103
|
+
every shadcn field type — input, textarea, select, combobox, date
|
|
104
|
+
picker, radio, checkbox, switch, slider and OTP — with{" "}
|
|
105
|
+
<strong className="text-foreground">Zod</strong> validation and
|
|
106
|
+
inline <code className="font-mono text-foreground">FormMessage</code>{" "}
|
|
107
|
+
errors. Submit with empty fields to see validation.
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<Demo title="Everything form" desc="Fully validated, themed by tokens.">
|
|
111
|
+
<Form {...form}>
|
|
112
|
+
<form
|
|
113
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
114
|
+
className="grid w-full grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2"
|
|
115
|
+
>
|
|
116
|
+
<FormField
|
|
117
|
+
control={form.control}
|
|
118
|
+
name="username"
|
|
119
|
+
render={({ field }) => (
|
|
120
|
+
<FormItem>
|
|
121
|
+
<FormLabel>Username</FormLabel>
|
|
122
|
+
<FormControl>
|
|
123
|
+
<Input placeholder="ada" {...field} />
|
|
124
|
+
</FormControl>
|
|
125
|
+
<FormDescription>Your public display name.</FormDescription>
|
|
126
|
+
<FormMessage />
|
|
127
|
+
</FormItem>
|
|
128
|
+
)}
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<FormField
|
|
132
|
+
control={form.control}
|
|
133
|
+
name="email"
|
|
134
|
+
render={({ field }) => (
|
|
135
|
+
<FormItem>
|
|
136
|
+
<FormLabel>Email</FormLabel>
|
|
137
|
+
<FormControl>
|
|
138
|
+
<Input type="email" placeholder="you@example.com" {...field} />
|
|
139
|
+
</FormControl>
|
|
140
|
+
<FormMessage />
|
|
141
|
+
</FormItem>
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
<FormField
|
|
146
|
+
control={form.control}
|
|
147
|
+
name="bio"
|
|
148
|
+
render={({ field }) => (
|
|
149
|
+
<FormItem className="sm:col-span-2">
|
|
150
|
+
<FormLabel>Bio</FormLabel>
|
|
151
|
+
<FormControl>
|
|
152
|
+
<Textarea placeholder="Tell us about yourself" {...field} />
|
|
153
|
+
</FormControl>
|
|
154
|
+
<FormMessage />
|
|
155
|
+
</FormItem>
|
|
156
|
+
)}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
<FormField
|
|
160
|
+
control={form.control}
|
|
161
|
+
name="role"
|
|
162
|
+
render={({ field }) => (
|
|
163
|
+
<FormItem>
|
|
164
|
+
<FormLabel>Role</FormLabel>
|
|
165
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
166
|
+
<FormControl>
|
|
167
|
+
<SelectTrigger>
|
|
168
|
+
<SelectValue placeholder="Select a role" />
|
|
169
|
+
</SelectTrigger>
|
|
170
|
+
</FormControl>
|
|
171
|
+
<SelectContent>
|
|
172
|
+
<SelectItem value="administrator">Administrator</SelectItem>
|
|
173
|
+
<SelectItem value="manager">Manager</SelectItem>
|
|
174
|
+
<SelectItem value="member">Member</SelectItem>
|
|
175
|
+
</SelectContent>
|
|
176
|
+
</Select>
|
|
177
|
+
<FormMessage />
|
|
178
|
+
</FormItem>
|
|
179
|
+
)}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
<FormField
|
|
183
|
+
control={form.control}
|
|
184
|
+
name="country"
|
|
185
|
+
render={({ field }) => (
|
|
186
|
+
<FormItem className="flex flex-col">
|
|
187
|
+
<FormLabel>Country</FormLabel>
|
|
188
|
+
<Popover open={comboOpen} onOpenChange={setComboOpen}>
|
|
189
|
+
<PopoverTrigger asChild>
|
|
190
|
+
<FormControl>
|
|
191
|
+
<Button
|
|
192
|
+
variant="outline"
|
|
193
|
+
role="combobox"
|
|
194
|
+
className={cn(
|
|
195
|
+
"justify-between",
|
|
196
|
+
!field.value && "text-muted-foreground",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{field.value
|
|
200
|
+
? COUNTRIES.find((c) => c.value === field.value)?.label
|
|
201
|
+
: "Select country"}
|
|
202
|
+
<ChevronsUpDown className="ml-2 size-4 opacity-50" />
|
|
203
|
+
</Button>
|
|
204
|
+
</FormControl>
|
|
205
|
+
</PopoverTrigger>
|
|
206
|
+
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
|
207
|
+
<Command>
|
|
208
|
+
<CommandInput placeholder="Search country…" />
|
|
209
|
+
<CommandList>
|
|
210
|
+
<CommandEmpty>No country found.</CommandEmpty>
|
|
211
|
+
<CommandGroup>
|
|
212
|
+
{COUNTRIES.map((c) => (
|
|
213
|
+
<CommandItem
|
|
214
|
+
key={c.value}
|
|
215
|
+
value={c.label}
|
|
216
|
+
onSelect={() => {
|
|
217
|
+
form.setValue("country", c.value, {
|
|
218
|
+
shouldValidate: true,
|
|
219
|
+
});
|
|
220
|
+
setComboOpen(false);
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<Check
|
|
224
|
+
className={cn(
|
|
225
|
+
"mr-2 size-4",
|
|
226
|
+
field.value === c.value
|
|
227
|
+
? "opacity-100"
|
|
228
|
+
: "opacity-0",
|
|
229
|
+
)}
|
|
230
|
+
/>
|
|
231
|
+
{c.label}
|
|
232
|
+
</CommandItem>
|
|
233
|
+
))}
|
|
234
|
+
</CommandGroup>
|
|
235
|
+
</CommandList>
|
|
236
|
+
</Command>
|
|
237
|
+
</PopoverContent>
|
|
238
|
+
</Popover>
|
|
239
|
+
<FormDescription>Searchable combobox.</FormDescription>
|
|
240
|
+
<FormMessage />
|
|
241
|
+
</FormItem>
|
|
242
|
+
)}
|
|
243
|
+
/>
|
|
244
|
+
|
|
245
|
+
<FormField
|
|
246
|
+
control={form.control}
|
|
247
|
+
name="dob"
|
|
248
|
+
render={({ field }) => (
|
|
249
|
+
<FormItem className="flex flex-col">
|
|
250
|
+
<FormLabel>Date of birth</FormLabel>
|
|
251
|
+
<Popover>
|
|
252
|
+
<PopoverTrigger asChild>
|
|
253
|
+
<FormControl>
|
|
254
|
+
<Button
|
|
255
|
+
variant="outline"
|
|
256
|
+
className={cn(
|
|
257
|
+
"justify-start text-left font-normal",
|
|
258
|
+
!field.value && "text-muted-foreground",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
<CalendarIcon className="mr-2 size-4" />
|
|
262
|
+
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
|
263
|
+
</Button>
|
|
264
|
+
</FormControl>
|
|
265
|
+
</PopoverTrigger>
|
|
266
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
267
|
+
<Calendar
|
|
268
|
+
mode="single"
|
|
269
|
+
selected={field.value}
|
|
270
|
+
onSelect={field.onChange}
|
|
271
|
+
captionLayout="dropdown"
|
|
272
|
+
/>
|
|
273
|
+
</PopoverContent>
|
|
274
|
+
</Popover>
|
|
275
|
+
<FormMessage />
|
|
276
|
+
</FormItem>
|
|
277
|
+
)}
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
<FormField
|
|
281
|
+
control={form.control}
|
|
282
|
+
name="plan"
|
|
283
|
+
render={({ field }) => (
|
|
284
|
+
<FormItem>
|
|
285
|
+
<FormLabel>Plan</FormLabel>
|
|
286
|
+
<FormControl>
|
|
287
|
+
<RadioGroup
|
|
288
|
+
onValueChange={field.onChange}
|
|
289
|
+
value={field.value}
|
|
290
|
+
className="flex gap-6"
|
|
291
|
+
>
|
|
292
|
+
{["free", "pro", "team"].map((p) => (
|
|
293
|
+
<label key={p} className="flex items-center gap-2 text-sm capitalize">
|
|
294
|
+
<RadioGroupItem value={p} /> {p}
|
|
295
|
+
</label>
|
|
296
|
+
))}
|
|
297
|
+
</RadioGroup>
|
|
298
|
+
</FormControl>
|
|
299
|
+
<FormMessage />
|
|
300
|
+
</FormItem>
|
|
301
|
+
)}
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
<FormField
|
|
305
|
+
control={form.control}
|
|
306
|
+
name="volume"
|
|
307
|
+
render={({ field }) => (
|
|
308
|
+
<FormItem className="sm:col-span-2">
|
|
309
|
+
<FormLabel>Volume — {field.value}</FormLabel>
|
|
310
|
+
<FormControl>
|
|
311
|
+
<Slider
|
|
312
|
+
value={[field.value]}
|
|
313
|
+
max={100}
|
|
314
|
+
step={1}
|
|
315
|
+
onValueChange={(v) => field.onChange(v[0])}
|
|
316
|
+
/>
|
|
317
|
+
</FormControl>
|
|
318
|
+
<FormMessage />
|
|
319
|
+
</FormItem>
|
|
320
|
+
)}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
<FormField
|
|
324
|
+
control={form.control}
|
|
325
|
+
name="pin"
|
|
326
|
+
render={({ field }) => (
|
|
327
|
+
<FormItem className="sm:col-span-2">
|
|
328
|
+
<FormLabel>Verification code</FormLabel>
|
|
329
|
+
<FormControl>
|
|
330
|
+
<InputOTP maxLength={6} value={field.value} onChange={field.onChange}>
|
|
331
|
+
<InputOTPGroup>
|
|
332
|
+
{Array.from({ length: 6 }, (_, i) => (
|
|
333
|
+
<InputOTPSlot key={i} index={i} />
|
|
334
|
+
))}
|
|
335
|
+
</InputOTPGroup>
|
|
336
|
+
</InputOTP>
|
|
337
|
+
</FormControl>
|
|
338
|
+
<FormMessage />
|
|
339
|
+
</FormItem>
|
|
340
|
+
)}
|
|
341
|
+
/>
|
|
342
|
+
|
|
343
|
+
<FormField
|
|
344
|
+
control={form.control}
|
|
345
|
+
name="marketing"
|
|
346
|
+
render={({ field }) => (
|
|
347
|
+
<FormItem className="flex items-center justify-between rounded-lg border border-border p-3 sm:col-span-2">
|
|
348
|
+
<div className="space-y-0.5">
|
|
349
|
+
<FormLabel>Marketing emails</FormLabel>
|
|
350
|
+
<FormDescription>Product news and offers.</FormDescription>
|
|
351
|
+
</div>
|
|
352
|
+
<FormControl>
|
|
353
|
+
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
354
|
+
</FormControl>
|
|
355
|
+
</FormItem>
|
|
356
|
+
)}
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
<FormField
|
|
360
|
+
control={form.control}
|
|
361
|
+
name="terms"
|
|
362
|
+
render={({ field }) => (
|
|
363
|
+
<FormItem className="flex items-start gap-2 sm:col-span-2">
|
|
364
|
+
<FormControl>
|
|
365
|
+
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
|
366
|
+
</FormControl>
|
|
367
|
+
<div className="space-y-1 leading-none">
|
|
368
|
+
<FormLabel>Accept terms and conditions</FormLabel>
|
|
369
|
+
<FormMessage />
|
|
370
|
+
</div>
|
|
371
|
+
</FormItem>
|
|
372
|
+
)}
|
|
373
|
+
/>
|
|
374
|
+
|
|
375
|
+
<div className="flex gap-2 sm:col-span-2">
|
|
376
|
+
<Button type="submit">Submit</Button>
|
|
377
|
+
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
|
378
|
+
Reset
|
|
379
|
+
</Button>
|
|
380
|
+
</div>
|
|
381
|
+
</form>
|
|
382
|
+
</Form>
|
|
383
|
+
</Demo>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { PageChromeProvider } from "@viliha/vui-ui/record-view";
|
|
2
|
+
import { FOOTER_NOTICE, FOOTER_OVERRIDDEN, SITE } from "@/lib/seo";
|
|
3
|
+
import {
|
|
4
|
+
AppSidebar,
|
|
5
|
+
MobileNav,
|
|
6
|
+
SidebarProvider,
|
|
7
|
+
} from "@/app/_components/app-sidebar";
|
|
8
|
+
import { TopBar } from "@/app/_components/top-bar";
|
|
9
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
10
|
+
import { QuickActionsProvider } from "@/app/_components/quick-actions";
|
|
11
|
+
import { GlobalSearchProvider } from "@/app/_components/global-search";
|
|
12
|
+
import {
|
|
13
|
+
KeepAliveTabs,
|
|
14
|
+
OpenTabsProvider,
|
|
15
|
+
TabStrip,
|
|
16
|
+
} from "@/app/_components/open-tabs";
|
|
17
|
+
|
|
18
|
+
export default function AppLayout({
|
|
19
|
+
children,
|
|
20
|
+
}: Readonly<{
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}>) {
|
|
23
|
+
return (
|
|
24
|
+
<SidebarProvider>
|
|
25
|
+
<QuickActionsProvider>
|
|
26
|
+
<GlobalSearchProvider>
|
|
27
|
+
<OpenTabsProvider>
|
|
28
|
+
<div className="flex h-screen overflow-hidden">
|
|
29
|
+
<AppSidebar />
|
|
30
|
+
<PageChromeProvider titleLeading={<Breadcrumbs />}>
|
|
31
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
32
|
+
<TopBar />
|
|
33
|
+
<TabStrip />
|
|
34
|
+
{/* Single scroll owner is each page's own inner content area; this
|
|
35
|
+
wrapper clips (overflow-hidden) and is `relative` so any
|
|
36
|
+
absolutely-positioned descendant (e.g. cmdk's hidden label) is
|
|
37
|
+
contained here instead of escaping to extend the document. */}
|
|
38
|
+
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pb-14 md:pb-0">
|
|
39
|
+
{/* Keep-alive: open tabs stay mounted so switching is instant
|
|
40
|
+
(no remount/flash) and each page keeps its live state. */}
|
|
41
|
+
<KeepAliveTabs>{children}</KeepAliveTabs>
|
|
42
|
+
</div>
|
|
43
|
+
<footer className="hidden shrink-0 border-t border-border bg-background px-4 py-1 text-center text-[10px] text-muted-foreground md:block">
|
|
44
|
+
{FOOTER_OVERRIDDEN ? (
|
|
45
|
+
FOOTER_NOTICE
|
|
46
|
+
) : (
|
|
47
|
+
<>
|
|
48
|
+
© {SITE.copyrightYear}{" "}
|
|
49
|
+
{SITE.companyUrl ? (
|
|
50
|
+
<a
|
|
51
|
+
href={SITE.companyUrl}
|
|
52
|
+
target="_blank"
|
|
53
|
+
rel="noopener noreferrer"
|
|
54
|
+
className="underline-offset-2 hover:text-foreground hover:underline"
|
|
55
|
+
>
|
|
56
|
+
{SITE.company}
|
|
57
|
+
</a>
|
|
58
|
+
) : (
|
|
59
|
+
SITE.company
|
|
60
|
+
)}{" "}
|
|
61
|
+
· {SITE.license}
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</footer>
|
|
65
|
+
</div>
|
|
66
|
+
</PageChromeProvider>
|
|
67
|
+
<MobileNav />
|
|
68
|
+
</div>
|
|
69
|
+
</OpenTabsProvider>
|
|
70
|
+
</GlobalSearchProvider>
|
|
71
|
+
</QuickActionsProvider>
|
|
72
|
+
</SidebarProvider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CubeIcon as Building,
|
|
5
|
+
GlobeIcon as Compass,
|
|
6
|
+
RulerHorizontalIcon as Ruler,
|
|
7
|
+
SewingPinFilledIcon as MapPin,
|
|
8
|
+
} from "@radix-ui/react-icons";
|
|
9
|
+
|
|
10
|
+
import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
|
|
11
|
+
import { markets, type Market } from "@/lib/mock-data";
|
|
12
|
+
|
|
13
|
+
function formatCenter(market: Market): string {
|
|
14
|
+
if (market.centerLatitude === null || market.centerLongitude === null) {
|
|
15
|
+
return "—";
|
|
16
|
+
}
|
|
17
|
+
return `${market.centerLatitude.toFixed(4)}, ${market.centerLongitude.toFixed(4)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fields: RecordField<Market>[] = [
|
|
21
|
+
{ key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true },
|
|
22
|
+
{ key: "organization", label: "Organization", icon: Building, editable: true, width: 200, group: "General" },
|
|
23
|
+
{
|
|
24
|
+
key: "centerLatitude",
|
|
25
|
+
label: "Center (lat, lng)",
|
|
26
|
+
icon: Compass,
|
|
27
|
+
width: 180,
|
|
28
|
+
group: "System",
|
|
29
|
+
render: (row) => (
|
|
30
|
+
<span className="whitespace-nowrap font-mono tabular-nums text-muted-foreground">
|
|
31
|
+
{formatCenter(row)}
|
|
32
|
+
</span>
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: "radiusMiles",
|
|
37
|
+
label: "Radius",
|
|
38
|
+
icon: Ruler,
|
|
39
|
+
// No explicit align — auto-aligns center (numeric).
|
|
40
|
+
group: "System",
|
|
41
|
+
render: (row) => (
|
|
42
|
+
<span className="tabular-nums">
|
|
43
|
+
{row.radiusMiles === null ? (
|
|
44
|
+
<span className="text-muted-foreground">—</span>
|
|
45
|
+
) : (
|
|
46
|
+
`${row.radiusMiles} mi`
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export function MarketsTable() {
|
|
54
|
+
return (
|
|
55
|
+
<RecordView
|
|
56
|
+
title="Markets"
|
|
57
|
+
singular="Market"
|
|
58
|
+
icon={MapPin}
|
|
59
|
+
fields={fields}
|
|
60
|
+
initialData={markets}
|
|
61
|
+
getPrimary={(row) => ({
|
|
62
|
+
title: row.name,
|
|
63
|
+
subtitle: row.organization,
|
|
64
|
+
initials: row.name.slice(0, 2).toUpperCase(),
|
|
65
|
+
})}
|
|
66
|
+
makeEmptyRow={() => ({
|
|
67
|
+
id: Date.now(),
|
|
68
|
+
organization: "",
|
|
69
|
+
name: "",
|
|
70
|
+
centerLatitude: null,
|
|
71
|
+
centerLongitude: null,
|
|
72
|
+
radiusMiles: null,
|
|
73
|
+
})}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { pageMeta } from "@/lib/seo";
|
|
2
|
+
|
|
3
|
+
import { MarketsTable } from "./markets-table";
|
|
4
|
+
|
|
5
|
+
export const metadata = pageMeta("/markets");
|
|
6
|
+
|
|
7
|
+
export default function MarketsPage() {
|
|
8
|
+
return (
|
|
9
|
+
<main className="h-full">
|
|
10
|
+
<MarketsTable />
|
|
11
|
+
</main>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Suspense } from "react";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
import { RecordForm } from "@viliha/vui-ui/record-view";
|
|
7
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
8
|
+
import { orgStore } from "@/lib/org-store";
|
|
9
|
+
import {
|
|
10
|
+
fields,
|
|
11
|
+
getPrimary,
|
|
12
|
+
ORG_FORM_DESCRIPTION,
|
|
13
|
+
ORG_ICON,
|
|
14
|
+
ORG_SINGULAR,
|
|
15
|
+
ORG_TITLE,
|
|
16
|
+
} from "../organizations-config";
|
|
17
|
+
|
|
18
|
+
function EditOrganizationForm() {
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const searchParams = useSearchParams();
|
|
21
|
+
const row = orgStore.get(Number(searchParams.get("id")));
|
|
22
|
+
|
|
23
|
+
if (!row) {
|
|
24
|
+
// Unknown id — e.g. a refresh cleared an in-session record.
|
|
25
|
+
return (
|
|
26
|
+
<main className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
|
27
|
+
<p>
|
|
28
|
+
Organization not found.{" "}
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={() => router.push("/organizations")}
|
|
32
|
+
className="text-[var(--button-primary)] hover:underline"
|
|
33
|
+
>
|
|
34
|
+
Back to organizations
|
|
35
|
+
</button>
|
|
36
|
+
</p>
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<main className="h-full">
|
|
43
|
+
<SetPageTitle title={ORG_TITLE} icon={ORG_ICON} />
|
|
44
|
+
<RecordForm
|
|
45
|
+
persistKey={`/organizations/edit?id=${row.id}`}
|
|
46
|
+
columns={1}
|
|
47
|
+
fields={fields}
|
|
48
|
+
row={row}
|
|
49
|
+
title={ORG_TITLE}
|
|
50
|
+
singular={ORG_SINGULAR}
|
|
51
|
+
icon={ORG_ICON}
|
|
52
|
+
getPrimary={getPrimary}
|
|
53
|
+
formDescription={ORG_FORM_DESCRIPTION}
|
|
54
|
+
onHome={() => router.push("/dashboard")}
|
|
55
|
+
onSave={(saved) => {
|
|
56
|
+
orgStore.update(saved);
|
|
57
|
+
router.push("/organizations");
|
|
58
|
+
}}
|
|
59
|
+
onCancel={() => router.push("/organizations")}
|
|
60
|
+
/>
|
|
61
|
+
</main>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function EditOrganizationPage() {
|
|
66
|
+
// useSearchParams needs a Suspense boundary under static export.
|
|
67
|
+
return (
|
|
68
|
+
<Suspense>
|
|
69
|
+
<EditOrganizationForm />
|
|
70
|
+
</Suspense>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
import { RecordForm } from "@viliha/vui-ui/record-view";
|
|
7
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
8
|
+
import { orgStore } from "@/lib/org-store";
|
|
9
|
+
import {
|
|
10
|
+
fields,
|
|
11
|
+
getPrimary,
|
|
12
|
+
makeEmptyRow,
|
|
13
|
+
ORG_FORM_DESCRIPTION,
|
|
14
|
+
ORG_ICON,
|
|
15
|
+
ORG_SINGULAR,
|
|
16
|
+
ORG_TITLE,
|
|
17
|
+
} from "../organizations-config";
|
|
18
|
+
|
|
19
|
+
export default function NewOrganizationPage() {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
// Stable draft row (a fresh id per mount) so the form doesn't reset on render.
|
|
22
|
+
const [row] = useState(makeEmptyRow);
|
|
23
|
+
return (
|
|
24
|
+
<main className="h-full">
|
|
25
|
+
<SetPageTitle title={ORG_TITLE} icon={ORG_ICON} />
|
|
26
|
+
<RecordForm
|
|
27
|
+
isNew
|
|
28
|
+
persistKey="/organizations/new"
|
|
29
|
+
columns={1}
|
|
30
|
+
fields={fields}
|
|
31
|
+
row={row}
|
|
32
|
+
title={ORG_TITLE}
|
|
33
|
+
singular={ORG_SINGULAR}
|
|
34
|
+
icon={ORG_ICON}
|
|
35
|
+
getPrimary={getPrimary}
|
|
36
|
+
formDescription={ORG_FORM_DESCRIPTION}
|
|
37
|
+
onHome={() => router.push("/dashboard")}
|
|
38
|
+
onSave={(saved) => {
|
|
39
|
+
orgStore.add(saved);
|
|
40
|
+
router.push("/organizations");
|
|
41
|
+
}}
|
|
42
|
+
onCancel={() => router.push("/organizations")}
|
|
43
|
+
/>
|
|
44
|
+
</main>
|
|
45
|
+
);
|
|
46
|
+
}
|