create-freakpunch 0.0.0-stage → 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 (216) hide show
  1. package/README.md +24 -3
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +19 -0
  4. package/files/app.config.ts +50 -0
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -6
  8. package/template/.dockerignore +17 -0
  9. package/template/.oxlintrc.json +9 -0
  10. package/template/AGENTS.md +6 -0
  11. package/template/BACKEND.md +108 -0
  12. package/template/CLAUDE.md +297 -0
  13. package/template/Dockerfile +19 -0
  14. package/template/_gitignore +26 -0
  15. package/template/components.json +27 -0
  16. package/template/design/COMPONENTS.md +145 -0
  17. package/template/design/DESIGN.md +212 -0
  18. package/template/design/PATTERNS.md +110 -0
  19. package/template/index.html +25 -0
  20. package/template/nginx.conf +32 -0
  21. package/template/package-lock.json +6484 -0
  22. package/template/package.json +51 -0
  23. package/template/public/favicon.svg +1 -0
  24. package/template/public/robots.txt +2 -0
  25. package/template/src/app.config.ts +50 -0
  26. package/template/src/app.roles.ts +24 -0
  27. package/template/src/components/ui/alert-dialog.tsx +181 -0
  28. package/template/src/components/ui/avatar.tsx +116 -0
  29. package/template/src/components/ui/badge.tsx +57 -0
  30. package/template/src/components/ui/breadcrumb.tsx +130 -0
  31. package/template/src/components/ui/button.tsx +58 -0
  32. package/template/src/components/ui/calendar.tsx +277 -0
  33. package/template/src/components/ui/card.tsx +99 -0
  34. package/template/src/components/ui/chart.tsx +370 -0
  35. package/template/src/components/ui/checkbox.tsx +28 -0
  36. package/template/src/components/ui/combobox.tsx +329 -0
  37. package/template/src/components/ui/command.tsx +191 -0
  38. package/template/src/components/ui/dialog.tsx +162 -0
  39. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  40. package/template/src/components/ui/empty.tsx +103 -0
  41. package/template/src/components/ui/field.tsx +236 -0
  42. package/template/src/components/ui/input-group.tsx +263 -0
  43. package/template/src/components/ui/input.tsx +45 -0
  44. package/template/src/components/ui/kbd.tsx +31 -0
  45. package/template/src/components/ui/label.tsx +17 -0
  46. package/template/src/components/ui/pagination.tsx +134 -0
  47. package/template/src/components/ui/popover.tsx +87 -0
  48. package/template/src/components/ui/radio-group.tsx +35 -0
  49. package/template/src/components/ui/select.tsx +204 -0
  50. package/template/src/components/ui/separator.tsx +25 -0
  51. package/template/src/components/ui/sheet.tsx +139 -0
  52. package/template/src/components/ui/sidebar.tsx +741 -0
  53. package/template/src/components/ui/skeleton.tsx +13 -0
  54. package/template/src/components/ui/sonner.tsx +78 -0
  55. package/template/src/components/ui/spinner.tsx +42 -0
  56. package/template/src/components/ui/switch.tsx +29 -0
  57. package/template/src/components/ui/table.tsx +121 -0
  58. package/template/src/components/ui/tabs.tsx +191 -0
  59. package/template/src/components/ui/textarea.tsx +17 -0
  60. package/template/src/components/ui/toggle-group.tsx +94 -0
  61. package/template/src/components/ui/toggle.tsx +48 -0
  62. package/template/src/components/ui/tooltip.tsx +91 -0
  63. package/template/src/core/api/demo.ts +8 -0
  64. package/template/src/core/api/http.ts +76 -0
  65. package/template/src/core/api/mock.ts +88 -0
  66. package/template/src/core/api/rest.ts +67 -0
  67. package/template/src/core/api/types.ts +21 -0
  68. package/template/src/core/audit/audit.service.ts +94 -0
  69. package/template/src/core/audit/hooks.ts +19 -0
  70. package/template/src/core/auth/auth-store.ts +52 -0
  71. package/template/src/core/auth/auth.service.ts +86 -0
  72. package/template/src/core/auth/can.tsx +39 -0
  73. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  74. package/template/src/core/auth/local-credentials.ts +46 -0
  75. package/template/src/core/auth/permissions.ts +115 -0
  76. package/template/src/core/auth/require-auth.tsx +16 -0
  77. package/template/src/core/auth/require-permission.tsx +24 -0
  78. package/template/src/core/auth/roles.ts +9 -0
  79. package/template/src/core/auth/types.ts +20 -0
  80. package/template/src/core/auth/use-auth.ts +19 -0
  81. package/template/src/core/company/company.service.ts +94 -0
  82. package/template/src/core/company/hooks.ts +40 -0
  83. package/template/src/core/company/password-policy.ts +40 -0
  84. package/template/src/core/company/types.ts +46 -0
  85. package/template/src/core/company/use-app-name.ts +7 -0
  86. package/template/src/core/components/button-link.tsx +14 -0
  87. package/template/src/core/components/confirm-dialog.tsx +116 -0
  88. package/template/src/core/components/data-table/data-table.tsx +297 -0
  89. package/template/src/core/components/data-table/features.ts +24 -0
  90. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  91. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  92. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  93. package/template/src/core/components/date-picker.tsx +71 -0
  94. package/template/src/core/components/file-attachments.tsx +84 -0
  95. package/template/src/core/components/form-error.tsx +9 -0
  96. package/template/src/core/components/form-page.tsx +93 -0
  97. package/template/src/core/components/image-upload.tsx +86 -0
  98. package/template/src/core/components/page.tsx +75 -0
  99. package/template/src/core/components/phone-input.tsx +58 -0
  100. package/template/src/core/components/search-select.tsx +57 -0
  101. package/template/src/core/components/segmented.tsx +41 -0
  102. package/template/src/core/components/states.tsx +97 -0
  103. package/template/src/core/components/status-badge.tsx +26 -0
  104. package/template/src/core/components/user-avatar.tsx +23 -0
  105. package/template/src/core/config/runtime.ts +16 -0
  106. package/template/src/core/config/types.ts +37 -0
  107. package/template/src/core/i18n/i18n.ts +69 -0
  108. package/template/src/core/i18n/i18next.d.ts +12 -0
  109. package/template/src/core/i18n/keys.ts +37 -0
  110. package/template/src/core/i18n/language-switcher.tsx +45 -0
  111. package/template/src/core/i18n/types.ts +17 -0
  112. package/template/src/core/i18n/use-format.ts +31 -0
  113. package/template/src/core/layout/app-shell.tsx +50 -0
  114. package/template/src/core/layout/app-sidebar.tsx +285 -0
  115. package/template/src/core/layout/command-menu.tsx +117 -0
  116. package/template/src/core/layout/top-bar.tsx +100 -0
  117. package/template/src/core/lib/csv.ts +27 -0
  118. package/template/src/core/lib/format.ts +87 -0
  119. package/template/src/core/lib/phone.ts +50 -0
  120. package/template/src/core/lib/storage.ts +27 -0
  121. package/template/src/core/modules/lazy-page.ts +9 -0
  122. package/template/src/core/modules/navigation.ts +39 -0
  123. package/template/src/core/modules/types.ts +55 -0
  124. package/template/src/core/notifications/hooks.ts +84 -0
  125. package/template/src/core/notifications/notification-center.tsx +251 -0
  126. package/template/src/core/notifications/notifications.service.ts +132 -0
  127. package/template/src/core/notifications/types.ts +70 -0
  128. package/template/src/core/preferences/preferences-store.ts +70 -0
  129. package/template/src/core/setup/setup-gate.tsx +20 -0
  130. package/template/src/core/setup/setup.service.ts +101 -0
  131. package/template/src/core/theme/brand-context.tsx +53 -0
  132. package/template/src/core/theme/brand.ts +29 -0
  133. package/template/src/core/theme/theme-context.tsx +62 -0
  134. package/template/src/core/theme/theme-options.ts +8 -0
  135. package/template/src/core/theme/theme-toggle.tsx +45 -0
  136. package/template/src/core/users/hooks.ts +51 -0
  137. package/template/src/core/users/types.ts +37 -0
  138. package/template/src/core/users/users.service.ts +167 -0
  139. package/template/src/hooks/use-mobile.ts +19 -0
  140. package/template/src/index.css +321 -0
  141. package/template/src/lib/utils.ts +1 -0
  142. package/template/src/locales/en/account.ts +180 -0
  143. package/template/src/locales/en/auth.ts +18 -0
  144. package/template/src/locales/en/common.ts +151 -0
  145. package/template/src/locales/en/company.ts +380 -0
  146. package/template/src/locales/en/dashboard.ts +46 -0
  147. package/template/src/locales/en/errors.ts +18 -0
  148. package/template/src/locales/en/notifications.ts +43 -0
  149. package/template/src/locales/en/settings.ts +25 -0
  150. package/template/src/locales/en/setup.ts +45 -0
  151. package/template/src/locales/index.ts +58 -0
  152. package/template/src/locales/tr/account.ts +184 -0
  153. package/template/src/locales/tr/auth.ts +22 -0
  154. package/template/src/locales/tr/common.ts +154 -0
  155. package/template/src/locales/tr/company.ts +384 -0
  156. package/template/src/locales/tr/dashboard.ts +50 -0
  157. package/template/src/locales/tr/errors.ts +22 -0
  158. package/template/src/locales/tr/notifications.ts +47 -0
  159. package/template/src/locales/tr/settings.ts +29 -0
  160. package/template/src/locales/tr/setup.ts +49 -0
  161. package/template/src/main.tsx +16 -0
  162. package/template/src/modules/locales.ts +10 -0
  163. package/template/src/modules/registry.ts +17 -0
  164. package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
  165. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  166. package/template/src/pages/login-page.tsx +152 -0
  167. package/template/src/pages/no-access-page.tsx +11 -0
  168. package/template/src/pages/not-found-page.tsx +61 -0
  169. package/template/src/pages/settings/account/account.service.ts +128 -0
  170. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  171. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  172. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  173. package/template/src/pages/settings/account/hooks.ts +73 -0
  174. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  175. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  176. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  177. package/template/src/pages/settings/account/security-page.tsx +364 -0
  178. package/template/src/pages/settings/account/types.ts +37 -0
  179. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  180. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  181. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  182. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  183. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  184. package/template/src/pages/settings/company/general-page.tsx +234 -0
  185. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  186. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  187. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  188. package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
  189. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  190. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  191. package/template/src/pages/settings/company/modules-page.tsx +65 -0
  192. package/template/src/pages/settings/company/roles-page.tsx +116 -0
  193. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  194. package/template/src/pages/settings/company/users-page.tsx +262 -0
  195. package/template/src/pages/settings/settings-layout.tsx +57 -0
  196. package/template/src/pages/settings/settings-nav.ts +69 -0
  197. package/template/src/pages/settings/settings-routes.tsx +49 -0
  198. package/template/src/pages/setup-page.tsx +387 -0
  199. package/template/src/providers.tsx +68 -0
  200. package/template/src/router.tsx +59 -0
  201. package/template/tools/check-design.mjs +73 -0
  202. package/template/tools/new-module.mjs +90 -0
  203. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  204. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  205. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  206. package/template/tools/templates/module/index.ts.tpl +44 -0
  207. package/template/tools/templates/module/locales/en.ts.tpl +51 -0
  208. package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
  209. package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
  210. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  211. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
  212. package/template/tools/templates/module/types.ts.tpl +32 -0
  213. package/template/tsconfig.app.json +32 -0
  214. package/template/tsconfig.json +12 -0
  215. package/template/tsconfig.node.json +23 -0
  216. package/template/vite.config.ts +47 -0
