@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.
Files changed (137) hide show
  1. package/AGENT.md +79 -9
  2. package/README.md +60 -0
  3. package/bin/vui.mjs +211 -0
  4. package/package.json +10 -2
  5. package/template/.env.example +20 -0
  6. package/template/app/(app)/branches/branches-table.tsx +69 -0
  7. package/template/app/(app)/branches/page.tsx +13 -0
  8. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  9. package/template/app/(app)/businesses/page.tsx +13 -0
  10. package/template/app/(app)/calendar/page.tsx +725 -0
  11. package/template/app/(app)/charts/charts-content.tsx +190 -0
  12. package/template/app/(app)/charts/layout.tsx +8 -0
  13. package/template/app/(app)/charts/page.tsx +44 -0
  14. package/template/app/(app)/chat/layout.tsx +14 -0
  15. package/template/app/(app)/chat/page.tsx +311 -0
  16. package/template/app/(app)/components/layout.tsx +8 -0
  17. package/template/app/(app)/components/page.tsx +464 -0
  18. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  19. package/template/app/(app)/crm/companies/page.tsx +13 -0
  20. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  21. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  22. package/template/app/(app)/crm/people/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  24. package/template/app/(app)/dashboard/layout.tsx +8 -0
  25. package/template/app/(app)/dashboard/page.tsx +239 -0
  26. package/template/app/(app)/departments/departments-table.tsx +55 -0
  27. package/template/app/(app)/departments/page.tsx +13 -0
  28. package/template/app/(app)/employees/employees-table.tsx +64 -0
  29. package/template/app/(app)/employees/page.tsx +13 -0
  30. package/template/app/(app)/forms/layout.tsx +8 -0
  31. package/template/app/(app)/forms/page.tsx +388 -0
  32. package/template/app/(app)/layout.tsx +74 -0
  33. package/template/app/(app)/markets/markets-table.tsx +76 -0
  34. package/template/app/(app)/markets/page.tsx +13 -0
  35. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  36. package/template/app/(app)/organizations/new/page.tsx +46 -0
  37. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  38. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  39. package/template/app/(app)/organizations/page.tsx +13 -0
  40. package/template/app/(app)/settings/layout.tsx +8 -0
  41. package/template/app/(app)/settings/page.tsx +255 -0
  42. package/template/app/(app)/steps/page.tsx +263 -0
  43. package/template/app/(app)/support/layout.tsx +14 -0
  44. package/template/app/(app)/support/page.tsx +345 -0
  45. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  46. package/template/app/(app)/system/cities/page.tsx +13 -0
  47. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  48. package/template/app/(app)/system/countries/page.tsx +13 -0
  49. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  50. package/template/app/(app)/system/currencies/page.tsx +13 -0
  51. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  52. package/template/app/(app)/system/languages/page.tsx +13 -0
  53. package/template/app/(app)/system/regions/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  55. package/template/app/(app)/users/page.tsx +13 -0
  56. package/template/app/(app)/users/users-table.tsx +97 -0
  57. package/template/app/_components/app-sidebar.tsx +565 -0
  58. package/template/app/_components/auth.tsx +184 -0
  59. package/template/app/_components/breadcrumbs.tsx +26 -0
  60. package/template/app/_components/global-search.tsx +209 -0
  61. package/template/app/_components/logo.tsx +49 -0
  62. package/template/app/_components/nav-config.ts +138 -0
  63. package/template/app/_components/open-tabs.tsx +530 -0
  64. package/template/app/_components/quick-actions.tsx +140 -0
  65. package/template/app/_components/route-meta.ts +137 -0
  66. package/template/app/_components/set-page-title.tsx +15 -0
  67. package/template/app/_components/showcase.tsx +29 -0
  68. package/template/app/_components/stat-card.tsx +56 -0
  69. package/template/app/_components/theme-toggle.tsx +46 -0
  70. package/template/app/_components/top-bar.tsx +109 -0
  71. package/template/app/_components/user-menu.tsx +109 -0
  72. package/template/app/_components/wordmark.tsx +45 -0
  73. package/template/app/apple-icon.png +0 -0
  74. package/template/app/auth/forgot-password/page.tsx +102 -0
  75. package/template/app/auth/layout.tsx +43 -0
  76. package/template/app/auth/page.tsx +13 -0
  77. package/template/app/auth/reset-password/page.tsx +108 -0
  78. package/template/app/auth/signin/page.tsx +238 -0
  79. package/template/app/auth/signup/page.tsx +159 -0
  80. package/template/app/auth/verify/page.tsx +86 -0
  81. package/template/app/error.tsx +56 -0
  82. package/template/app/globals.css +6 -0
  83. package/template/app/icon.png +0 -0
  84. package/template/app/icon.svg +4 -0
  85. package/template/app/layout.tsx +103 -0
  86. package/template/app/not-found.tsx +29 -0
  87. package/template/app/onboarding/layout.tsx +31 -0
  88. package/template/app/onboarding/page.tsx +399 -0
  89. package/template/app/page.tsx +13 -0
  90. package/template/app/register-business/layout.tsx +30 -0
  91. package/template/app/register-business/page.tsx +234 -0
  92. package/template/components/ui/accordion.tsx +66 -0
  93. package/template/components/ui/alert-dialog.tsx +196 -0
  94. package/template/components/ui/alert.tsx +66 -0
  95. package/template/components/ui/aspect-ratio.tsx +11 -0
  96. package/template/components/ui/avatar.tsx +109 -0
  97. package/template/components/ui/badge.tsx +48 -0
  98. package/template/components/ui/breadcrumb.tsx +109 -0
  99. package/template/components/ui/button.tsx +64 -0
  100. package/template/components/ui/calendar.tsx +220 -0
  101. package/template/components/ui/card.tsx +92 -0
  102. package/template/components/ui/checkbox.tsx +32 -0
  103. package/template/components/ui/collapsible.tsx +33 -0
  104. package/template/components/ui/command.tsx +184 -0
  105. package/template/components/ui/dialog.tsx +158 -0
  106. package/template/components/ui/dropdown-menu.tsx +257 -0
  107. package/template/components/ui/form.tsx +167 -0
  108. package/template/components/ui/hover-card.tsx +44 -0
  109. package/template/components/ui/input-otp.tsx +77 -0
  110. package/template/components/ui/input.tsx +21 -0
  111. package/template/components/ui/label.tsx +24 -0
  112. package/template/components/ui/popover.tsx +89 -0
  113. package/template/components/ui/progress.tsx +31 -0
  114. package/template/components/ui/radio-group.tsx +45 -0
  115. package/template/components/ui/scroll-area.tsx +58 -0
  116. package/template/components/ui/select.tsx +190 -0
  117. package/template/components/ui/separator.tsx +28 -0
  118. package/template/components/ui/sheet.tsx +143 -0
  119. package/template/components/ui/skeleton.tsx +13 -0
  120. package/template/components/ui/slider.tsx +63 -0
  121. package/template/components/ui/sonner.tsx +40 -0
  122. package/template/components/ui/switch.tsx +35 -0
  123. package/template/components/ui/table.tsx +116 -0
  124. package/template/components/ui/tabs.tsx +91 -0
  125. package/template/components/ui/textarea.tsx +18 -0
  126. package/template/components/ui/toggle-group.tsx +83 -0
  127. package/template/components/ui/toggle.tsx +47 -0
  128. package/template/components/ui/tooltip.tsx +57 -0
  129. package/template/lib/auth-demo.ts +94 -0
  130. package/template/lib/crm-data.ts +80 -0
  131. package/template/lib/demo-data.ts +267 -0
  132. package/template/lib/mock-data.ts +169 -0
  133. package/template/lib/org-store.ts +50 -0
  134. package/template/lib/seo.ts +188 -0
  135. package/template/lib/utils.ts +3 -0
  136. package/template/next.config.mjs +13 -0
  137. 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
+ }