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
@@ -1,35 +1,22 @@
1
- import { useState } from 'react'
2
- import { Trans, useTranslation } from 'react-i18next'
3
- import { Link } from 'react-router'
4
- import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
5
- import { ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react'
1
+ import { lazy, Suspense } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Blocks } from 'lucide-react'
6
4
 
7
- import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
8
- import { Skeleton } from '@/components/ui/skeleton'
9
- import { usePermissions } from '@/core/auth/can'
10
5
  import { useAuth } from '@/core/auth/use-auth'
11
- import { isFeatureOn } from '@/core/config/runtime'
12
- import { Page, Panel } from '@/core/components/page'
13
- import { Segmented } from '@/core/components/segmented'
14
- import { ErrorState } from '@/core/components/states'
15
- import { StatusDot } from '@/core/components/status-badge'
16
- import { useAnyT } from '@/core/i18n/keys'
17
- import { useFormat } from '@/core/i18n/use-format'
18
- import type { Formatters } from '@/core/lib/format'
19
- import { quickActions } from '@/core/modules/navigation'
20
- import { cn } from '@/lib/utils'
6
+ import { isDemo } from '@/core/config/runtime'
7
+ import { Page } from '@/core/components/page'
8
+ import { EmptyState, LoadingState } from '@/core/components/states'
21
9
  import { enabledModules } from '@/modules/registry'
22
- import { rangeDays, useActivity, useKpis, useRevenue, type Activity, type Kpi, type Range } from './dashboard.service'
10
+ import { QuickActionsCard } from './quick-actions'
23
11
 
24
- const ranges = ['7d', '30d', '90d'] as const
25
- const moduleWidgets = enabledModules.flatMap((m) => m.dashboardWidgets ?? [])
26
- const demoCards = isFeatureOn('demoDashboard')
12
+ // A glob, not an import: the public demo's sample dashboard isn't part of installed copies.
13
+ const demoLoader = Object.values(
14
+ import.meta.glob<{ DemoDashboardPage: React.ComponentType }>('/src/demo/dashboard-page.tsx'),
15
+ )[0]
16
+ const DemoDashboardPage =
17
+ isDemo && demoLoader ? lazy(async () => ({ default: (await demoLoader()).DemoDashboardPage })) : null
27
18
 
28
- function formatKpi(kpi: Kpi, fmt: Formatters) {
29
- if (kpi.format === 'currency') return fmt.currencyWhole(kpi.value)
30
- if (kpi.format === 'percent') return fmt.percent(kpi.value)
31
- return fmt.number(kpi.value)
32
- }
19
+ const moduleWidgets = enabledModules.flatMap((m) => m.dashboardWidgets ?? [])
33
20
 
