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,286 +0,0 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
2
|
-
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import { Link, useNavigate } from 'react-router'
|
|
4
|
-
import { createColumnHelper, type RowSelectionState } from '@tanstack/react-table'
|
|
5
|
-
import { Copy, Download, Eye, MoreHorizontal, Package, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
6
|
-
import { toast } from 'sonner'
|
|
7
|
-
|
|
8
|
-
import { Button } from '@/components/ui/button'
|
|
9
|
-
import {
|
|
10
|
-
DropdownMenu,
|
|
11
|
-
DropdownMenuContent,
|
|
12
|
-
DropdownMenuItem,
|
|
13
|
-
DropdownMenuSeparator,
|
|
14
|
-
DropdownMenuTrigger,
|
|
15
|
-
} from '@/components/ui/dropdown-menu'
|
|
16
|
-
import { Can, usePermissions } from '@/core/auth/can'
|
|
17
|
-
import { ButtonLink } from '@/core/components/button-link'
|
|
18
|
-
import { useConfirm } from '@/core/components/confirm-dialog'
|
|
19
|
-
import { DataTable } from '@/core/components/data-table/data-table'
|
|
20
|
-
import type { DataTableFeatures } from '@/core/components/data-table/features'
|
|
21
|
-
import { ClearFiltersButton, FilterSelect, TableSearch } from '@/core/components/data-table/toolbar'
|
|
22
|
-
import { useListUrlState } from '@/core/components/data-table/use-list-url-state'
|
|
23
|
-
import { Page } from '@/core/components/page'
|
|
24
|
-
import { EmptyState } from '@/core/components/states'
|
|
25
|
-
import { useFormat } from '@/core/i18n/use-format'
|
|
26
|
-
import { downloadCsv } from '@/core/lib/csv'
|
|
27
|
-
import { ProductStatusBadge, StockLevel } from '../components/product-status'
|
|
28
|
-
import { useDeleteProduct, useExportProducts, useProducts } from '../hooks'
|
|
29
|
-
import { productCategories, productStatuses, type Product } from '../types'
|
|
30
|
-
|
|
31
|
-
const FILTER_KEYS = ['status', 'category'] as const
|
|
32
|
-
const DEFAULT_SORT = { id: 'updatedAt', desc: true }
|
|
33
|
-
const helper = createColumnHelper<DataTableFeatures, Product>()
|
|
34
|
-
|
|
35
|
-
export default function ProductsListPage() {
|
|
36
|
-
const list = useListUrlState({ filterKeys: FILTER_KEYS, defaultSort: DEFAULT_SORT })
|
|
37
|
-
const query = useProducts(list.listParams)
|
|
38
|
-
const { mutateAsync: deleteAsync } = useDeleteProduct()
|
|
39
|
-
const confirm = useConfirm()
|
|
40
|
-
const navigate = useNavigate()
|
|
41
|
-
const { t } = useTranslation(['products', 'common'])
|
|
42
|
-
const fmt = useFormat()
|
|
43
|
-
const { can } = usePermissions()
|
|
44
|
-
const [selected, setSelected] = useState<RowSelectionState>({})
|
|
45
|
-
const exportRows = useExportProducts(list.listParams)
|
|
46
|
-
|
|
47
|
-
async function onExport() {
|
|
48
|
-
const rows = await exportRows()
|
|
49
|
-
downloadCsv(
|
|
50
|
-
'products',
|
|
51
|
-
[
|
|
52
|
-
{ header: t('form.fields.name'), value: (p: Product) => p.name },
|
|
53
|
-
{ header: t('form.fields.sku'), value: (p: Product) => p.sku },
|
|
54
|
-
{ header: t('form.fields.status'), value: (p: Product) => t(`status.${p.status}`) },
|
|
55
|
-
{ header: t('form.fields.price'), value: (p: Product) => p.price },
|
|
56
|
-
{ header: t('form.fields.stock'), value: (p: Product) => p.stock },
|
|
57
|
-
],
|
|
58
|
-
rows,
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function onBulkDelete() {
|
|
63
|
-
const ids = Object.keys(selected).filter((id) => selected[id])
|
|
64
|
-
const ok = await confirm({
|
|
65
|
-
title: t('delete.bulkTitle', { count: ids.length }),
|
|
66
|
-
description: t('delete.bulkDescription'),
|
|
67
|
-
confirmLabel: t('common:actions.delete'),
|
|
68
|
-
destructive: true,
|
|
69
|
-
})
|
|
70
|
-
if (!ok) return
|
|
71
|
-
toast.promise(Promise.all(ids.map((id) => deleteAsync(id))), {
|
|
72
|
-
loading: t('delete.deleting'),
|
|
73
|
-
success: () => {
|
|
74
|
-
setSelected({})
|
|
75
|
-
return t('delete.bulkDeleted', { count: ids.length })
|
|
76
|
-
},
|
|
77
|
-
error: (e: Error) => e.message,
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const columns = useMemo(() => {
|
|
82
|
-
async function onDelete(product: Product) {
|
|
83
|
-
const ok = await confirm({
|
|
84
|
-
title: t('delete.title', { name: product.name }),
|
|
85
|
-
description: t('delete.description'),
|
|
86
|
-
confirmLabel: t('common:actions.delete'),
|
|
87
|
-
destructive: true,
|
|
88
|
-
})
|
|
89
|
-
if (!ok) return
|
|
90
|
-
toast.promise(deleteAsync(product.id), {
|
|
91
|
-
loading: t('delete.deleting'),
|
|
92
|
-
success: t('delete.deleted', { name: product.name }),
|
|
93
|
-
error: (e: Error) => e.message,
|
|
94
|
-
})
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return helper.columns([
|
|
98
|
-
helper.accessor('name', {
|
|
99
|
-
header: t('list.columns.product'),
|
|
100
|
-
cell: ({ row }) => (
|
|
101
|
-
<div className="flex min-w-0 items-center gap-3">
|
|
102
|
-
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-fill-2 text-muted-foreground">
|
|
103
|
-
<Package className="size-4" />
|
|
104
|
-
</div>
|
|
105
|
-
<div className="min-w-0">
|
|
106
|
-
<Link
|
|
107
|
-
to={row.original.id}
|
|
108
|
-
className="block truncate font-medium outline-none hover:underline focus-visible:underline"
|
|
109
|
-
>
|
|
110
|
-
{row.original.name}
|
|
111
|
-
</Link>
|
|
112
|
-
<div className="tabular-nums text-2xs text-muted-foreground">{row.original.sku}</div>
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
),
|
|
116
|
-
}),
|
|
117
|
-
helper.accessor('category', {
|
|
118
|
-
header: t('list.columns.category'),
|
|
119
|
-
meta: { hideOnMobile: true },
|
|
120
|
-
cell: ({ getValue }) => t(`categories.${getValue()}`),
|
|
121
|
-
}),
|
|
122
|
-
helper.accessor('status', {
|
|
123
|
-
header: t('list.columns.status'),
|
|
124
|
-
cell: ({ getValue }) => <ProductStatusBadge status={getValue()} />,
|
|
125
|
-
}),
|
|
126
|
-
helper.accessor('stock', {
|
|
127
|
-
header: t('list.columns.stock'),
|
|
128
|
-
meta: { align: 'right', hideOnMobile: true },
|
|
129
|
-
cell: ({ getValue }) => <StockLevel stock={getValue()} />,
|
|
130
|
-
}),
|
|
131
|
-
helper.accessor('price', {
|
|
132
|
-
header: t('list.columns.price'),
|
|
133
|
-
meta: { align: 'right' },
|
|
134
|
-
cell: ({ getValue }) => fmt.currency(getValue()),
|
|
135
|
-
}),
|
|
136
|
-
helper.accessor('updatedAt', {
|
|
137
|
-
header: t('list.columns.updated'),
|
|
138
|
-
meta: { hideOnMobile: true, className: 'text-muted-foreground' },
|
|
139
|
-
cell: ({ getValue }) => fmt.date(getValue()),
|
|
140
|
-
}),
|
|
141
|
-
helper.display({
|
|
142
|
-
id: 'actions',
|
|
143
|
-
header: () => <span className="sr-only">{t('common:table.actions')}</span>,
|
|
144
|
-
meta: { align: 'right', className: 'w-12' },
|
|
145
|
-
cell: ({ row }) => (
|
|
146
|
-
<DropdownMenu>
|
|
147
|
-
<DropdownMenuTrigger
|
|
148
|
-
render={
|
|
149
|
-
<Button
|
|
150
|
-
variant="ghost"
|
|
151
|
-
size="icon-sm"
|
|
152
|
-
aria-label={t('common:table.actionsFor', { name: row.original.name })}
|
|
153
|
-
>
|
|
154
|
-
<MoreHorizontal />
|
|
155
|
-
</Button>
|
|
156
|
-
}
|
|
157
|
-
/>
|
|
158
|
-
<DropdownMenuContent align="end" className="w-44">
|
|
159
|
-
<DropdownMenuItem onClick={() => navigate(row.original.id)}>
|
|
160
|
-
<Eye />
|
|
161
|
-
{t('common:actions.view')}
|
|
162
|
-
</DropdownMenuItem>
|
|
163
|
-
{can('products.edit') && (
|
|
164
|
-
<DropdownMenuItem onClick={() => navigate(`${row.original.id}/edit`)}>
|
|
165
|
-
<Pencil />
|
|
166
|
-
{t('common:actions.edit')}
|
|
167
|
-
</DropdownMenuItem>
|
|
168
|
-
)}
|
|
169
|
-
<DropdownMenuItem
|
|
170
|
-
onClick={() => {
|
|
171
|
-
void navigator.clipboard?.writeText(row.original.sku)
|
|
172
|
-
toast.success(t('list.skuCopied'))
|
|
173
|
-
}}
|
|
174
|
-
>
|
|
175
|
-
<Copy />
|
|
176
|
-
{t('list.copySku')}
|
|
177
|
-
</DropdownMenuItem>
|
|
178
|
-
{can('products.delete') && (
|
|
179
|
-
<>
|
|
180
|
-
<DropdownMenuSeparator />
|
|
181
|
-
<DropdownMenuItem variant="destructive" onClick={() => onDelete(row.original)}>
|
|
182
|
-
<Trash2 />
|
|
183
|
-
{t('common:actions.delete')}
|
|
184
|
-
</DropdownMenuItem>
|
|
185
|
-
</>
|
|
186
|
-
)}
|
|
187
|
-
</DropdownMenuContent>
|
|
188
|
-
</DropdownMenu>
|
|
189
|
-
),
|
|
190
|
-
}),
|
|
191
|
-
])
|
|
192
|
-
}, [confirm, deleteAsync, navigate, t, fmt, can])
|
|
193
|
-
|
|
194
|
-
const statusOptions = productStatuses.map((s) => ({ value: s, label: t(`status.${s}`) }))
|
|
195
|
-
const categoryOptions = productCategories.map((c) => ({ value: c, label: t(`categories.${c}`) }))
|
|
196
|
-
|
|
197
|
-
return (
|
|
198
|
-
<Page
|
|
199
|
-
title={t('list.title')}
|
|
200
|
-
description={t('list.description')}
|
|
201
|
-
actions={
|
|
202
|
-
<>
|
|
203
|
-
<Button variant="outline" onClick={onExport}>
|
|
204
|
-
<Download data-icon="inline-start" />
|
|
205
|
-
{t('list.export')}
|
|
206
|
-
</Button>
|
|
207
|
-
<Can permission="products.create">
|
|
208
|
-
<ButtonLink to="new">
|
|
209
|
-
<Plus data-icon="inline-start" />
|
|
210
|
-
{t('list.new')}
|
|
211
|
-
</ButtonLink>
|
|
212
|
-
</Can>
|
|
213
|
-
</>
|
|
214
|
-
}
|
|
215
|
-
>
|
|
216
|
-
<DataTable
|
|
217
|
-
columns={columns}
|
|
218
|
-
data={query.data?.items}
|
|
219
|
-
total={query.data?.total ?? 0}
|
|
220
|
-
getRowId={(p) => p.id}
|
|
221
|
-
pagination={list.pagination}
|
|
222
|
-
onPaginationChange={list.onPaginationChange}
|
|
223
|
-
sorting={list.sorting}
|
|
224
|
-
onSortingChange={list.onSortingChange}
|
|
225
|
-
isLoading={query.isPending}
|
|
226
|
-
isFetching={query.isFetching}
|
|
227
|
-
error={query.error}
|
|
228
|
-
onRetry={() => query.refetch()}
|
|
229
|
-
noun={t('noun')}
|
|
230
|
-
selection={
|
|
231
|
-
can('products.delete')
|
|
232
|
-
? {
|
|
233
|
-
value: selected,
|
|
234
|
-
onChange: setSelected,
|
|
235
|
-
actions: (
|
|
236
|
-
<Button variant="destructive" size="sm" onClick={onBulkDelete}>
|
|
237
|
-
<Trash2 data-icon="inline-start" />
|
|
238
|
-
{t('common:actions.delete')}
|
|
239
|
-
</Button>
|
|
240
|
-
),
|
|
241
|
-
}
|
|
242
|
-
: undefined
|
|
243
|
-
}
|
|
244
|
-
toolbar={
|
|
245
|
-
<>
|
|
246
|
-
<TableSearch value={list.search} onChange={list.setSearch} placeholder={t('list.search')} />
|
|
247
|
-
<FilterSelect
|
|
248
|
-
label={t('list.filterStatus')}
|
|
249
|
-
allLabel={t('list.allStatuses')}
|
|
250
|
-
value={list.filters.status}
|
|
251
|
-
options={statusOptions}
|
|
252
|
-
onChange={(v) => list.setFilter('status', v)}
|
|
253
|
-
/>
|
|
254
|
-
<FilterSelect
|
|
255
|
-
label={t('list.filterCategory')}
|
|
256
|
-
allLabel={t('list.allCategories')}
|
|
257
|
-
value={list.filters.category}
|
|
258
|
-
options={categoryOptions}
|
|
259
|
-
onChange={(v) => list.setFilter('category', v)}
|
|
260
|
-
/>
|
|
261
|
-
{list.hasActiveFilters && <ClearFiltersButton onClick={list.clearAll} />}
|
|
262
|
-
</>
|
|
263
|
-
}
|
|
264
|
-
empty={
|
|
265
|
-
list.hasActiveFilters ? (
|
|
266
|
-
<EmptyState
|
|
267
|
-
title={t('list.noMatchesTitle')}
|
|
268
|
-
description={t('list.noMatchesDescription')}
|
|
269
|
-
action={<ClearFiltersButton onClick={list.clearAll} />}
|
|
270
|
-
/>
|
|
271
|
-
) : (
|
|
272
|
-
<EmptyState
|
|
273
|
-
title={t('list.emptyTitle')}
|
|
274
|
-
description={t('list.emptyDescription')}
|
|
275
|
-
action={
|
|
276
|
-
<Can permission="products.create">
|
|
277
|
-
<ButtonLink to="new">{t('list.new')}</ButtonLink>
|
|
278
|
-
</Can>
|
|
279
|
-
}
|
|
280
|
-
/>
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
/>
|
|
284
|
-
</Page>
|
|
285
|
-
)
|
|
286
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
|
|
3
|
-
import { vmsg } from '@/core/i18n/keys'
|
|
4
|
-
|
|
5
|
-
export const productStatuses = ['active', 'draft', 'archived'] as const
|
|
6
|
-
export type ProductStatus = (typeof productStatuses)[number]
|
|
7
|
-
|
|
8
|
-
/** Category ids are stored data; labels come from `products:categories.<id>`. */
|
|
9
|
-
export const productCategories = ['Lighting', 'Furniture', 'Decor', 'Kitchen', 'Textiles'] as const
|
|
10
|
-
export type ProductCategory = (typeof productCategories)[number]
|
|
11
|
-
|
|
12
|
-
export type Product = {
|
|
13
|
-
id: string
|
|
14
|
-
name: string
|
|
15
|
-
sku: string
|
|
16
|
-
category: ProductCategory
|
|
17
|
-
price: number
|
|
18
|
-
stock: number
|
|
19
|
-
status: ProductStatus
|
|
20
|
-
description: string
|
|
21
|
-
createdAt: string
|
|
22
|
-
updatedAt: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type ProductFilters = {
|
|
26
|
-
status?: string
|
|
27
|
-
category?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type ProductStats = {
|
|
31
|
-
total: number
|
|
32
|
-
active: number
|
|
33
|
-
lowStock: number
|
|
34
|
-
inventoryValue: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Validation for create/edit. Shared with the service so mock and real APIs agree. */
|
|
38
|
-
export const productFormSchema = z.object({
|
|
39
|
-
name: z
|
|
40
|
-
.string()
|
|
41
|
-
.trim()
|
|
42
|
-
.min(2, vmsg('validation.minChars', { min: 2 }))
|
|
43
|
-
.max(80, vmsg('validation.maxChars', { max: 80 })),
|
|
44
|
-
sku: z
|
|
45
|
-
.string()
|
|
46
|
-
.trim()
|
|
47
|
-
.regex(/^[A-Z0-9-]{3,20}$/, vmsg('products:form.errors.sku')),
|
|
48
|
-
category: z.enum(productCategories, { error: vmsg('validation.choose') }),
|
|
49
|
-
price: z
|
|
50
|
-
.number({ error: vmsg('products:form.errors.price') })
|
|
51
|
-
.min(0, vmsg('validation.nonNegative'))
|
|
52
|
-
.max(100_000, vmsg('validation.max', { max: 100_000 })),
|
|
53
|
-
stock: z
|
|
54
|
-
.number({ error: vmsg('products:form.errors.stock') })
|
|
55
|
-
.int(vmsg('validation.integer'))
|
|
56
|
-
.min(0, vmsg('validation.nonNegative')),
|
|
57
|
-
status: z.enum(productStatuses),
|
|
58
|
-
description: z.string().trim().max(500, vmsg('validation.maxChars', { max: 500 })),
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
export type ProductInput = z.infer<typeof productFormSchema>
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query'
|
|
2
|
-
|
|
3
|
-
import { delay, seededRandom } from '@/core/api/mock'
|
|
4
|
-
|
|
5
|
-
export type Range = '7d' | '30d' | '90d'
|
|
6
|
-
|
|
7
|
-
export type KpiId = 'revenue' | 'orders' | 'customers' | 'conversion'
|
|
8
|
-
|
|
9
|
-
export type Kpi = {
|
|
10
|
-
/** Label comes from `dashboard:kpis.<id>`. */
|
|
11
|
-
id: KpiId
|
|
12
|
-
value: number
|
|
13
|
-
format: 'currency' | 'number' | 'percent'
|
|
14
|
-
/** Change vs previous period, in percent. */
|
|
15
|
-
change: number
|
|
16
|
-
spark: number[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type RevenuePoint = { date: string; revenue: number; orders: number }
|
|
20
|
-
|
|
21
|
-
/** Event kinds map to `dashboard:activity.<kind>` sentences. */
|
|
22
|
-
export type ActivityKind = 'orderPlaced' | 'lowStock' | 'updated' | 'refund' | 'published'
|
|
23
|
-
|
|
24
|
-
export type Activity = {
|
|
25
|
-
id: string
|
|
26
|
-
/** A person's name, or `system` / `payments` for automated events. */
|
|
27
|
-
actor: string
|
|
28
|
-
kind: ActivityKind
|
|
29
|
-
target: string
|
|
30
|
-
/** Order amount; formatted in the active locale and appended to the target. */
|
|
31
|
-
amount?: number
|
|
32
|
-
at: string
|
|
33
|
-
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface DashboardService {
|
|
37
|
-
kpis(range: Range): Promise<Kpi[]>
|
|
38
|
-
revenue(range: Range): Promise<RevenuePoint[]>
|
|
39
|
-
activity(): Promise<Activity[]>
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export const rangeDays: Record<Range, number> = { '7d': 7, '30d': 30, '90d': 90 }
|
|
43
|
-
|
|
44
|
-
function series(n: number, seed: number) {
|
|
45
|
-
const rand = seededRandom(seed)
|
|
46
|
-
let v = 3200
|
|
47
|
-
return Array.from({ length: n }, (_, i) => {
|
|
48
|
-
v = Math.max(1200, v + (rand() - 0.42) * 600)
|
|
49
|
-
const date = new Date(Date.now() - (n - 1 - i) * 86_400_000)
|
|
50
|
-
return { date: date.toISOString().slice(0, 10), revenue: Math.round(v), orders: Math.round(v / (38 + rand() * 12)) }
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const mockDashboardService: DashboardService = {
|
|
55
|
-
async kpis(range) {
|
|
56
|
-
await delay()
|
|
57
|
-
const pts = series(rangeDays[range], 7)
|
|
58
|
-
const revenue = pts.reduce((s, p) => s + p.revenue, 0)
|
|
59
|
-
const orders = pts.reduce((s, p) => s + p.orders, 0)
|
|
60
|
-
const scale = rangeDays[range] / 30
|
|
61
|
-
return [
|
|
62
|
-
{ id: 'revenue', value: revenue, format: 'currency', change: 12.4, spark: pts.map((p) => p.revenue) },
|
|
63
|
-
{ id: 'orders', value: orders, format: 'number', change: 8.1, spark: pts.map((p) => p.orders) },
|
|
64
|
-
{ id: 'customers', value: Math.round(412 * scale), format: 'number', change: -2.3, spark: pts.map((p) => p.orders * 0.4) },
|
|
65
|
-
{ id: 'conversion', value: 3.42, format: 'percent', change: 0.6, spark: pts.map((p) => p.revenue / p.orders) },
|
|
66
|
-
]
|
|
67
|
-
},
|
|
68
|
-
async revenue(range) {
|
|
69
|
-
await delay()
|
|
70
|
-
return series(rangeDays[range], 7)
|
|
71
|
-
},
|
|
72
|
-
async activity() {
|
|
73
|
-
await delay()
|
|
74
|
-
const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString()
|
|
75
|
-
return [
|
|
76
|
-
{ id: 'a1', actor: 'Maya Chen', kind: 'orderPlaced', target: '#10482', amount: 284, at: ago(4), tone: 'success' },
|
|
77
|
-
{ id: 'a2', actor: 'system', kind: 'lowStock', target: 'Halo Pendant', at: ago(26), tone: 'warning' },
|
|
78
|
-
{ id: 'a3', actor: 'Jonas Weber', kind: 'updated', target: 'Oak Side Table', at: ago(58), tone: 'info' },
|
|
79
|
-
{ id: 'a4', actor: 'payments', kind: 'refund', target: '#10467', amount: 79, at: ago(140), tone: 'danger' },
|
|
80
|
-
{ id: 'a5', actor: 'Ana Lopez', kind: 'published', target: 'Linen Cushion Natural', at: ago(310), tone: 'neutral' },
|
|
81
|
-
{ id: 'a6', actor: 'Maya Chen', kind: 'orderPlaced', target: '#10461', amount: 1120, at: ago(620), tone: 'success' },
|
|
82
|
-
]
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const dashboardService: DashboardService = mockDashboardService
|
|
87
|
-
|
|
88
|
-
export const useKpis = (range: Range) =>
|
|
89
|
-
useQuery({ queryKey: ['dashboard', 'kpis', range], queryFn: () => dashboardService.kpis(range) })
|
|
90
|
-
export const useRevenue = (range: Range) =>
|
|
91
|
-
useQuery({ queryKey: ['dashboard', 'revenue', range], queryFn: () => dashboardService.revenue(range) })
|
|
92
|
-
export const useActivity = () => useQuery({ queryKey: ['dashboard', 'activity'], queryFn: () => dashboardService.activity() })
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
2
|
-
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import { ArrowRight, Boxes, Gauge, Palette } from 'lucide-react'
|
|
4
|
-
|
|
5
|
-
import { appConfig } from '@/app.config'
|
|
6
|
-
import { useSession } from '@/core/auth/use-auth'
|
|
7
|
-
import { ButtonLink } from '@/core/components/button-link'
|
|
8
|
-
import { LanguageSwitcher } from '@/core/i18n/language-switcher'
|
|
9
|
-
import { ThemeToggle } from '@/core/theme/theme-toggle'
|
|
10
|
-
|
|
11
|
-
const YEAR = new Date().getFullYear()
|
|
12
|
-
|
|
13
|
-
const features = [
|
|
14
|
-
{ id: 'modular', icon: Boxes },
|
|
15
|
-
{ id: 'tokens', icon: Palette },
|
|
16
|
-
{ id: 'api', icon: Gauge },
|
|
17
|
-
] as const
|
|
18
|
-
|
|
19
|
-
export default function LandingPage() {
|
|
20
|
-
const session = useSession()
|
|
21
|
-
const { t } = useTranslation(['landing', 'common'])
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
document.title = appConfig.name
|
|
25
|
-
}, [])
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div className="flex min-h-svh flex-col bg-canvas">
|
|
29
|
-
<header className="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-6">
|
|
30
|
-
<span className="flex items-center gap-2 text-[15px] font-semibold tracking-tight">
|
|
31
|
-
<span className="flex size-7 items-center justify-center rounded-lg bg-foreground text-xs text-background">
|
|
32
|
-
{appConfig.name[0]?.toUpperCase()}
|
|
33
|
-
</span>
|
|
34
|
-
{appConfig.name}
|
|
35
|
-
</span>
|
|
36
|
-
<div className="flex items-center gap-1.5">
|
|
37
|
-
<LanguageSwitcher />
|
|
38
|
-
<ThemeToggle />
|
|
39
|
-
<ButtonLink to={session ? '/app' : '/login'} variant="outline" size="sm">
|
|
40
|
-
{session ? t('openApp') : t('signIn')}
|
|
41
|
-
</ButtonLink>
|
|
42
|
-
</div>
|
|
43
|
-
</header>
|
|
44
|
-
|
|
45
|
-
<main className="flex flex-1 flex-col items-center justify-center px-6 py-20 text-center">
|
|
46
|
-
<span className="label-caps mb-6 inline-flex items-center gap-2 rounded-full border bg-raised px-3 py-1 shadow-(--shadow-raised)">
|
|
47
|
-
<span className="size-1.5 rounded-full bg-primary" />
|
|
48
|
-
{t('common:app.tagline')}
|
|
49
|
-
</span>
|
|
50
|
-
<h1 className="max-w-2xl text-5xl font-semibold tracking-tight text-balance sm:text-6xl">
|
|
51
|
-
{t('headline')}
|
|
52
|
-
<br />
|
|
53
|
-
<span className="text-muted-foreground">{t('headlineMuted')}</span>
|
|
54
|
-
</h1>
|
|
55
|
-
<p className="mt-5 max-w-lg text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">{t('lead')}</p>
|
|
56
|
-
<div className="mt-9 flex items-center gap-3">
|
|
57
|
-
<ButtonLink to={session ? '/app' : '/login'} size="xl" className="rounded-full px-5">
|
|
58
|
-
{session ? t('openDashboard') : t('signIn')}
|
|
59
|
-
<ArrowRight data-icon="inline-end" />
|
|
60
|
-
</ButtonLink>
|
|
61
|
-
</div>
|
|
62
|
-
|
|
63
|
-
<ul className="mt-20 grid w-full max-w-4xl gap-4 text-left sm:grid-cols-3">
|
|
64
|
-
{features.map(({ id, icon: Icon }) => (
|
|
65
|
-
<li key={id} className="rounded-xl border bg-card p-5 shadow-(--shadow-card)">
|
|
66
|
-
<Icon className="size-5 text-primary" strokeWidth={1.75} />
|
|
67
|
-
<h2 className="mt-4 text-sm font-medium">{t(`features.${id}.title`)}</h2>
|
|
68
|
-
<p className="mt-1 text-sm text-muted-foreground">{t(`features.${id}.text`)}</p>
|
|
69
|
-
</li>
|
|
70
|
-
))}
|
|
71
|
-
</ul>
|
|
72
|
-
</main>
|
|
73
|
-
|
|
74
|
-
<footer className="mx-auto w-full max-w-5xl px-6 py-8 tabular-nums text-[11px] text-muted-foreground">
|
|
75
|
-
{t('copyright', { year: YEAR, name: appConfig.name })}
|
|
76
|
-
</footer>
|
|
77
|
-
</div>
|
|
78
|
-
)
|
|
79
|
-
}
|