create-freakpunch 0.1.0 → 0.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 (79) hide show
  1. package/README.md +24 -23
  2. package/bin/create-freakpunch.mjs +6 -6
  3. package/files/README.md +19 -15
  4. package/files/app.config.ts +50 -58
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -23
  8. package/template/BACKEND.md +108 -86
  9. package/template/CLAUDE.md +15 -10
  10. package/template/design/COMPONENTS.md +1 -1
  11. package/template/design/DESIGN.md +3 -3
  12. package/template/design/PATTERNS.md +5 -5
  13. package/template/index.html +1 -1
  14. package/template/src/app.config.ts +50 -61
  15. package/template/src/app.roles.ts +24 -24
  16. package/template/src/core/api/demo.ts +8 -0
  17. package/template/src/core/audit/audit.service.ts +3 -34
  18. package/template/src/core/auth/auth.service.ts +25 -14
  19. package/template/src/core/auth/local-credentials.ts +46 -0
  20. package/template/src/core/auth/permissions.ts +1 -1
  21. package/template/src/core/company/company.service.ts +14 -12
  22. package/template/src/core/company/use-app-name.ts +7 -0
  23. package/template/src/core/components/page.tsx +4 -3
  24. package/template/src/core/config/runtime.ts +16 -14
  25. package/template/src/core/config/types.ts +3 -2
  26. package/template/src/core/layout/app-sidebar.tsx +19 -2
  27. package/template/src/core/notifications/notifications.service.ts +10 -115
  28. package/template/src/core/notifications/types.ts +5 -0
  29. package/template/src/core/setup/setup-gate.tsx +20 -0
  30. package/template/src/core/setup/setup.service.ts +101 -0
  31. package/template/src/core/users/users.service.ts +2 -2
  32. package/template/src/locales/en/auth.ts +2 -1
  33. package/template/src/locales/en/common.ts +1 -0
  34. package/template/src/locales/en/company.ts +3 -1
  35. package/template/src/locales/en/dashboard.ts +5 -0
  36. package/template/src/locales/en/setup.ts +45 -0
  37. package/template/src/locales/index.ts +4 -4
  38. package/template/src/locales/tr/auth.ts +2 -1
  39. package/template/src/locales/tr/common.ts +1 -0
  40. package/template/src/locales/tr/company.ts +3 -1
  41. package/template/src/locales/tr/dashboard.ts +5 -0
  42. package/template/src/locales/tr/setup.ts +49 -0
  43. package/template/src/modules/locales.ts +0 -4
  44. package/template/src/modules/registry.ts +17 -19
  45. package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
  46. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  47. package/template/src/pages/login-page.tsx +26 -14
  48. package/template/src/pages/not-found-page.tsx +1 -2
  49. package/template/src/pages/settings/account/account.service.ts +22 -41
  50. package/template/src/pages/settings/account/notifications-page.tsx +3 -3
  51. package/template/src/pages/settings/company/general-page.tsx +6 -4
  52. package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
  53. package/template/src/pages/settings/company/modules-page.tsx +10 -2
  54. package/template/src/pages/settings/company/roles-page.tsx +2 -1
  55. package/template/src/pages/setup-page.tsx +387 -0
  56. package/template/src/router.tsx +9 -4
  57. package/template/tools/new-module.mjs +90 -90
  58. package/template/tools/templates/module/index.ts.tpl +44 -37
  59. package/template/tools/templates/module/locales/en.ts.tpl +51 -45
  60. package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
  61. package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
  62. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
  63. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
  64. package/template/vite.config.ts +6 -3
  65. package/template/src/core/users/users.mock.ts +0 -24
  66. package/template/src/locales/en/landing.ts +0 -15
  67. package/template/src/locales/tr/landing.ts +0 -19
  68. package/template/src/modules/products/api/products.mock.ts +0 -46
  69. package/template/src/modules/products/api/products.service.ts +0 -84
  70. package/template/src/modules/products/components/product-status.tsx +0 -21
  71. package/template/src/modules/products/hooks.ts +0 -73
  72. package/template/src/modules/products/index.ts +0 -50
  73. package/template/src/modules/products/locales/en.ts +0 -78
  74. package/template/src/modules/products/locales/tr.ts +0 -82
  75. package/template/src/modules/products/pages/product-form-page.tsx +0 -211
  76. package/template/src/modules/products/pages/products-list-page.tsx +0 -286
  77. package/template/src/modules/products/types.ts +0 -61
  78. package/template/src/pages/dashboard/dashboard.service.ts +0 -92
  79. package/template/src/pages/landing-page.tsx +0 -79