34
21
  function greetingKey() {
35
22
  const h = new Date().getHours()
@@ -37,234 +24,32 @@ function greetingKey() {
37
24
  }
38
25
 
39
26
  export default function DashboardPage() {
40
- const [range, setRange] = useState<Range>('30d')
41
27
  const { user } = useAuth()
42
28
  const { t } = useTranslation(['dashboard', 'common'])
43
29
 
30
+ if (DemoDashboardPage) {
31
+ return (
32
+ <Suspense fallback={<LoadingState />}>
33
+ <DemoDashboardPage />
34
+ </Suspense>
35
+ )
36
+ }
37
+
44
38
  return (
45
39
  <Page
46
40
  title={t(greetingKey(), { name: user?.firstName || t('greeting.fallbackName') })}
47
41
  documentTitle={t('common:nav.dashboard')}
48
- description={t('description')}
49
- actions={
50
- demoCards && (
51
- <Segmented
52
- label={t('range.label')}
53
- value={range}
54
- onChange={setRange}
55
- options={ranges.map((r) => ({ value: r, label: t(`range.${r}`) }))}
56
- />
57
- )
58
- }
42
+ description={t('readyDescription')}
59
43
  >
60
- {demoCards && <KpiGrid range={range} />}
61
44
  {moduleWidgets.map((Widget, i) => (
62
45
  <Widget key={i} />
63
46
  ))}
64
- {demoCards ? (
65
- <>
66
- <div className="grid gap-4 xl:grid-cols-3">
67
- <RevenueCard range={range} className="xl:col-span-2" />
68
- <QuickActionsCard />
69
- </div>
70
- <ActivityCard />
71
- </>
72
- ) : (
73
- <QuickActionsCard />
74
- )}
75
- </Page>
76
- )
77
- }
78
-
79
- function KpiGrid({ range }: { range: Range }) {
80
- const { data, isPending, error, refetch } = useKpis(range)
81
- if (error) return <ErrorState error={error} onRetry={() => refetch()} />
82
-
83
- return (
84
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
85
- {isPending
86
- ? Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-[7.5rem] rounded-xl" />)
87
- : data.map((kpi) => <KpiCard key={kpi.id} kpi={kpi} />)}
88
- </div>
89
- )
90
- }
91
-
92
- function KpiCard({ kpi }: { kpi: Kpi }) {
93
- const { t } = useTranslation(['dashboard', 'common'])
94
- const fmt = useFormat()
95
- const up = kpi.change >= 0
96
- const spark = kpi.spark.map((v, i) => ({ i, v }))
97
- return (
98
- <div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-(--shadow-card)">
99
- <div className="flex items-center justify-between">
100
- <span className="label-caps">{t(`kpis.${kpi.id}`)}</span>
101
- <span
102
- className={cn(
103
- 'inline-flex items-center gap-0.5 text-[11px] tabular-nums',
104
- up ? 'text-success' : 'text-danger',
105
- )}
106
- >
107
- {up ? <ArrowUpRight className="size-3" /> : <ArrowDownRight className="size-3" />}
108
- {fmt.delta(kpi.change)}
109
- </span>
110
- </div>
111
- <div className="flex items-end justify-between gap-3">
112
- <span className="text-2xl font-semibold tracking-tight tabular-nums">{formatKpi(kpi, fmt)}</span>
113
- <div className="h-8 w-20" aria-hidden>
114
- <ChartContainer config={{ v: { color: 'var(--muted-foreground)' } }} className="aspect-auto h-8 w-20">
115
- <LineChart data={spark}>
116
- <Line dataKey="v" type="monotone" stroke="var(--color-v)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
117
- </LineChart>
118
- </ChartContainer>
47
+ {enabledModules.length === 0 && (
48
+ <div className="rounded-xl border bg-card shadow-(--shadow-card)">
49
+ <EmptyState icon={<Blocks />} title={t('noModules.title')} description={t('noModules.description')} />
119
50
  </div>
120
- </div>
121
- </div>
122
- )
123
- }
124
-
125
- function RevenueCard({ range, className }: { range: Range; className?: string }) {
126
- const { data, isPending, error, refetch } = useRevenue(range)
127
- const { t } = useTranslation(['dashboard', 'common'])
128
- const fmt = useFormat()
129
- const total = data?.reduce((s, p) => s + p.revenue, 0) ?? 0
130
- const chartConfig = { revenue: { label: t('kpis.revenue'), color: 'var(--chart-1)' } } satisfies ChartConfig
131
-
132
- return (
133
- <Panel label={t('panels.revenue')} className={className} bodyClassName="p-4">
134
- {error ? (
135
- <ErrorState error={error} onRetry={() => refetch()} />
136
- ) : (
137
- <>
138
- <div className="mb-4 flex items-baseline gap-2">
139
- {isPending ? (
140
- <Skeleton className="h-8 w-32" />
141
- ) : (
142
- <span className="text-2xl font-semibold tracking-tight tabular-nums">{fmt.currency(total)}</span>
143
- )}
144
- <span className="text-xs text-muted-foreground">{t('range.last', { count: rangeDays[range] })}</span>
145
- </div>
146
- {isPending ? (
147
- <Skeleton className="h-64 w-full rounded-lg" />
148
- ) : (
149
- <ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
150
- <AreaChart data={data} margin={{ left: 0, right: 4, top: 4 }}>
151
- <defs>
152
- <linearGradient id="fill-revenue" x1="0" y1="0" x2="0" y2="1">
153
- <stop offset="0%" stopColor="var(--color-revenue)" stopOpacity={0.22} />
154
- <stop offset="100%" stopColor="var(--color-revenue)" stopOpacity={0} />
155
- </linearGradient>
156
- </defs>
157
- <CartesianGrid vertical={false} strokeDasharray="3 3" />
158
- <XAxis
159
- dataKey="date"
160
- tickLine={false}
161
- axisLine={false}
162
- tickMargin={8}
163
- minTickGap={28}
164
- tickFormatter={(d: string) => fmt.dayMonth(d)}
165
- />
166
- <YAxis tickLine={false} axisLine={false} width={52} tickFormatter={(v: number) => fmt.compactCurrency(v)} />
167
- <ChartTooltip
168
- cursor={{ stroke: 'var(--border)' }}
169
- content={
170
- <ChartTooltipContent
171
- labelFormatter={(d) => fmt.weekdayDayMonth(String(d))}
172
- formatter={(v) => (
173
- <span className="font-medium tabular-nums">{fmt.currency(Number(v))}</span>
174
- )}
175
- />
176
- }
177
- />
178
- <Area
179
- dataKey="revenue"
180
- type="monotone"
181
- stroke="var(--color-revenue)"
182
- strokeWidth={2}
183
- fill="url(#fill-revenue)"
184
- />
185
- </AreaChart>
186
- </ChartContainer>
187
- )}
188
- </>
189
- )}
190
- </Panel>
191
- )
192
- }
193
-
194
- function QuickActionsCard() {
195
- const t = useAnyT()
196
- const { can } = usePermissions()
197
- return (
198
- <Panel label={t('dashboard:panels.quickActions')} bodyClassName="p-2">
199
- <ul className="flex flex-col gap-0.5">
200
- {quickActions
201
- .filter((a) => !a.permission || can(a.permission))
202
- .map(({ path, label, description, icon: Icon }) => (
203
- <li key={path}>
204
- <Link
205
- to={path}
206
- className="group flex items-center gap-3 rounded-lg p-2 outline-none hover:bg-fill-1 focus-visible:ring-2 focus-visible:ring-ring/40"
207
- >
208
- <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-raised text-muted-foreground shadow-(--shadow-raised) group-hover:text-primary">
209
- <Icon className="size-4" />
210
- </span>
211
- <span className="min-w-0 flex-1">
212
- <span className="block text-sm font-medium">{t(label)}</span>
213
- {description && <span className="block truncate text-xs text-muted-foreground">{t(description)}</span>}
214
- </span>
215
- <ArrowRight className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
216
- </Link>
217
- </li>
218
- ))}
219
- </ul>
220
- </Panel>
221
- )
222
- }
223
-
224
- function ActivityCard() {
225
- const { data, isPending, error, refetch } = useActivity()
226
- const { t } = useTranslation(['dashboard', 'common'])
227
-
228
- return (
229
- <Panel label={t('panels.activity')}>
230
- {error ? (
231
- <ErrorState error={error} onRetry={() => refetch()} />
232
- ) : (
233
- <ul className="divide-y">
234
- {isPending
235
- ? Array.from({ length: 5 }, (_, i) => (
236
- <li key={i} className="flex items-center gap-3 px-4 py-3">
237
- <Skeleton className="size-1.5 rounded-full" />
238
- <Skeleton className="h-4 flex-1" />
239
- </li>
240
- ))
241
- : data.map((a) => <ActivityRow key={a.id} activity={a} />)}
242
- </ul>
243
51
  )}
244
- </Panel>
245
- )
246
- }
247
-
248
- function ActivityRow({ activity: a }: { activity: Activity }) {
249
- const { t } = useTranslation(['dashboard', 'common'])
250
- const fmt = useFormat()
251
- const actor = a.actor === 'system' ? t('activity.system') : a.actor === 'payments' ? t('activity.payments') : a.actor
252
- const target = a.amount === undefined ? a.target : `${a.target} · ${fmt.currency(a.amount)}`
253
-
254
- return (
255
- <li className="flex items-center gap-3 px-4 py-3 text-sm">
256
- <StatusDot tone={a.tone} />
257
- <p className="min-w-0 flex-1 truncate text-muted-foreground">
258
- <Trans
259
- t={t}
260
- i18nKey={`activity.${a.kind}`}
261
- values={{ actor, target }}
262
- components={{ b: <span className="font-medium text-foreground" /> }}
263
- />
264
- </p>
265
- <time dateTime={a.at} className="shrink-0 tabular-nums text-[11px] text-muted-foreground">
266
- {fmt.relative(a.at)}
267
- </time>
268
- </li>
52
+ <QuickActionsCard />
53
+ </Page>
269
54
  )
270
55
  }
@@ -0,0 +1,37 @@
1
+ import { Link } from 'react-router'
2
+ import { ArrowRight } from 'lucide-react'
3
+
4
+ import { usePermissions } from '@/core/auth/can'
5
+ import { Panel } from '@/core/components/page'
6
+ import { useAnyT } from '@/core/i18n/keys'
7
+ import { quickActions } from '@/core/modules/navigation'
8
+
9
+ export function QuickActionsCard() {
10
+ const t = useAnyT()
11
+ const { can } = usePermissions()
12
+ return (
13
+ <Panel label={t('dashboard:panels.quickActions')} bodyClassName="p-2">
14
+ <ul className="flex flex-col gap-0.5">
15
+ {quickActions
16
+ .filter((a) => !a.permission || can(a.permission))
17
+ .map(({ path, label, description, icon: Icon }) => (
18
+ <li key={path}>
19
+ <Link
20
+ to={path}
21
+ className="group flex items-center gap-3 rounded-lg p-2 outline-none hover:bg-fill-1 focus-visible:ring-2 focus-visible:ring-ring/40"
22
+ >
23
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-raised text-muted-foreground shadow-(--shadow-raised) group-hover:text-primary">
24
+ <Icon className="size-4" />
25
+ </span>
26
+ <span className="min-w-0 flex-1">
27
+ <span className="block text-sm font-medium">{t(label)}</span>
28
+ {description && <span className="block truncate text-xs text-muted-foreground">{t(description)}</span>}
29
+ </span>
30
+ <ArrowRight className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
31
+ </Link>
32
+ </li>
33
+ ))}
34
+ </ul>
35
+ </Panel>
36
+ )
37
+ }
@@ -11,10 +11,12 @@ import { Card, CardContent } from '@/components/ui/card'
11
11
  import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
12
12
  import { Input } from '@/components/ui/input'
13
13
  import { Spinner } from '@/components/ui/spinner'
14
- import { appConfig } from '@/app.config'
14
+ import { demoSeed } from '@/core/api/demo'
15
15
  import { ApiError } from '@/core/api/http'
16
16
  import { useAuth } from '@/core/auth/use-auth'
17
- import { authMode } from '@/core/config/runtime'
17
+ import { useCompanySettings } from '@/core/company/hooks'
18
+ import { useAppName } from '@/core/company/use-app-name'
19
+ import { isDemo } from '@/core/config/runtime'
18
20
  import { FormFieldError } from '@/core/components/form-error'
19
21
  import { LanguageSwitcher } from '@/core/i18n/language-switcher'
20
22
  import { vmsg } from '@/core/i18n/keys'
@@ -40,14 +42,15 @@ export default function LoginPage() {
40
42
 
41
43
  const { register, handleSubmit, formState } = useForm<Values>({
42
44
  resolver: zodResolver(schema),
43
- defaultValues: { email: 'owner@freakpunch.site', password: '' },
45
+ defaultValues: { email: demoSeed<string>('loginEmail') ?? '', password: '' },
44
46
  })
45
47
  const { errors, isSubmitting } = formState
46
48
 
49
+ const appName = useAppName()
47
50
  const title = t('signIn.title')
48
51
  useEffect(() => {
49
- document.title = `${title} · ${appConfig.name}`
50
- }, [title])
52
+ document.title = `${title} · ${appName}`
53
+ }, [title, appName])
51
54
 
52
55
  if (isAuthenticated && !isSubmitting) return <Navigate to={redirect} replace />
53
56
 
@@ -57,8 +60,10 @@ export default function LoginPage() {
57
60
  toast.success(t('signIn.welcome', { name: session.user.firstName }))
58
61
  navigate(redirect, { replace: true })
59
62
  } catch (e) {
60
- const inactive = e instanceof ApiError && e.status === 403
61
- toast.error(t('signIn.failed'), { description: inactive ? t('signIn.inactive') : e instanceof Error ? e.message : undefined })
63
+ const status = e instanceof ApiError ? e.status : undefined
64
+ const description =
65
+ status === 401 ? t('signIn.invalid') : status === 403 ? t('signIn.inactive') : e instanceof Error ? e.message : undefined
66
+ toast.error(t('signIn.failed'), { description })
62
67
  }
63
68
  })