@@ -0,0 +1,251 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Link } from 'react-router'
4
+ import { Bell, BellOff, CheckCheck, CircleAlert, CircleCheck, Info, Settings, TriangleAlert, type LucideIcon } from 'lucide-react'
5
+
6
+ import { Button } from '@/components/ui/button'
7
+ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@/components/ui/popover'
8
+ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
9
+ import { Skeleton } from '@/components/ui/skeleton'
10
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
11
+ import { ButtonLink } from '@/core/components/button-link'
12
+ import { EmptyState, ErrorState } from '@/core/components/states'
13
+ import { useFormat } from '@/core/i18n/use-format'
14
+ import type { Formatters } from '@/core/lib/format'
15
+ import { useIsMobile } from '@/hooks/use-mobile'
16
+ import { cn } from '@/lib/utils'
17
+ import { isUnread, useMarkAllNotificationsRead, useMarkNotificationRead, useNotifications } from './hooks'
18
+ import type { AppNotification, NotificationType } from './types'
19
+
20
+ const typeIcons: Record<NotificationType, { icon: LucideIcon; className: string }> = {
21
+ info: { icon: Info, className: 'text-info' },
22
+ success: { icon: CircleCheck, className: 'text-success' },
23
+ warning: { icon: TriangleAlert, className: 'text-warning' },
24
+ error: { icon: CircleAlert, className: 'text-danger' },
25
+ }
26
+
27
+ type Tab = 'all' | 'unread'
28
+
29
+ /**
30
+ * Top-bar bell with an unread badge. Opens a popover on desktop and a bottom
31
+ * sheet on mobile; both render the same `NotificationPanel`.
32
+ */
33
+ export function NotificationBell() {
34
+ const { t } = useTranslation('notifications')
35
+ const isMobile = useIsMobile()
36
+ const [open, setOpen] = useState(false)
37
+ const unread = useNotifications().data?.filter(isUnread).length ?? 0
38
+
39
+ const trigger = (
40
+ <Button
41
+ variant="ghost"
42
+ size="icon-sm"
43
+ aria-label={unread > 0 ? t('bellUnread', { count: unread }) : t('bell')}
44
+ className="relative"
45
+ >
46
+ <Bell />
47
+ {unread > 0 && (
48
+ <span
49
+ aria-hidden
50
+ className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-semibold text-primary-foreground tabular-nums ring-2 ring-background"
51
+ >
52
+ {unread > 9 ? '9+' : unread}
53
+ </span>
54
+ )}
55
+ </Button>
56
+ )
57
+ const close = () => setOpen(false)
58
+
59
+ if (isMobile) {
60
+ return (
61
+ <Sheet open={open} onOpenChange={setOpen}>
62
+ <SheetTrigger render={trigger} />
63
+ <SheetContent
64
+ side="bottom"
65
+ showCloseButton={false}
66
+ className="max-h-[85svh] gap-0 rounded-t-(--radius-panel)"
67
+ >
68
+ <NotificationPanel title={<SheetTitle className="text-sm font-semibold">{t('title')}</SheetTitle>} onDone={close} />
69
+ </SheetContent>
70
+ </Sheet>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <Popover open={open} onOpenChange={setOpen}>
76
+ <PopoverTrigger render={trigger} />
77
+ <PopoverContent align="end" sideOffset={8} className="w-96 gap-0 overflow-hidden p-0">
78
+ <NotificationPanel title={<PopoverTitle className="text-sm font-semibold">{t('title')}</PopoverTitle>} onDone={close} />
79
+ </PopoverContent>
80
+ </Popover>
81
+ )
82
+ }
83
+
84
+ function NotificationPanel({ title, onDone }: { title: ReactNode; onDone: () => void }) {
85
+ const { t } = useTranslation('notifications')
86
+ const [tab, setTab] = useState<Tab>('all')
87
+ const query = useNotifications()
88
+ const markAll = useMarkAllNotificationsRead()
89
+ const unread = query.data?.filter(isUnread).length ?? 0
90
+
91
+ return (
92
+ <div className="flex min-h-0 flex-1 flex-col">
93
+ <div className="flex items-center justify-between gap-2 px-4 pt-3 pb-2">
94
+ {title}
95
+ <Button
96
+ variant="ghost"
97
+ size="sm"
98
+ className="-mr-2 text-muted-foreground"
99
+ disabled={unread === 0}
100
+ onClick={() => markAll.mutate()}
101
+ >
102
+ <CheckCheck data-icon="inline-start" />
103
+ {t('markAllRead')}
104
+ </Button>
105
+ </div>
106
+ <div className="border-b px-4 pb-3">
107
+ <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
108
+ <TabsList className="w-full">
109
+ <TabsTrigger value="all" className="flex-1">{t('tabs.all')}</TabsTrigger>
110
+ <TabsTrigger value="unread" className="flex-1">
111
+ {t('tabs.unread')}
112
+ {unread > 0 && <span className="text-muted-foreground tabular-nums">{unread}</span>}
113
+ </TabsTrigger>
114
+ </TabsList>
115
+ </Tabs>
116
+ </div>
117
+
118
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:max-h-[min(28rem,60svh)]">
119
+ {query.isPending ? (
120
+ <NotificationSkeleton />
121
+ ) : query.error ? (
122
+ <ErrorState title={t('loadFailed')} error={query.error} onRetry={() => query.refetch()} className="py-10" />
123
+ ) : (
124
+ <NotificationList items={tab === 'unread' ? query.data.filter(isUnread) : query.data} tab={tab} onDone={onDone} />
125
+ )}
126
+ </div>
127
+
128
+ <div className="border-t p-1.5">
129
+ <ButtonLink to="/app/settings/notifications" variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={onDone}>
130
+ <Settings data-icon="inline-start" />
131
+ {t('settings')}
132
+ </ButtonLink>
133
+ </div>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ function NotificationList({ items, tab, onDone }: { items: AppNotification[]; tab: Tab; onDone: () => void }) {
139
+ const { t } = useTranslation('notifications')
140
+ const fmt = useFormat()
141
+ // The panel mounts when opened, so "today" is fresh each time it opens.
142
+ const [now] = useState(() => new Date())
143
+
144
+ if (items.length === 0) {
145
+ return (
146
+ <EmptyState
147
+ icon={tab === 'unread' ? <CheckCheck /> : <BellOff />}
148
+ title={t(`empty.${tab}.title`)}
149
+ description={t(`empty.${tab}.description`)}
150
+ className="py-10"
151
+ />
152
+ )
153
+ }
154
+
155
+ const today = fmt.date(now)
156
+ const groups = [
157
+ { id: 'today', items: items.filter((n) => fmt.date(n.createdAt) === today) },
158
+ { id: 'earlier', items: items.filter((n) => fmt.date(n.createdAt) !== today) },
159
+ ] as const
160
+
161
+ return groups.map(
162
+ (group) =>
163
+ group.items.length > 0 && (
164
+ <section key={group.id} aria-labelledby={`notifications-${group.id}`} className="pb-1">
165
+ <h3 id={`notifications-${group.id}`} className="label-caps px-4 pt-3 pb-1">
166
+ {t(`groups.${group.id}`)}
167
+ </h3>
168
+ <ul className="px-1.5">
169
+ {group.items.map((n) => (
170
+ <li key={n.id}>
171
+ <NotificationItem notification={n} fmt={fmt} onDone={onDone} />
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ </section>
176
+ ),
177
+ )
178
+ }
179
+
180
+ function NotificationItem({ notification: n, fmt, onDone }: { notification: AppNotification; fmt: Formatters; onDone: () => void }) {
181
+ const { t } = useTranslation(['notifications', 'common'])
182
+ const markRead = useMarkNotificationRead()
183
+ const unread = isUnread(n)
184
+ const { icon: Icon, className: iconClass } = typeIcons[n.type]
185
+
186
+ // Params are raw values from the API; localize the ones that need it.
187
+ const values: Record<string, string | number> = { ...n.params }
188
+ if (typeof n.params.role === 'string') values.role = t(`common:roles.${n.params.role as 'owner'}`)
189
+ if (typeof n.params.revenue === 'number') values.revenue = fmt.currencyWhole(n.params.revenue)
190
+ if (typeof n.params.orders === 'number') values.orders = fmt.number(n.params.orders)
191
+
192
+ const onClick = () => {
193
+ if (unread) markRead.mutate(n.id)
194
+ if (n.link) onDone()
195
+ }
196
+
197
+ const content = (
198
+ <>
199
+ <span className={cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-fill-1', iconClass)}>
200
+ <Icon className="size-4" />
201
+ <span className="sr-only">{t(`types.${n.type}`)}</span>
202
+ </span>
203
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
204
+ <span className={cn('text-sm', unread ? 'font-medium text-foreground' : 'text-foreground/80')}>
205
+ {t(`templates.${n.template}.title`)}
206
+ </span>
207
+ <span className="line-clamp-2 text-xs text-muted-foreground">{t(`templates.${n.template}.body`, values)}</span>
208
+ <time dateTime={n.createdAt} title={fmt.dateTime(n.createdAt)} className="text-2xs text-muted-foreground tabular-nums">
209
+ {fmt.relative(n.createdAt)}
210
+ </time>
211
+ </span>
212
+ <span className="flex w-2 shrink-0 justify-center pt-2">
213
+ {unread && (
214
+ <>
215
+ <span aria-hidden className="size-2 rounded-full bg-primary" />
216
+ <span className="sr-only">{t('unread')}</span>
217
+ </>
218
+ )}
219
+ </span>
220
+ </>
221
+ )
222
+
223
+ const className =
224
+ 'flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left outline-none hover:bg-fill-1 focus-visible:bg-fill-1 focus-visible:ring-2 focus-visible:ring-ring'
225
+
226
+ return n.link ? (
227
+ <Link to={n.link} onClick={onClick} className={className}>
228
+ {content}
229
+ </Link>
230
+ ) : (
231
+ <button type="button" onClick={onClick} className={className}>
232
+ {content}
233
+ </button>
234
+ )
235
+ }
236
+
237
+ function NotificationSkeleton() {
238
+ return (
239
+ <div role="status" className="flex flex-col gap-1 p-3">
240
+ {Array.from({ length: 4 }, (_, i) => (
241
+ <div key={i} className="flex items-start gap-3 px-1 py-2">
242
+ <Skeleton className="size-8 rounded-lg" />
243
+ <div className="flex flex-1 flex-col gap-1.5">
244
+ <Skeleton className="h-3.5 w-2/5" />
245
+ <Skeleton className="h-3 w-4/5" />
246
+ </div>
247
+ </div>
248
+ ))}
249
+ </div>
250
+ )
251
+ }
@@ -0,0 +1,132 @@
1
+ import { ApiError } from '@/core/api/http'
2
+ import { demoSeed } from '@/core/api/demo'
3
+ import { createMockDocument, delay } from '@/core/api/mock'
4
+ import { authStore } from '@/core/auth/auth-store'
5
+ import type { Role } from '@/core/auth/roles'
6
+ import {
7
+ defaultNotificationSettings,
8
+ type AppNotification,
9
+ type NotificationSettings,
10
+ } from './types'
11
+
12
+ /**
13
+ * The signed-in user's notifications and notification settings. A real
14
+ * backend implements this with `http`, e.g.
15
+ *
16
+ * list: GET /me/notifications (server drops events the user turned off)
17
+ * markRead: POST /me/notifications/{id}/read
18
+ * markAllRead: POST /me/notifications/read-all
19
+ * settings: GET /me/notification-settings
20
+ * updateSettings: PUT /me/notification-settings
21
+ *
22
+ * and may add `subscribe` for push (SSE / WebSocket): the hooks then refetch
23
+ * on every pushed event instead of relying on polling alone.
24
+ */
25
+ export interface NotificationsService {
26
+ list(): Promise<AppNotification[]>
27
+ markRead(id: string): Promise<void>
28
+ markAllRead(): Promise<void>
29
+ settings(): Promise<NotificationSettings>
30
+ updateSettings(settings: NotificationSettings): Promise<NotificationSettings>
31
+ /** Optional push channel. Calls `onChange` when the feed changes; returns an unsubscribe. */
32
+ subscribe?(onChange: () => void): () => void
33
+ }
34
+
35
+ type NotificationsDoc = {
36
+ items: AppNotification[]
37
+ settings: NotificationSettings
38
+ /** When the mock last "received" a notification; drives the occasional new one. */
39
+ lastArrivalAt: number
40
+ }
41
+
42
+ export type Draft = Omit<AppNotification, 'id' | 'createdAt' | 'readAt'>
43
+
44
+ const minute = 60_000
45
+
46
+ /** Sample feed; without it the list starts empty and only a real backend adds to it. */
47
+ const demo = demoSeed<{
48
+ seedFor(role: Role): [Draft, number, boolean][]
49
+ arrivalsFor(role: Role): (() => Draft)[]
50
+ }>('notifications')
51
+ function createMockNotificationsService(): NotificationsService {
52
+ const docs = new Map<string, ReturnType<typeof createMockDocument<NotificationsDoc>>>()
53
+
54
+ const current = () => {
55
+ const user = authStore.getSession()?.user
56
+ if (!user) throw new ApiError(401, 'unauthorized')
57
+ if (!docs.has(user.id)) {
58
+ docs.set(
59
+ user.id,
60
+ createMockDocument(`notifications:${user.id}`, () => {
61
+ const now = Date.now()
62
+ const items = (demo?.seedFor(user.role) ?? []).map(([draft, ago, read], i): AppNotification => {
63
+ const createdAt = new Date(now - ago * minute).toISOString()
64
+ return { ...draft, id: `n_${user.id}_${i + 1}`, createdAt, readAt: read ? createdAt : undefined }
65
+ })
66
+ return { items, settings: defaultNotificationSettings(), lastArrivalAt: now }
67
+ }),
68
+ )
69
+ }
70
+ return { doc: docs.get(user.id)!, role: user.role }
71
+ }
72
+
73
+ const save = (patch: Partial<NotificationsDoc>) => {
74
+ const { doc } = current()
75
+ return doc.save({ ...doc.get(), ...patch })
76
+ }
77
+
78
+ // Settings saved before an event type existed still get its default.
79
+ const settingsOf = (doc: NotificationsDoc): NotificationSettings => ({ ...defaultNotificationSettings(), ...doc.settings })
80
+
81
+ /** Every ~minute or two a new notification "arrives", so polling has something to pick up. */
82
+ function maybeArrive() {
83
+ const { doc, role } = current()
84
+ const data = doc.get()
85
+ if (Date.now() - data.lastArrivalAt < 45_000 || Math.random() > 0.4) return
86
+ const settings = settingsOf(data)
87
+ const pool = (demo?.arrivalsFor(role) ?? [])
88
+ .map((make) => make())
89
+ .filter((d) => settings[d.event].inApp)
90
+ const draft = pool[Math.floor(Math.random() * pool.length)]
91
+ const now = Date.now()
92
+ if (!draft) return save({ lastArrivalAt: now })
93
+ const item: AppNotification = { ...draft, id: `n_${now.toString(36)}`, createdAt: new Date(now).toISOString() }
94
+ save({ items: [item, ...data.items].slice(0, 50), lastArrivalAt: now })
95
+ }
96
+
97
+ return {
98
+ async list() {
99
+ await delay(150, 300)
100
+ maybeArrive()
101
+ const data = current().doc.get()
102
+ const settings = settingsOf(data)
103
+ return data.items
104
+ .filter((n) => settings[n.event]?.inApp !== false)
105
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
106
+ },
107
+
108
+ async markRead(id) {
109
+ await delay(100, 200)
110
+ const now = new Date().toISOString()
111
+ save({ items: current().doc.get().items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: now } : n)) })
112
+ },
113
+
114
+ async markAllRead() {
115
+ await delay(150, 300)
116
+ const now = new Date().toISOString()
117
+ save({ items: current().doc.get().items.map((n) => (n.readAt ? n : { ...n, readAt: now })) })
118
+ },
119
+
120
+ async settings() {
121
+ await delay(150, 300)
122
+ return settingsOf(current().doc.get())
123
+ },
124
+
125
+ async updateSettings(settings) {
126
+ await delay()
127
+ return save({ settings }).settings
128
+ },
129
+ }
130
+ }
131
+
132
+ export const notificationsService: NotificationsService = createMockNotificationsService()
@@ -0,0 +1,70 @@
1
+ import { isDemo } from '@/core/config/runtime'
2
+
3
+ /**
4
+ * Notification event types. One catalog drives both the Settings → Notifications
5
+ * toggles (email / in-app per event) and the in-app notification feed: a
6
+ * notification always belongs to one event, so turning the event off hides it.
7
+ */
8
+ export const notificationEvents = [
9
+ { id: 'newSignIn', group: 'account' },
10
+ { id: 'passwordChanged', group: 'account' },
11
+ { id: 'userJoined', group: 'team' },
12
+ { id: 'roleChanged', group: 'team' },
13
+ { id: 'settingsChanged', group: 'company' },
14
+ { id: 'lowStock', group: 'catalog' },
15
+ { id: 'productChanged', group: 'catalog' },
16
+ { id: 'weeklySummary', group: 'updates' },
17
+ { id: 'announcements', group: 'updates' },
18
+ ] as const
19
+
20
+ /** What Settings → Notifications lists; catalog events belong to the demo's sample module. */
21
+ export const visibleNotificationEvents = notificationEvents.filter((e) => isDemo || e.group !== 'catalog')
22
+
23
+ export type NotificationEventId = (typeof notificationEvents)[number]['id']
24
+ export type NotificationGroup = (typeof notificationEvents)[number]['group']
25
+ export type NotificationChannels = { email: boolean; inApp: boolean }
26
+ export type NotificationSettings = Record<NotificationEventId, NotificationChannels>
27
+
28
+ export function defaultNotificationSettings(): NotificationSettings {
29
+ return Object.fromEntries(
30
+ notificationEvents.map((e) => [e.id, { email: e.group === 'account', inApp: e.id !== 'announcements' }]),
31
+ ) as NotificationSettings
32
+ }
33
+
34
+ /** Visual tone of a notification: picks the icon and its semantic color. */
35
+ export const notificationTypes = ['info', 'success', 'warning', 'error'] as const
36
+ export type NotificationType = (typeof notificationTypes)[number]
37
+
38
+ /**
39
+ * Message templates. The API sends a template id plus params (never
40
+ * pre-rendered text), so the client renders it in the active language from
41
+ * `notifications:templates.<template>.title|body`.
42
+ */
43
+ export const notificationTemplates = [
44
+ 'userInvited',
45
+ 'userJoined',
46
+ 'roleChangedOther',
47
+ 'roleChangedYou',
48
+ 'lowStock',
49
+ 'productUpdated',
50
+ 'settingsChanged',
51
+ 'brandingChanged',
52
+ 'securityAlert',
53
+ 'passwordChanged',
54
+ 'weeklySummary',
55
+ 'announcement',
56
+ ] as const
57
+ export type NotificationTemplate = (typeof notificationTemplates)[number]
58
+
59
+ export type AppNotification = {
60
+ id: string
61
+ event: NotificationEventId
62
+ type: NotificationType
63
+ template: NotificationTemplate
64
+ /** Interpolation values for the template (names, counts, roles…). */
65
+ params: Record<string, string | number>
66
+ /** In-app path opened when the notification is clicked. */
67
+ link?: string
68
+ createdAt: string
69
+ readAt?: string
70
+ }
@@ -0,0 +1,70 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ import { storage } from '@/core/lib/storage'
4
+
5
+ /**
6
+ * Per-user display preferences (Settings → Preferences). Theme and language
7
+ * have their own stores (theme-context, i18n); these are the rest.
8
+ * Stored in this browser (`fp:preferences`); a backend would keep them on
9
+ * the user record and return them with the session.
10
+ */
11
+ export const dateFormats = ['auto', 'dd.MM.yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd'] as const
12
+ export type DateFormat = (typeof dateFormats)[number]
13
+
14
+ export const sidebarDefaults = ['remember', 'expanded', 'collapsed'] as const
15
+ export type SidebarDefault = (typeof sidebarDefaults)[number]
16
+
17
+ export type Preferences = {
18
+ /** IANA zone or 'auto' (the browser's). */
19
+ timeZone: string
20
+ dateFormat: DateFormat
21
+ sidebarDefault: SidebarDefault
22
+ }
23
+
24
+ const KEY = 'preferences'
25
+ const defaults: Preferences = { timeZone: 'auto', dateFormat: 'auto', sidebarDefault: 'remember' }
26
+ const listeners = new Set<() => void>()
27
+ let current: Preferences = { ...defaults, ...storage.get<Partial<Preferences>>(KEY) }
28
+
29
+ export const preferencesStore = {
30
+ get: () => current,
31
+ set(patch: Partial<Preferences>) {
32
+ current = { ...current, ...patch }
33
+ storage.set(KEY, current)
34
+ for (const l of listeners) l()
35
+ },
36
+ subscribe(listener: () => void) {
37
+ listeners.add(listener)
38
+ return () => listeners.delete(listener)
39
+ },
40
+ }
41
+
42
+ export function usePreferences() {
43
+ return useSyncExternalStore(preferencesStore.subscribe, preferencesStore.get)
44
+ }
45
+
46
+ /** Curated IANA zones for pickers (labels come from Intl). */
47
+ export const timeZones = [
48
+ 'Europe/Istanbul',
49
+ 'Europe/London',
50
+ 'Europe/Berlin',
51
+ 'Europe/Paris',
52
+ 'Europe/Madrid',
53
+ 'Europe/Amsterdam',
54
+ 'Europe/Moscow',
55
+ 'Asia/Dubai',
56
+ 'Asia/Riyadh',
57
+ 'Asia/Kolkata',
58
+ 'Asia/Singapore',
59
+ 'Asia/Shanghai',
60
+ 'Asia/Tokyo',
61
+ 'Australia/Sydney',
62
+ 'America/New_York',
63
+ 'America/Chicago',
64
+ 'America/Denver',
65
+ 'America/Los_Angeles',
66
+ 'America/Sao_Paulo',
67
+ 'UTC',
68
+ ] as const
69
+
70
+ export const browserTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -0,0 +1,20 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Navigate, Outlet, useLocation } from 'react-router'
3
+
4
+ import { setupService } from './setup.service'
5
+
6
+ /** Sends a brand-new install to the setup wizard, and keeps finished installs out of it. */
7
+ export function SetupGate() {
8
+ const { pathname } = useLocation()
9
+ const { data: needsSetup, isPending } = useQuery({
10
+ queryKey: ['setup', 'status'],
11
+ queryFn: () => setupService.needsSetup(),
12
+ staleTime: Infinity,
13
+ retry: false,
14
+ })
15
+
16
+ if (isPending) return <div className="min-h-svh bg-canvas" />
17
+ if (needsSetup && pathname !== '/setup') return <Navigate to="/setup" replace />
18
+ if (!needsSetup && pathname === '/setup') return <Navigate to="/app" replace />
19
+ return <Outlet />
20
+ }
@@ -0,0 +1,101 @@
1
+ import { ApiError, http } from '@/core/api/http'
2
+ import { delay } from '@/core/api/mock'
3
+ import { authStore } from '@/core/auth/auth-store'
4
+ import { fetchCsrfCookie, startLocalSession, type AuthResponse } from '@/core/auth/auth.service'
5
+ import { localCredentials } from '@/core/auth/local-credentials'
6
+ import { createCompanySettings } from '@/core/company/company.service'
7
+ import type { CompanyGeneral, Currency } from '@/core/company/types'
8
+ import { authMode, isDemo } from '@/core/config/runtime'
9
+ import { storage } from '@/core/lib/storage'
10
+ import type { Language } from '@/core/i18n/types'
11
+ import type { BrandSetting } from '@/core/theme/brand'
12
+ import type { User } from '@/core/users/types'
13
+
14
+ export type SetupInput = {
15
+ company: { name: string; language: Language; timeZone: string; currency: Currency }
16
+ admin: { firstName: string; lastName: string; email: string; password: string }
17
+ branding: { brand: BrandSetting; logo?: string }
18
+ }
19
+
20
+ /**
21
+ * First run: create the company and its Owner. With a backend these are
22
+ * GET /setup/status and POST /setup (see BACKEND.md), and the server must
23
+ * refuse POST /setup once an Owner exists.
24
+ */
25
+ export interface SetupService {
26
+ needsSetup(): Promise<boolean>
27
+ complete(input: SetupInput): Promise<void>
28
+ }
29
+
30
+ /** Company name, logo and brand stay in the company service until it is connected to a backend too. */
31
+ function saveCompany({ company, admin, branding }: SetupInput) {
32
+ const general: Partial<CompanyGeneral> = {
33
+ name: company.name,
34
+ contactEmail: admin.email,
35
+ defaultLanguage: company.language,
36
+ timeZone: company.timeZone,
37
+ currency: company.currency,
38
+ }
39
+ storage.set('mock:company', createCompanySettings(general, { brand: branding.brand, logoLight: branding.logo, logoDark: branding.logo }))
40
+ }
41
+
42
+ /** Clears anything left in this browser, such as data from another project on the same address. */
43
+ function clearLocalData() {
44
+ for (const key of Object.keys(localStorage)) {
45
+ if (key.startsWith('fp:mock:')) storage.remove(key.slice(3))
46
+ }
47
+ authStore.clear()
48
+ }
49
+
50
+ const localSetupService: SetupService = {
51
+ async needsSetup() {
52
+ return !localCredentials.any()
53
+ },
54
+
55
+ async complete(input) {
56
+ await delay(300, 500)
57
+ if (localCredentials.any()) throw new ApiError(409, 'already_set_up')
58
+ clearLocalData()
59
+ const { firstName, lastName, email, password } = input.admin
60
+ const now = new Date().toISOString()
61
+ const owner: User = {
62
+ id: 'u_owner',
63
+ firstName,
64
+ lastName,
65
+ email: email.trim().toLowerCase(),
66
+ role: 'owner',
67
+ status: 'active',
68
+ createdAt: now,
69
+ lastActiveAt: now,
70
+ }
71
+ storage.set('mock:users', [owner])
72
+ saveCompany(input)
73
+ await localCredentials.set(owner.id, password)
74
+ startLocalSession(owner)
75
+ },
76
+ }
77
+
78
+ const restSetupService: SetupService = {
79
+ async needsSetup() {
80
+ // A backend that creates its admin another way (seeder, CLI) simply doesn't have this endpoint.
81
+ try {
82
+ return (await http.get<{ needsSetup: boolean }>('/setup/status')).needsSetup
83
+ } catch {
84
+ return false
85
+ }
86
+ },
87
+
88
+ async complete(input) {
89
+ await fetchCsrfCookie()
90
+ const { user, token, expiresAt } = await http.post<AuthResponse>('/setup', input)
91
+ saveCompany(input)
92
+ authStore.set({ user, token, expiresAt: expiresAt ?? Date.now() + 7 * 86_400_000 })
93
+ },
94
+ }
95
+
96
+ const demoSetupService: SetupService = {
97
+ needsSetup: async () => false,
98
+ complete: async () => {},
99
+ }
100
+
101
+ export const setupService: SetupService = isDemo ? demoSetupService : authMode === 'mock' ? localSetupService : restSetupService