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.
- package/README.md +24 -23
- package/bin/create-freakpunch.mjs +6 -6
- package/files/README.md +19 -15
- package/files/app.config.ts +50 -58
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -23
- package/template/BACKEND.md +108 -86
- package/template/CLAUDE.md +15 -10
- package/template/design/COMPONENTS.md +1 -1
- package/template/design/DESIGN.md +3 -3
- package/template/design/PATTERNS.md +5 -5
- package/template/index.html +1 -1
- package/template/src/app.config.ts +50 -61
- package/template/src/app.roles.ts +24 -24
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/audit/audit.service.ts +3 -34
- package/template/src/core/auth/auth.service.ts +25 -14
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +1 -1
- package/template/src/core/company/company.service.ts +14 -12
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/page.tsx +4 -3
- package/template/src/core/config/runtime.ts +16 -14
- package/template/src/core/config/types.ts +3 -2
- package/template/src/core/layout/app-sidebar.tsx +19 -2
- package/template/src/core/notifications/notifications.service.ts +10 -115
- package/template/src/core/notifications/types.ts +5 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/users/users.service.ts +2 -2
- package/template/src/locales/en/auth.ts +2 -1
- package/template/src/locales/en/common.ts +1 -0
- package/template/src/locales/en/company.ts +3 -1
- package/template/src/locales/en/dashboard.ts +5 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +4 -4
- package/template/src/locales/tr/auth.ts +2 -1
- package/template/src/locales/tr/common.ts +1 -0
- package/template/src/locales/tr/company.ts +3 -1
- package/template/src/locales/tr/dashboard.ts +5 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/modules/locales.ts +0 -4
- package/template/src/modules/registry.ts +17 -19
- package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +26 -14
- package/template/src/pages/not-found-page.tsx +1 -2
- package/template/src/pages/settings/account/account.service.ts +22 -41
- package/template/src/pages/settings/account/notifications-page.tsx +3 -3
- package/template/src/pages/settings/company/general-page.tsx +6 -4
- package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
- package/template/src/pages/settings/company/modules-page.tsx +10 -2
- package/template/src/pages/settings/company/roles-page.tsx +2 -1
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/router.tsx +9 -4
- package/template/tools/new-module.mjs +90 -90
- package/template/tools/templates/module/index.ts.tpl +44 -37
- package/template/tools/templates/module/locales/en.ts.tpl +51 -45
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
- package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
- package/template/vite.config.ts +6 -3
- package/template/src/core/users/users.mock.ts +0 -24
- package/template/src/locales/en/landing.ts +0 -15
- package/template/src/locales/tr/landing.ts +0 -19
- package/template/src/modules/products/api/products.mock.ts +0 -46
- package/template/src/modules/products/api/products.service.ts +0 -84
- package/template/src/modules/products/components/product-status.tsx +0 -21
- package/template/src/modules/products/hooks.ts +0 -73
- package/template/src/modules/products/index.ts +0 -50
- package/template/src/modules/products/locales/en.ts +0 -78
- package/template/src/modules/products/locales/tr.ts +0 -82
- package/template/src/modules/products/pages/product-form-page.tsx +0 -211
- package/template/src/modules/products/pages/products-list-page.tsx +0 -286
- package/template/src/modules/products/types.ts +0 -61
- package/template/src/pages/dashboard/dashboard.service.ts +0 -92
- 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/template/src/router.tsx
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
|
-
import { createBrowserRouter, Navigate
|
|
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: <
|
|
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(
|
|
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 --
|
|
6
|
-
*
|
|
7
|
-
* The id is one lowercase word; it becomes the URL (/app/
|
|
8
|
-
* translation namespace and the permission prefix (
|
|
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.
|
|
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.`)
|