64
69
 
@@ -77,7 +82,7 @@ export default function LoginPage() {
77
82
  <div className="mb-2 flex flex-col items-center gap-2 text-center">
78
83
  <h1 className="text-2xl font-semibold tracking-tight">{t('signIn.heading')}</h1>
79
84
  <p className="text-sm text-balance text-muted-foreground">
80
- {t('signIn.description', { name: appConfig.name })}
85
+ {t('signIn.description', { name: appName })}
81
86
  </p>
82
87
  </div>
83
88
  <Field data-invalid={!!errors.email}>
@@ -86,6 +91,7 @@ export default function LoginPage() {
86
91
  id="email"
87
92
  type="email"
88
93
  autoComplete="email"
94
+ autoFocus={!isDemo}
89
95
  size="lg"
90
96
  aria-invalid={!!errors.email}
91
97
  {...register('email')}
@@ -98,7 +104,7 @@ export default function LoginPage() {
98
104
  id="password"
99
105
  type="password"
100
106
  autoComplete="current-password"
101
- autoFocus
107
+ autoFocus={isDemo}
102
108
  size="lg"
103
109
  aria-invalid={!!errors.password}
104
110
  {...register('password')}
@@ -116,9 +122,9 @@ export default function LoginPage() {
116
122
  </CardContent>
117
123
  </Card>
118
124
 
119
- {authMode === 'mock' && (
125
+ {isDemo && (
120
126
  <p className="row-start-3 max-w-sm self-end pt-4 text-center text-xs text-balance text-muted-foreground md:max-w-md">
121
- {t('signIn.demo')}
127
+ {t('signIn.demo', { domain: demoSeed<string>('loginDomain') })}
122
128
  </p>
123
129
  )}
124
130
  </div>
@@ -127,13 +133,19 @@ export default function LoginPage() {
127
133
 
128
134
  /** Image side of the split card: a neutral, token-only placeholder (no stock photo). Hidden on mobile. */
129
135
  function BrandPanel() {
136
+ const appName = useAppName()
137
+ const logo = useCompanySettings().data?.branding.logoLight
130
138
  return (
131
139
  <div aria-hidden className="relative hidden items-center justify-center overflow-hidden border-l bg-fill-1 md:flex">
132
140
  <div className="absolute inset-0 bg-[radial-gradient(var(--border)_1px,transparent_1px)] bg-size-[16px_16px] mask-[radial-gradient(ellipse_at_center,black_30%,transparent_75%)]" />
133
141
  <div className="relative flex size-24 items-center justify-center rounded-(--radius-panel) bg-raised shadow-(--shadow-raised) ring-1 ring-border">
134
- <span className="flex size-12 items-center justify-center rounded-xl bg-foreground text-xl font-semibold text-background">
135
- {appConfig.name[0]?.toUpperCase()}
136
- </span>
142
+ {logo ? (
143
+ <img src={logo} alt="" className="max-h-16 max-w-16 object-contain" />
144
+ ) : (
145
+ <span className="flex size-12 items-center justify-center rounded-xl bg-foreground text-xl font-semibold text-background">
146
+ {appName[0]?.toUpperCase()}
147
+ </span>
148
+ )}
137
149
  </div>
138
150
  </div>
139
151
  )
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
3
3
  import { isRouteErrorResponse, useRouteError } from 'react-router'
4
4
  import { ArrowLeft } from 'lucide-react'
5
5
 
6
- import { appConfig } from '@/app.config'
7
6
  import { ButtonLink } from '@/core/components/button-link'
8
7
  import { cn } from '@/lib/utils'
9
8
 
@@ -22,7 +21,7 @@ export function Message({
22
21
  const { t } = useTranslation()
23
22
 
24
23
  useEffect(() => {
25
- document.title = `${title} · ${appConfig.name}`
24
+ document.title = title
26
25
  }, [title])
27
26
 
28
27
  return (
@@ -1,6 +1,10 @@
1
1
  import { ApiError } from '@/core/api/http'
2
- import { createMockDocument, delay, seededRandom } from '@/core/api/mock'
2
+ import { demoSeed } from '@/core/api/demo'
3
+ import { createMockDocument, delay } from '@/core/api/mock'
4
+ import { appConfig } from '@/app.config'
3
5
  import { authStore } from '@/core/auth/auth-store'
6
+ import { localCredentials } from '@/core/auth/local-credentials'
7
+ import { isDemo } from '@/core/config/runtime'
4
8
  import {
5
9
  type ActiveSession,
6
10
  type LoginEvent,
@@ -30,53 +34,24 @@ export interface AccountService {
30
34
  /** Error codes in ApiError.message; screens map them to translations. */
31
35
  export const accountErrors = { wrongPassword: 'wrong_password', invalidCode: 'invalid_code' } as const
32
36
 
33
- type AccountDoc = {
37
+ export type AccountDoc = {
34
38
  security: SecurityOverview
35
39
  sessions: ActiveSession[]
36
40
  history: LoginEvent[]
37
41
  }
38
42
 
39
- const hour = 3_600_000
40
-
41
- function seed(userId: string): AccountDoc {
42
- const now = Date.now()
43
- const rand = seededRandom(userId.length * 7919 + userId.charCodeAt(userId.length - 1))
44
- const at = (hoursAgo: number) => new Date(now - hoursAgo * hour).toISOString()
45
- const devices = [
46
- { browser: 'Chrome', os: 'macOS', kind: 'desktop', location: 'İstanbul, TR', ip: '88.241.12.40' },
47
- { browser: 'Safari', os: 'iOS', kind: 'mobile', location: 'İstanbul, TR', ip: '176.88.31.7' },
48
- { browser: 'Firefox', os: 'Windows', kind: 'desktop', location: 'Ankara, TR', ip: '78.180.5.122' },
49
- { browser: 'Edge', os: 'Windows', kind: 'desktop', location: 'Berlin, DE', ip: '91.64.200.18' },
50
- ] as const
51
- return {
52
- security: { passwordChangedAt: at(24 * 64), twoFactorEnabled: false },
53
- sessions: devices.slice(0, 3).map((d, i) => ({
54
- ...d,
55
- id: `s_${i + 1}`,
56
- lastActiveAt: at(i === 0 ? 0 : i * 20 + rand() * 10),
57
- current: i === 0,
58
- })),
59
- history: Array.from({ length: 8 }, (_, i) => {
60
- const d = devices[Math.floor(rand() * devices.length)]!
61
- return {
62
- id: `l_${i + 1}`,
63
- at: at(i * 19 + rand() * 6),
64
- browser: d.browser,
65
- os: d.os,
66
- location: d.location,
67
- ip: d.ip,
68
- success: i !== 2 && i !== 5,
69
- }
70
- }),
71
- }
72
- }
43
+ const blankAccount = (): AccountDoc => ({
44
+ security: { passwordChangedAt: new Date().toISOString(), twoFactorEnabled: false },
45
+ sessions: [],
46
+ history: [],
47
+ })
73
48
 
74
49
  function createMockAccountService(): AccountService {
75
50
  const docs = new Map<string, ReturnType<typeof createMockDocument<AccountDoc>>>()
76
51
  const doc = () => {
77
52
  const userId = authStore.getSession()?.user.id
78
53
  if (!userId) throw new ApiError(401, 'unauthorized')
79
- if (!docs.has(userId)) docs.set(userId, createMockDocument(`account:${userId}`, () => seed(userId)))
54
+ if (!docs.has(userId)) docs.set(userId, createMockDocument(`account:${userId}`, () => demoSeed<(id: string) => AccountDoc>('account')?.(userId) ?? blankAccount()))
80
55
  return docs.get(userId)!
81
56
  }
82
57
  const update = (patch: Partial<AccountDoc>) => doc().save({ ...doc().get(), ...patch })
@@ -88,10 +63,16 @@ function createMockAccountService(): AccountService {
88
63
  return doc().get().security
89
64
  },
90
65
 
91
- async changePassword({ current }) {
66
+ async changePassword({ current, next }) {
92
67
  await delay(500, 800)
93
- // Demo rule so the error path can be tried: "wrong" is never the current password.
94
- if (current === 'wrong') throw new ApiError(400, accountErrors.wrongPassword)
68
+ if (isDemo) {
69
+ // Demo rule so the error path can be tried: "wrong" is never the current password.
70
+ if (current === 'wrong') throw new ApiError(400, accountErrors.wrongPassword)
71
+ } else {
72
+ const userId = authStore.getSession()?.user.id ?? ''
73
+ if (!(await localCredentials.verify(userId, current))) throw new ApiError(400, accountErrors.wrongPassword)
74
+ await localCredentials.set(userId, next)
75
+ }
95
76
  update({ security: { ...doc().get().security, passwordChangedAt: new Date().toISOString() } })
96
77
  },
97
78
 
@@ -102,7 +83,7 @@ function createMockAccountService(): AccountService {
102
83
  const email = authStore.getSession()?.user.email ?? ''
103
84
  return {
104
85
  secret: pendingSecret.match(/.{4}/g)!.join(' '),
105
- otpauthUrl: `otpauth://totp/freakpunch:${encodeURIComponent(email)}?secret=${pendingSecret}&issuer=freakpunch`,
86
+ otpauthUrl: `otpauth://totp/${encodeURIComponent(appConfig.name)}:${encodeURIComponent(email)}?secret=${pendingSecret}&issuer=${encodeURIComponent(appConfig.name)}`,
106
87
  }
107
88
  },
108
89
 
@@ -8,10 +8,10 @@ import { FormLayout } from '@/core/components/form-page'
8
8
  import { Page } from '@/core/components/page'
9
9
  import { ErrorState, LoadingState } from '@/core/components/states'
10
10
  import { useNotificationSettings, useUpdateNotificationSettings } from '@/core/notifications/hooks'
11
- import { notificationEvents, type NotificationGroup, type NotificationSettings } from '@/core/notifications/types'
11
+ import { visibleNotificationEvents, type NotificationGroup, type NotificationSettings } from '@/core/notifications/types'
12
12
 
13
13
  const channels = ['email', 'inApp'] as const
14
- const groups = [...new Set(notificationEvents.map((e) => e.group))] as NotificationGroup[]
14
+ const groups = [...new Set(visibleNotificationEvents.map((e) => e.group))] as NotificationGroup[]
15
15
 
16
16
  export default function NotificationsPage() {
17
17
  const { t } = useTranslation('account')
@@ -67,7 +67,7 @@ function NotificationsForm({ settings }: { settings: NotificationSettings }) {
67
67
  {t(`notifications.groups.${group}`)}
68
68
  </span>
69
69
  </div>
70
- {notificationEvents
70
+ {visibleNotificationEvents
71
71
  .filter((e) => e.group === group)
72
72
  .map(({ id }) => {
73
73
  const label = t(`notifications.events.${id}.label`)
@@ -30,16 +30,18 @@ const required = (max: number) =>
30
30
  .min(1, vmsg('validation.required'))
31
31
  .max(max, vmsg('validation.maxChars', { max }))
32
32
 
33
+ const optional = (max: number) => z.string().trim().max(max, vmsg('validation.maxChars', { max }))
34
+
33
35
  const schema = z
34
36
  .object({
35
37
  name: required(80),
36
- legalName: required(160),
37
- taxOffice: required(80),
38
+ legalName: optional(160),
39
+ taxOffice: optional(80),
38
40
  taxNumber: z
39
41
  .string()
40
42
  .trim()
41
- .regex(/^\d{10}$/, vmsg('company:general.tax.numberInvalid')),
42
- address: required(300),
43
+ .regex(/^(\d{10})?$/, vmsg('company:general.tax.numberInvalid')),
44
+ address: optional(300),
43
45
  contactEmail: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
44
46
  phoneCountry: z.string(),
45
47
  phoneNumber: z.string().trim(),
@@ -1,3 +1,4 @@
1
+ import { demoSeed } from '@/core/api/demo'
1
2
  import { createMockDocument, delay } from '@/core/api/mock'
2
3
  import { auditService } from '@/core/audit/audit.service'
3
4
 
@@ -70,33 +71,12 @@ export interface IntegrationsService {
70
71
  revokeApiKey(id: string): Promise<Integrations>
71
72
  }
72
73
 
73
- const day = 86_400_000
74
- const ago = (days: number) => new Date(Date.now() - days * day).toISOString()
75
-
76
- const seed = (): Integrations => ({
77
- smtp: {
78
- host: 'smtp.postmarkapp.com',
79
- port: 587,
80
- encryption: 'tls',
81
- username: 'freakpunch',
82
- password: 'mock-smtp-password',
83
- fromName: 'freakpunch',
84
- fromEmail: 'no-reply@freakpunch.site',
85
- },
86
- storage: { provider: 'r2', bucket: 'freakpunch-media', region: 'auto', accessKeyId: 'R2AKIA7Q3EXAMPLE', secretAccessKey: 'mock-secret' },
87
- payments: { provider: 'iyzico', mode: 'test', publicKey: 'sandbox-pk-1a2b3c', secretKey: 'mock-secret' },
88
- webhooks: [
89
- {
90
- id: 'wh_1',
91
- url: 'https://hooks.example.com/orders',
92
- events: ['product.created', 'product.updated'],
93
- active: true,
94
- createdAt: ago(40),
95
- lastDeliveryAt: ago(0.2),
96
- lastStatus: 'success',
97
- },
98
- ],
99
- apiKeys: [{ id: 'key_1', name: 'Order sync', prefix: 'fp_live_8Kd2', createdAt: ago(62), lastUsedAt: ago(0.5) }],
74
+ const blank = (): Integrations => ({
75
+ smtp: { host: '', port: 587, encryption: 'tls', username: '', password: '', fromName: '', fromEmail: '' },
76
+ storage: { provider: 'r2', bucket: '', region: '', accessKeyId: '', secretAccessKey: '' },
77
+ payments: { provider: 'iyzico', mode: 'test', publicKey: '', secretKey: '' },
78
+ webhooks: [],
79
+ apiKeys: [],
100
80
  })
101
81
 
102
82
  /** Replace secrets with the placeholder before returning (as an API would). */
@@ -113,7 +93,7 @@ function redact(value: Integrations): Integrations {
113
93
  const keep = (next: string, prev: string) => (next === SECRET_PLACEHOLDER ? prev : next)
114
94
 
115
95
  function createMockIntegrationsService(): IntegrationsService {
116
- const doc = createMockDocument('integrations', seed)
96
+ const doc = createMockDocument('integrations', () => demoSeed<() => Integrations>('integrations')?.() ?? blank())
117
97
  const save = (patch: Partial<Integrations>) => redact(doc.save({ ...doc.get(), ...patch }))
118
98
 
119
99
  return {