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
|
@@ -1,35 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
12
|
-
import { Page
|
|
13
|
-
import {
|
|
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 {
|
|
10
|
+
import { QuickActionsCard } from './quick-actions'
|
|
23
11
|
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
{
|
|
65
|
-
|
|
66
|
-
<
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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: '
|
|
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} · ${
|
|
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
|
|
61
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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}`, () =>
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
{
|
|
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:
|
|
37
|
-
taxOffice:
|
|
38
|
+
legalName: optional(160),
|
|
39
|
+
taxOffice: optional(80),
|
|
38
40
|
taxNumber: z
|
|
39
41
|
.string()
|
|
40
42
|
.trim()
|
|
41
|
-
.regex(
|
|
42
|
-
address:
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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',
|
|
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 {
|