@@ -5,8 +5,10 @@ import { Switch } from '@/components/ui/switch'
5
5
  import { appConfig } from '@/app.config'
6
6
  import { moduleActions } from '@/core/auth/permissions'
7
7
  import { Page } from '@/core/components/page'
8
+ import { EmptyState } from '@/core/components/states'
8
9
  import { StatusBadge } from '@/core/components/status-badge'
9
10
  import { useAnyT } from '@/core/i18n/keys'
11
+ import type { AppModule } from '@/core/modules/types'
10
12
  import { moduleRegistry, type ModuleId } from '@/modules/registry'
11
13
 
12
14
  const moduleIds = Object.keys(moduleRegistry) as ModuleId[]
@@ -24,9 +26,15 @@ export default function ModulesPage() {
24
26
  <Trans t={t} i18nKey="modules.note" components={{ code: <code className="tabular-nums text-[11px] text-foreground" /> }} />
25
27
  </span>
26
28
  </p>
27
- <ul className="divide-y rounded-xl border bg-card shadow-(--shadow-card)">
29
+ {moduleIds.length === 0 && (
30
+ <div className="rounded-xl border bg-card shadow-(--shadow-card)">
31
+ <EmptyState icon={<Blocks />} title={t('modules.emptyTitle')} description={t('modules.emptyDescription')} />
32
+ </div>
33
+ )}
34
+ <ul className="divide-y rounded-xl border bg-card shadow-(--shadow-card) empty:hidden">
28
35
  {moduleIds.map((id) => {
29
- const module = moduleRegistry[id]
36
+ // Typed explicitly: with no modules registered yet the registry's type is empty.
37
+ const module: AppModule = moduleRegistry[id]
30
38
  const nav = module.nav[0]
31
39
  const Icon = nav?.icon ?? Blocks
32
40
  const name = nav ? anyT(nav.label) : id
@@ -6,6 +6,7 @@ import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components
6
6
  import { TableHeaderCell } from '@/core/components/data-table/table-header-cell'
7
7
  import { hasPermission, matrixActions, permissionMatrix, roles, type Role } from '@/core/auth/permissions'
8
8
  import { Page, Panel } from '@/core/components/page'
9
+ import type { AppModule } from '@/core/modules/types'
9
10
  import { Segmented } from '@/core/components/segmented'
10
11
  import { Skeleton } from '@/components/ui/skeleton'
11
12
  import { useAnyT } from '@/core/i18n/keys'
@@ -27,7 +28,7 @@ export default function RolesPage() {
27
28
 
28
29
  const rowLabel = (row: (typeof permissionMatrix)[number]) =>
29
30
  row.module
30
- ? anyT(moduleRegistry[row.module].nav[0]?.label ?? 'common:nav.dashboard')
31
+ ? anyT((moduleRegistry[row.module] as AppModule).nav[0]?.label ?? 'common:nav.dashboard')
31
32
  : t(`roles.resources.${row.id as 'dashboard'}`)
32
33
 
33
34
  return (
@@ -0,0 +1,387 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Controller, useForm, useWatch } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Check, ImageOff } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { z } from 'zod'
8
+
9
+ import { Button } from '@/components/ui/button'
10
+ import { Card, CardContent } from '@/components/ui/card'
11
+ import { Field, FieldLabel } from '@/components/ui/field'
12
+ import { Input } from '@/components/ui/input'
13
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
14
+ import { Spinner } from '@/components/ui/spinner'
15
+ import { appConfig } from '@/app.config'
16
+ import { ApiError } from '@/core/api/http'
17
+ import { checkPassword, defaultPasswordPolicy } from '@/core/company/password-policy'
18
+ import { currencies, type Currency } from '@/core/company/types'
19
+ import { FormFieldError } from '@/core/components/form-error'
20
+ import { ImageUpload } from '@/core/components/image-upload'
21
+ import { Segmented } from '@/core/components/segmented'
22
+ import { authMode } from '@/core/config/runtime'
23
+ import { setLanguage, currentLanguage } from '@/core/i18n/i18n'
24
+ import { vmsg } from '@/core/i18n/keys'
25
+ import { LanguageSwitcher } from '@/core/i18n/language-switcher'
26
+ import { languageNames, languages, type Language } from '@/core/i18n/types'
27
+ import { timeZones } from '@/core/preferences/preferences-store'
28
+ import { setupService } from '@/core/setup/setup.service'
29
+ import { brandPresets, type BrandPresetId } from '@/core/theme/brand'
30
+ import { useBrand } from '@/core/theme/brand-context'
31
+ import { ThemeToggle } from '@/core/theme/theme-toggle'
32
+ import { cn } from '@/lib/utils'
33
+ import { PasswordStrength } from '@/pages/settings/account/components/password-strength'
34
+
35
+ const required = (max: number) =>
36
+ z
37
+ .string()
38
+ .trim()
39
+ .min(1, vmsg('validation.required'))
40
+ .max(max, vmsg('validation.maxChars', { max }))
41
+
42
+ const schema = z
43
+ .object({
44
+ companyName: required(80),
45
+ language: z.enum(languages),
46
+ timeZone: z.string(),
47
+ currency: z.enum(currencies),
48
+ firstName: required(60),
49
+ lastName: required(60),
50
+ email: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
51
+ password: z.string(),
52
+ confirmPassword: z.string(),
53
+ brand: z.string(),
54
+ logo: z.string().optional(),
55
+ })
56
+ .superRefine((v, ctx) => {
57
+ if (!checkPassword(defaultPasswordPolicy, v.password).valid) {
58
+ ctx.addIssue({ code: 'custom', path: ['password'], message: vmsg('setup:admin.passwordWeak') })
59
+ }
60
+ if (v.password !== v.confirmPassword) {
61
+ ctx.addIssue({ code: 'custom', path: ['confirmPassword'], message: vmsg('setup:admin.passwordMismatch') })
62
+ }
63
+ })
64
+ type Values = z.infer<typeof schema>
65
+
66
+ const steps = ['company', 'admin', 'branding', 'review'] as const
67
+ const stepFields: Record<(typeof steps)[number], (keyof Values)[]> = {
68
+ company: ['companyName', 'language', 'timeZone', 'currency'],
69
+ admin: ['firstName', 'lastName', 'email', 'password', 'confirmPassword'],
70
+ branding: ['brand', 'logo'],
71
+ review: [],
72
+ }
73
+ const presetIds = Object.keys(brandPresets) as BrandPresetId[]
74
+
75
+ function detectedTimeZone() {
76
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
77
+ return (timeZones as readonly string[]).includes(zone) ? zone : timeZones[0]!
78
+ }
79
+
80
+ /** First-run wizard: company, Owner account, look, review. See core/setup. */
81
+ export default function SetupPage() {
82
+ const { t, i18n } = useTranslation(['setup', 'common', 'company'])
83
+ const [step, setStep] = useState(0)
84
+ const [done, setDone] = useState(false)
85
+ const { setBrand, previewBrand, defaultBrand } = useBrand()
86
+
87
+ const form = useForm<Values>({
88
+ resolver: zodResolver(schema),
89
+ defaultValues: {
90
+ companyName: '',
91
+ language: currentLanguage(),
92
+ timeZone: detectedTimeZone(),
93
+ currency: currentLanguage() === 'tr' ? 'TRY' : (appConfig.currency as Currency),
94
+ firstName: '',
95
+ lastName: '',
96
+ email: '',
97
+ password: '',
98
+ confirmPassword: '',
99
+ brand: typeof defaultBrand === 'string' ? defaultBrand : 'indigo',
100
+ },
101
+ })
102
+ const { register, control, handleSubmit, trigger, getValues, setValue, formState } = form
103
+ const { errors, isSubmitting } = formState
104
+ const password = useWatch({ control, name: 'password' })
105
+ const brand = useWatch({ control, name: 'brand' }) as BrandPresetId
106
+ const companyName = useWatch({ control, name: 'companyName' })
107
+ const current = steps[step]!
108
+
109
+ useEffect(() => {
110
+ document.title = t('title')
111
+ }, [t])
112
+ useEffect(() => previewBrand(brand), [brand, previewBrand])
113
+ // The language switcher in the corner and the field on the first step are one setting.
114
+ useEffect(() => setValue('language', currentLanguage()), [i18n.language, setValue])
115
+
116
+ const submit = handleSubmit(async (v) => {
117
+ try {
118
+ await setupService.complete({
119
+ company: { name: v.companyName, language: v.language, timeZone: v.timeZone, currency: v.currency },
120
+ admin: { firstName: v.firstName, lastName: v.lastName, email: v.email, password: v.password },
121
+ branding: { brand: v.brand as BrandPresetId, logo: v.logo },
122
+ })
123
+ setBrand(v.brand as BrandPresetId)
124
+ setDone(true)
125
+ } catch (e) {
126
+ const insecure = e instanceof ApiError && e.message === 'insecure_context'
127
+ toast.error(t('failed'), { description: insecure ? t('insecure') : e instanceof Error ? e.message : undefined })
128
+ }
129
+ })
130
+
131
+ async function next() {
132
+ if (await trigger(stepFields[current], { shouldFocus: true })) setStep((s) => s + 1)
133
+ }
134
+
135
+ const zoneItems = timeZones.map((z) => ({ value: z, label: z }))
136
+ const currencyItems = currencies.map((c) => ({ value: c, label: c }))
137
+ const values = getValues()
138
+
139
+ return (
140
+ <div className="relative grid min-h-svh grid-rows-[1fr_auto_1fr] justify-items-center bg-canvas px-4 py-14">
141
+ <div className="absolute top-3 right-3 flex items-center gap-1.5 sm:top-4 sm:right-4">
142
+ <LanguageSwitcher />
143
+ <ThemeToggle />
144
+ </div>
145
+
146
+ <Card className="row-start-2 w-full max-w-lg gap-0 rounded-(--radius-panel) py-0 shadow-(--shadow-panel)">
147
+ <CardContent className="p-6 sm:p-8">
148
+ {done ? (
149
+ <div className="flex flex-col items-center gap-4 py-6 text-center">
150
+ <span className="flex size-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
151
+ <Check className="size-6" />
152
+ </span>
153
+ <div className="flex flex-col gap-1">
154
+ <h1 className="text-2xl font-semibold tracking-tight">{t('done.title')}</h1>
155
+ <p className="text-sm text-balance text-muted-foreground">{t('done.description', { name: values.companyName })}</p>
156
+ </div>
157
+ {/* A full load so every service starts from the data just saved. */}
158
+ <Button size="lg" onClick={() => window.location.assign(`${import.meta.env.BASE_URL}app`)}>
159
+ {t('done.open')}
160
+ </Button>
161
+ </div>
162
+ ) : (
163
+ <form
164
+ noValidate
165
+ onSubmit={(e) => {
166
+ e.preventDefault()
167
+ if (current === 'review') void submit(e)
168
+ else void next()
169
+ }}
170
+ className="flex flex-col gap-6"
171
+ >
172
+ <header className="flex flex-col gap-3">
173
+ <div className="flex gap-1" aria-hidden>
174
+ {steps.map((s, i) => (
175
+ <span key={s} className={cn('h-1 flex-1 rounded-full', i <= step ? 'bg-primary' : 'bg-fill-3')} />
176
+ ))}
177
+ </div>
178
+ <p className="label-caps">{t('progress', { step: step + 1, total: steps.length })}</p>
179
+ <div className="flex flex-col gap-1">
180
+ <h1 className="text-2xl font-semibold tracking-tight">{t(`${current}.title`)}</h1>
181
+ <p className="text-sm text-muted-foreground">{t(`${current}.description`)}</p>
182
+ </div>
183
+ </header>
184
+
185
+ {current === 'company' && (
186
+ <div className="flex flex-col gap-4">
187
+ <Field data-invalid={!!errors.companyName}>
188
+ <FieldLabel htmlFor="companyName">{t('company.name')}</FieldLabel>
189
+ <Input id="companyName" autoFocus autoComplete="organization" aria-invalid={!!errors.companyName} {...register('companyName')} />
190
+ <FormFieldError error={errors.companyName} />
191
+ </Field>
192
+ <Field>
193
+ <FieldLabel>{t('company.language')}</FieldLabel>
194
+ <Controller
195
+ control={control}
196
+ name="language"
197
+ render={({ field }) => (
198
+ <div className="w-fit">
199
+ <Segmented
200
+ label={t('company.language')}
201
+ value={field.value}
202
+ onChange={(lng: Language) => {
203
+ field.onChange(lng)
204
+ setLanguage(lng)
205
+ }}
206
+ options={languages.map((l) => ({ value: l, label: languageNames[l] }))}
207
+ />
208
+ </div>
209
+ )}
210
+ />
211
+ </Field>
212
+ <div className="grid gap-4 sm:grid-cols-2">
213
+ <Field>
214
+ <FieldLabel htmlFor="timeZone">{t('company.timeZone')}</FieldLabel>
215
+ <Controller
216
+ control={control}
217
+ name="timeZone"
218
+ render={({ field }) => (
219
+ <Select items={zoneItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
220
+ <SelectTrigger id="timeZone" className="w-full">
221
+ <SelectValue />
222
+ </SelectTrigger>
223
+ <SelectContent>
224
+ {zoneItems.map((z) => (
225
+ <SelectItem key={z.value} value={z.value}>
226
+ {z.label}
227
+ </SelectItem>
228
+ ))}
229
+ </SelectContent>
230
+ </Select>
231
+ )}
232
+ />
233
+ </Field>
234
+ <Field>
235
+ <FieldLabel htmlFor="currency">{t('company.currency')}</FieldLabel>
236
+ <Controller
237
+ control={control}
238
+ name="currency"
239
+ render={({ field }) => (
240
+ <Select items={currencyItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
241
+ <SelectTrigger id="currency" className="w-full">
242
+ <SelectValue />
243
+ </SelectTrigger>
244
+ <SelectContent>
245
+ {currencyItems.map((c) => (
246
+ <SelectItem key={c.value} value={c.value}>
247
+ {c.label}
248
+ </SelectItem>
249
+ ))}
250
+ </SelectContent>
251
+ </Select>
252
+ )}
253
+ />
254
+ </Field>
255
+ </div>
256
+ </div>
257
+ )}
258
+
259
+ {current === 'admin' && (
260
+ <div className="flex flex-col gap-4">
261
+ <div className="grid gap-4 sm:grid-cols-2">
262
+ <Field data-invalid={!!errors.firstName}>
263
+ <FieldLabel htmlFor="firstName">{t('admin.firstName')}</FieldLabel>
264
+ <Input id="firstName" autoFocus autoComplete="given-name" aria-invalid={!!errors.firstName} {...register('firstName')} />
265
+ <FormFieldError error={errors.firstName} />
266
+ </Field>
267
+ <Field data-invalid={!!errors.lastName}>
268
+ <FieldLabel htmlFor="lastName">{t('admin.lastName')}</FieldLabel>
269
+ <Input id="lastName" autoComplete="family-name" aria-invalid={!!errors.lastName} {...register('lastName')} />
270
+ <FormFieldError error={errors.lastName} />
271
+ </Field>
272
+ </div>
273
+ <Field data-invalid={!!errors.email}>
274
+ <FieldLabel htmlFor="email">{t('admin.email')}</FieldLabel>
275
+ <Input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} {...register('email')} />
276
+ <FormFieldError error={errors.email} />
277
+ </Field>
278
+ <Field data-invalid={!!errors.password}>
279
+ <FieldLabel htmlFor="password">{t('admin.password')}</FieldLabel>
280
+ <Input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password} {...register('password')} />
281
+ <FormFieldError error={errors.password} />
282
+ </Field>
283
+ <PasswordStrength password={password} policy={defaultPasswordPolicy} />
284
+ <Field data-invalid={!!errors.confirmPassword}>
285
+ <FieldLabel htmlFor="confirmPassword">{t('admin.confirmPassword')}</FieldLabel>
286
+ <Input id="confirmPassword" type="password" autoComplete="new-password" aria-invalid={!!errors.confirmPassword} {...register('confirmPassword')} />
287
+ <FormFieldError error={errors.confirmPassword} />
288
+ </Field>
289
+ </div>
290
+ )}
291
+
292
+ {current === 'branding' && (
293
+ <div className="flex flex-col gap-5">
294
+ <Field>
295
+ <FieldLabel>{t('branding.color')}</FieldLabel>
296
+ <Controller
297
+ control={control}
298
+ name="brand"
299
+ render={({ field }) => (
300
+ <div className="flex flex-wrap gap-2">
301
+ {presetIds.map((id) => (
302
+ <button
303
+ key={id}
304
+ type="button"
305
+ aria-pressed={field.value === id}
306
+ aria-label={t(`company:branding.presets.${id}`)}
307
+ title={t(`company:branding.presets.${id}`)}
308
+ onClick={() => field.onChange(id)}
309
+ style={{ background: brandPresets[id].color }}
310
+ className={cn(
311
+ 'flex size-9 items-center justify-center rounded-lg outline-none ring-offset-2 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring',
312
+ field.value === id && 'ring-2 ring-foreground',
313
+ )}
314
+ >
315
+ {field.value === id && <Check className="size-4" style={{ color: brandPresets[id].foreground }} />}
316
+ </button>
317
+ ))}
318
+ </div>
319
+ )}
320
+ />
321
+ </Field>
322
+ <Field>
323
+ <FieldLabel>{t('branding.logo')}</FieldLabel>
324
+ <Controller
325
+ control={control}
326
+ name="logo"
327
+ render={({ field }) => (
328
+ <ImageUpload
329
+ value={field.value}
330
+ onChange={field.onChange}
331
+ onError={(err) => toast.error(t(`company:branding.imageError.${err}`))}
332
+ label={t('branding.logo')}
333
+ preview={(src) => (
334
+ <div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-fill-1 text-muted-foreground">
335
+ {src ? <img src={src} alt="" className="max-h-full max-w-full object-contain" /> : <ImageOff className="size-4" />}
336
+ </div>
337
+ )}
338
+ />
339
+ )}
340
+ />
341
+ </Field>
342
+ </div>
343
+ )}
344
+
345
+ {current === 'review' && (
346
+ <dl className="divide-y rounded-xl border text-sm">
347
+ {[
348
+ [t('company.name'), companyName],
349
+ [t('company.language'), languageNames[values.language]],
350
+ [t('company.timeZone'), values.timeZone],
351
+ [t('company.currency'), values.currency],
352
+ [t('review.admin'), `${values.firstName} ${values.lastName}`],
353
+ [t('admin.email'), values.email],
354
+ [t('branding.color'), t(`company:branding.presets.${brand}`)],
355
+ ].map(([label, value]) => (
356
+ <div key={label} className="flex items-center justify-between gap-4 px-4 py-2.5">
357
+ <dt className="text-muted-foreground">{label}</dt>
358
+ <dd className="min-w-0 truncate text-right font-medium">{value}</dd>
359
+ </div>
360
+ ))}
361
+ </dl>
362
+ )}
363
+
364
+ <footer className="flex items-center justify-between gap-2">
365
+ {step > 0 ? (
366
+ <Button type="button" variant="ghost" disabled={isSubmitting} onClick={() => setStep((s) => s - 1)}>
367
+ {t('common:actions.back')}
368
+ </Button>
369
+ ) : (
370
+ <span />
371
+ )}
372
+ <Button type="submit" disabled={isSubmitting}>
373
+ {isSubmitting && <Spinner data-icon="inline-start" />}
374
+ {current === 'review' ? t('finish') : t('next')}
375
+ </Button>
376
+ </footer>
377
+ </form>
378
+ )}
379
+ </CardContent>
380
+ </Card>
381
+
382
+ {authMode === 'mock' && (
383
+ <p className="row-start-3 max-w-md self-end pt-4 text-center text-xs text-balance text-muted-foreground">{t('localNote')}</p>
384
+ )}
385
+ </div>
386
+ )
387
+ }
@@ -1,26 +1,31 @@
1
- import { createBrowserRouter, Navigate, Outlet } from 'react-router'
1
+ import { createBrowserRouter, Navigate } from 'react-router'
2
2
 
3
3
  import { showLanding } from '@/core/config/runtime'
4
4
  import { AppShell } from '@/core/layout/app-shell'
5
5
  import { lazyPage } from '@/core/modules/lazy-page'
6
6
  import type { RouteHandle } from '@/core/modules/types'
7
+ import { SetupGate } from '@/core/setup/setup-gate'
7
8
  import { enabledModules } from '@/modules/registry'
8
9
  import NotFoundPage, { RouteError } from '@/pages/not-found-page'
9
10
  import { settingsRoutes } from '@/pages/settings/settings-routes'
10
11
 
11
12
  const home: RouteHandle = { crumb: 'common:nav.dashboard' }
12
13
 
14
+ // A glob, not an import: the marketing page only exists in the public demo.
15
+ const landing = Object.values(import.meta.glob<{ default: React.ComponentType }>('/src/pages/landing-page.tsx'))[0]
16
+
13
17
  export const router = createBrowserRouter([
14
18
  {
15
19
  // Full-screen pages share one error screen.
16
- element: <Outlet />,
20
+ element: <SetupGate />,
17
21
  errorElement: <RouteError />,
18
22
  // Shown while the first lazy route loads.
19
23
  hydrateFallbackElement: <div className="min-h-svh bg-canvas" />,
20
24
  children: [
21
- showLanding
22
- ? { path: '/', lazy: lazyPage(() => import('@/pages/landing-page')) }
25
+ showLanding && landing
26
+ ? { path: '/', lazy: lazyPage(landing) }
23
27
  : { path: '/', element: <Navigate to="/app" replace /> },
28
+ { path: '/setup', lazy: lazyPage(() => import('@/pages/setup-page')) },
24
29
  { path: '/login', lazy: lazyPage(() => import('@/pages/login-page')) },
25
30
  {
26
31
  path: '/app',
@@ -1,90 +1,90 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Creates a module with the standard structure and registers it:
4
- *
5
- * npm run new-module -- assets --en "Assets" --tr "Zimmet" --group content
6
- *
7
- * The id is one lowercase word; it becomes the URL (/app/assets), the
8
- * translation namespace and the permission prefix (assets.view, …).
9
- */
10
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
- import path from 'node:path'
12
-
13
- const root = path.resolve(import.meta.dirname, '..')
14
- const templates = path.join(import.meta.dirname, 'templates/module')
15
-
16
- function fail(message) {
17
- console.error(`new-module: ${message}`)
18
- process.exit(1)
19
- }
20
-
21
- const args = process.argv.slice(2)
22
- const option = (name, fallback) => {
23
- const i = args.indexOf(`--${name}`)
24
- return i >= 0 ? args[i + 1] : fallback
25
- }
26
- const id = args[0]
27
- if (!id || id.startsWith('--')) fail('usage: npm run new-module -- <id> --en "Label" --tr "Etiket" [--group content]')
28
- if (!/^[a-z][a-z0-9]*$/.test(id)) fail(`"${id}" must be one lowercase word (letters and digits), e.g. assets`)
29
-
30
- const Name = id[0].toUpperCase() + id.slice(1)
31
- const values = { __id__: id, __Name__: Name, __labelEn__: option('en', Name), __labelTr__: option('tr', Name), __group__: option('group', 'content') }
32
- const fill = (text) => text.replace(/__(id|Name|labelEn|labelTr|group)__/g, (m) => values[m])
33
-
34
- const moduleDir = path.join(root, 'src/modules', id)
35
- if (existsSync(moduleDir)) fail(`src/modules/${id} already exists`)
36
-
37
- /** Inserts `line` above the `// marker` comment, keeping its indentation and the file's line endings. */
38
- function insert(file, entries) {
39
- const abs = path.join(root, file)
40
- let text = readFileSync(abs, 'utf8')
41
- const eol = text.includes('\r\n') ? '\r\n' : '\n'
42
- for (const [marker, line] of entries) {
43
- const match = new RegExp(`^([ \\t]*)// ${marker}\\s*$`, 'm').exec(text)
44
- if (!match) fail(`marker "// ${marker}" is missing from ${file}; add it where new entries belong`)
45
- text = text.slice(0, match.index) + match[1] + line + eol + text.slice(match.index)
46
- }
47
- return () => writeFileSync(abs, text)
48
- }
49
-
50
- const config = readFileSync(path.join(root, 'src/app.config.ts'), 'utf8')
51
- if (!config.includes(`id: '${values.__group__}'`)) fail(`no nav group "${values.__group__}" in src/app.config.ts → navGroups`)
52
-
53
- // Check all three files first so a missing marker leaves nothing half-done.
54
- const writes = [
55
- insert('src/modules/registry.ts', [
56
- ['fp:module-imports', `import { ${id}Module } from './${id}'`],
57
- ['fp:module-registry', `${id}: ${id}Module,`],
58
- ]),
59
- insert('src/modules/locales.ts', [
60
- ['fp:locale-imports', `import ${id}En from './${id}/locales/en'`],
61
- ['fp:locale-imports', `import ${id}Tr from './${id}/locales/tr'`],
62
- ['fp:locale-en', `${id}: ${id}En,`],
63
- ['fp:locale-tr', `${id}: ${id}Tr,`],
64
- ]),
65
- insert('src/app.config.ts', [['fp:modules', `${id}: true,`]]),
66
- ]
67
-
68
- function copy(dir) {
69
- for (const name of readdirSync(dir)) {
70
- const source = path.join(dir, name)
71
- if (statSync(source).isDirectory()) {
72
- copy(source)
73
- continue
74
- }
75
- const relative = fill(path.relative(templates, source)).replace(/\.tpl$/, '')
76
- const target = path.join(moduleDir, relative)
77
- mkdirSync(path.dirname(target), { recursive: true })
78
- writeFileSync(target, fill(readFileSync(source, 'utf8')))
79
- }
80
- }
81
-
82
- copy(templates)
83
- for (const write of writes) write()
84
-
85
- console.log(`Created src/modules/${id} and registered it (sidebar, routes, permissions, translations).
86
-
87
- Next:
88
- 1. Replace the sample fields in types.ts, the pages and both locale files.
89
- 2. Open /app/${id} in npm run dev.
90
- 3. Run npm run check.`)
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Creates a module with the standard structure and registers it:
4
+ *
5
+ * npm run new-module -- orders --en "Orders" --tr "Siparişler" --group content
6
+ *
7
+ * The id is one lowercase word; it becomes the URL (/app/orders), the
8
+ * translation namespace and the permission prefix (orders.view, …).
9
+ */
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ const root = path.resolve(import.meta.dirname, '..')
14
+ const templates = path.join(import.meta.dirname, 'templates/module')
15
+
16
+ function fail(message) {
17
+ console.error(`new-module: ${message}`)
18
+ process.exit(1)
19
+ }
20
+
21
+ const args = process.argv.slice(2)
22
+ const option = (name, fallback) => {
23
+ const i = args.indexOf(`--${name}`)
24
+ return i >= 0 ? args[i + 1] : fallback
25
+ }
26
+ const id = args[0]
27
+ if (!id || id.startsWith('--')) fail('usage: npm run new-module -- <id> --en "Label" --tr "Etiket" [--group content]')
28
+ if (!/^[a-z][a-z0-9]*$/.test(id)) fail(`"${id}" must be one lowercase word (letters and digits), e.g. orders`)
29
+
30
+ const Name = id[0].toUpperCase() + id.slice(1)
31
+ const values = { __id__: id, __Name__: Name, __labelEn__: option('en', Name), __labelTr__: option('tr', Name), __group__: option('group', 'content') }
32
+ const fill = (text) => text.replace(/__(id|Name|labelEn|labelTr|group)__/g, (m) => values[m])
33
+
34
+ const moduleDir = path.join(root, 'src/modules', id)
35
+ if (existsSync(moduleDir)) fail(`src/modules/${id} already exists`)
36
+
37
+ /** Inserts `line` above the `// marker` comment, keeping its indentation and the file's line endings. */
38
+ function insert(file, entries) {
39
+ const abs = path.join(root, file)
40
+ let text = readFileSync(abs, 'utf8')
41
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
42
+ for (const [marker, line] of entries) {
43
+ const match = new RegExp(`^([ \\t]*)// ${marker}\\s*$`, 'm').exec(text)
44
+ if (!match) fail(`marker "// ${marker}" is missing from ${file}; add it where new entries belong`)
45
+ text = text.slice(0, match.index) + match[1] + line + eol + text.slice(match.index)
46
+ }
47
+ return () => writeFileSync(abs, text)
48
+ }
49
+
50
+ const config = readFileSync(path.join(root, 'src/app.config.ts'), 'utf8')
51
+ if (!config.includes(`id: '${values.__group__}'`)) fail(`no nav group "${values.__group__}" in src/app.config.ts → navGroups`)
52
+
53
+ // Check all three files first so a missing marker leaves nothing half-done.
54
+ const writes = [
55
+ insert('src/modules/registry.ts', [
56
+ ['fp:module-imports', `import { ${id}Module } from './${id}'`],
57
+ ['fp:module-registry', `${id}: ${id}Module,`],
58
+ ]),
59
+ insert('src/modules/locales.ts', [
60
+ ['fp:locale-imports', `import ${id}En from './${id}/locales/en'`],
61
+ ['fp:locale-imports', `import ${id}Tr from './${id}/locales/tr'`],
62
+ ['fp:locale-en', `${id}: ${id}En,`],
63
+ ['fp:locale-tr', `${id}: ${id}Tr,`],
64
+ ]),
65
+ insert('src/app.config.ts', [['fp:modules', `${id}: true,`]]),
66
+ ]
67
+
68
+ function copy(dir) {
69
+ for (const name of readdirSync(dir)) {
70
+ const source = path.join(dir, name)
71
+ if (statSync(source).isDirectory()) {
72
+ copy(source)
73
+ continue
74
+ }
75
+ const relative = fill(path.relative(templates, source)).replace(/\.tpl$/, '')
76
+ const target = path.join(moduleDir, relative)
77
+ mkdirSync(path.dirname(target), { recursive: true })
78
+ writeFileSync(target, fill(readFileSync(source, 'utf8')))
79
+ }
80
+ }
81
+
82
+ copy(templates)
83
+ for (const write of writes) write()
84
+
85
+ console.log(`Created src/modules/${id} and registered it (sidebar, routes, permissions, translations).
86
+
87
+ Next:
88
+ 1. Replace the sample fields in types.ts, the pages and both locale files.
89
+ 2. Open /app/${id} in npm run dev.
90
+ 3. Run npm run check.`)