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,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
- }