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,117 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
2
+ import { useNavigate } from 'react-router'
3
+ import { LogOut } from 'lucide-react'
4
+
5
+ import {
6
+ Command,
7
+ CommandDialog,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ CommandSeparator,
14
+ } from '@/components/ui/command'
15
+ import type { Permission } from '@/core/auth/permissions'
16
+ import { useAuth } from '@/core/auth/use-auth'
17
+ import { useAnyT } from '@/core/i18n/keys'
18
+ import { usePermissions } from '@/core/auth/can'
19
+ import { navItems, quickActions } from '@/core/modules/navigation'
20
+ import { useTheme } from '@/core/theme/theme-context'
21
+ import { themeOptions } from '@/core/theme/theme-options'
22
+
23
+ type CommandMenuContextValue = { open: () => void }
24
+ const CommandMenuContext = createContext<CommandMenuContextValue | null>(null)
25
+
26
+ // eslint-disable-next-line react/only-export-components
27
+ export const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
28
+
29
+ /** ⌘K / Ctrl+K palette: navigation, quick actions, theme, sign out. */
30
+ export function CommandMenuProvider({ children }: { children: ReactNode }) {
31
+ const [isOpen, setOpen] = useState(false)
32
+ const navigate = useNavigate()
33
+ const { setTheme } = useTheme()
34
+ const { logout } = useAuth()
35
+ const t = useAnyT()
36
+ const { can } = usePermissions()
37
+ const allowed = <T extends { permission?: Permission }>(items: T[]) =>
38
+ items.filter((item) => !item.permission || can(item.permission))
39
+
40
+ useEffect(() => {
41
+ const onKeyDown = (e: KeyboardEvent) => {
42
+ if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
43
+ e.preventDefault()
44
+ setOpen((o) => !o)
45
+ }
46
+ }
47
+ window.addEventListener('keydown', onKeyDown)
48
+ return () => window.removeEventListener('keydown', onKeyDown)
49
+ }, [])
50
+
51
+ const run = useCallback((fn: () => void) => {
52
+ setOpen(false)
53
+ fn()
54
+ }, [])
55
+
56
+ const value = useMemo(() => ({ open: () => setOpen(true) }), [])
57
+
58
+ return (
59
+ <CommandMenuContext value={value}>
60
+ {children}
61
+ <CommandDialog open={isOpen} onOpenChange={setOpen} title={t('command.title')} description={t('command.description')}>
62
+ <Command>
63
+ <CommandInput placeholder={t('command.placeholder')} />
64
+ <CommandList>
65
+ <CommandEmpty>{t('command.empty')}</CommandEmpty>
66
+ <CommandGroup heading={t('command.pages')}>
67
+ {allowed(navItems).map(({ path, label, icon: Icon }) => (
68
+ <CommandItem key={path} value={`page ${t(label)}`} onSelect={() => run(() => navigate(path))}>
69
+ <Icon />
70
+ {t(label)}
71
+ </CommandItem>
72
+ ))}
73
+ </CommandGroup>
74
+ {allowed(quickActions).length > 0 && (
75
+ <CommandGroup heading={t('command.actions')}>
76
+ {allowed(quickActions).map(({ path, label, icon: Icon }) => (
77
+ <CommandItem key={path} value={`action ${t(label)}`} onSelect={() => run(() => navigate(path))}>
78
+ <Icon />
79
+ {t(label)}
80
+ </CommandItem>
81
+ ))}
82
+ </CommandGroup>
83
+ )}
84
+ <CommandSeparator />
85
+ <CommandGroup heading={t('command.preferences')}>
86
+ {themeOptions.map(({ value, icon: Icon }) => (
87
+ <CommandItem key={value} value={`theme ${value} ${t(`theme.${value}`)}`} onSelect={() => run(() => setTheme(value))}>
88
+ <Icon />
89
+ {t('theme.command', { theme: t(`theme.${value}`) })}
90
+ </CommandItem>
91
+ ))}
92
+ <CommandItem
93
+ value={`sign out logout ${t('shell.signOut')}`}
94
+ onSelect={() =>
95
+ run(() => {
96
+ void logout()
97
+ navigate('/login')
98
+ })
99
+ }
100
+ >
101
+ <LogOut />
102
+ {t('shell.signOut')}
103
+ </CommandItem>
104
+ </CommandGroup>
105
+ </CommandList>
106
+ </Command>
107
+ </CommandDialog>
108
+ </CommandMenuContext>
109
+ )
110
+ }
111
+
112
+ // eslint-disable-next-line react/only-export-components
113
+ export function useCommandMenu() {
114
+ const ctx = useContext(CommandMenuContext)
115
+ if (!ctx) throw new Error('useCommandMenu must be used within <CommandMenuProvider>')
116
+ return ctx
117
+ }
@@ -0,0 +1,100 @@
1
+ import { Fragment, lazy, Suspense, useEffect, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Link, useMatches } from 'react-router'
4
+
5
+ import {
6
+ Breadcrumb,
7
+ BreadcrumbItem,
8
+ BreadcrumbLink,
9
+ BreadcrumbList,
10
+ BreadcrumbPage,
11
+ BreadcrumbSeparator,
12
+ } from '@/components/ui/breadcrumb'
13
+ import { Separator } from '@/components/ui/separator'
14
+ import { SidebarTrigger } from '@/components/ui/sidebar'
15
+ import { authMode, isFeatureOn } from '@/core/config/runtime'
16
+ import { LanguageSwitcher } from '@/core/i18n/language-switcher'
17
+ import { NotificationBell } from '@/core/notifications/notification-center'
18
+ import { useAnyT } from '@/core/i18n/keys'
19
+ import { useFormat } from '@/core/i18n/use-format'
20
+ import type { RouteHandle } from '@/core/modules/types'
21
+ import { ThemeToggle } from '@/core/theme/theme-toggle'
22
+
23
+ /** Dev-only role switcher; `import.meta.env.DEV` is false in production so the chunk is dropped. */
24
+ const DevRoleSwitcher = import.meta.env.DEV && authMode === 'mock'
25
+ ? lazy(() => import('@/core/auth/dev-role-switcher').then((m) => ({ default: m.DevRoleSwitcher })))
26
+ : null
27
+
28
+ /** Breadcrumbs come from `handle.crumb` on matched routes. */
29
+ function Breadcrumbs() {
30
+ const matches = useMatches()
31
+ const t = useAnyT()
32
+ const crumbs = matches
33
+ .map((m) => {
34
+ const crumb = (m.handle as RouteHandle | undefined)?.crumb
35
+ if (!crumb) return null
36
+ return { path: m.pathname, label: t(crumb) }
37
+ })
38
+ .filter((c): c is { path: string; label: string } => c !== null)
39
+ // Index routes repeat their parent's pathname; keep the deepest.
40
+ .filter((c, i, all) => all.findIndex((o) => o.path === c.path) === i)
41
+
42
+ return (
43
+ <Breadcrumb className="min-w-0" aria-label={t('shell.breadcrumb')}>
44
+ <BreadcrumbList className="flex-nowrap">
45
+ {crumbs.map((c, i) => {
46
+ const last = i === crumbs.length - 1
47
+ return (
48
+ <Fragment key={c.path}>
49
+ {i > 0 && <BreadcrumbSeparator className="hidden sm:inline-flex" />}
50
+ <BreadcrumbItem className={last ? 'min-w-0' : 'hidden sm:inline-flex'}>
51
+ {last ? (
52
+ <BreadcrumbPage className="truncate">{c.label}</BreadcrumbPage>
53
+ ) : (
54
+ <BreadcrumbLink render={<Link to={c.path} />}>{c.label}</BreadcrumbLink>
55
+ )}
56
+ </BreadcrumbItem>
57
+ </Fragment>
58
+ )
59
+ })}
60
+ </BreadcrumbList>
61
+ </Breadcrumb>
62
+ )
63
+ }
64
+
65
+ function Clock() {
66
+ const [now, setNow] = useState(() => new Date())
67
+ const fmt = useFormat()
68
+ useEffect(() => {
69
+ const t = setInterval(() => setNow(new Date()), 30_000)
70
+ return () => clearInterval(t)
71
+ }, [])
72
+ return (
73
+ <span className="hidden h-7 items-center gap-1.5 rounded-full border px-2.5 tabular-nums text-[10.5px] tracking-wide text-muted-foreground uppercase md:inline-flex">
74
+ <span aria-hidden className="size-1.5 rounded-full bg-success" />
75
+ <time dateTime={now.toISOString()}>{fmt.clock(now)}</time>
76
+ </span>
77
+ )
78
+ }
79
+
80
+ export function TopBar() {
81
+ const { t } = useTranslation()
82
+ return (
83
+ <header className="flex h-(--topbar-height) shrink-0 items-center gap-2 border-b px-3 md:px-4">
84
+ <SidebarTrigger className="-ml-1 text-muted-foreground" aria-label={t('shell.toggleSidebar')} />
85
+ <Separator orientation="vertical" className="mx-1 self-center data-vertical:h-4" />
86
+ <Breadcrumbs />
87
+ <div className="ml-auto flex items-center gap-1.5">
88
+ {DevRoleSwitcher && (
89
+ <Suspense>
90
+ <DevRoleSwitcher />
91
+ </Suspense>
92
+ )}
93
+ <LanguageSwitcher />
94
+ <Clock />
95
+ {isFeatureOn('notifications') && <NotificationBell />}
96
+ <ThemeToggle />
97
+ </div>
98
+ </header>
99
+ )
100
+ }
@@ -0,0 +1,27 @@
1
+ export type CsvColumn<T> = { header: string; value: (row: T) => string | number | boolean | null | undefined }
2
+
3
+ /** Spreadsheet apps run cells starting with these as formulas; a leading quote keeps them as text. */
4
+ const FORMULA_START = /^[=+\-@\t\r]/
5
+
6
+ function cell(value: string | number | boolean | null | undefined) {
7
+ if (value === null || value === undefined) return ''
8
+ const text = typeof value === 'string' && FORMULA_START.test(value) ? `'${value}` : String(value)
9
+ return /[",;\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
10
+ }
11
+
12
+ /**
13
+ * Downloads rows as a CSV file. The BOM makes Excel read UTF-8 correctly
14
+ * (ç ğ ı İ ö ş ü); `;` is the list separator Excel expects in Turkish locales.
15
+ */
16
+ export function downloadCsv<T>(filename: string, columns: CsvColumn<T>[], rows: T[], delimiter: ',' | ';' = ',') {
17
+ const lines = [
18
+ columns.map((c) => cell(c.header)).join(delimiter),
19
+ ...rows.map((row) => columns.map((c) => cell(c.value(row))).join(delimiter)),
20
+ ]
21
+ const url = URL.createObjectURL(new Blob([`${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }))
22
+ const link = document.createElement('a')
23
+ link.href = url
24
+ link.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
25
+ link.click()
26
+ URL.revokeObjectURL(url)
27
+ }
@@ -0,0 +1,87 @@
1
+ export type FormatOptions = {
2
+ /** BCP 47 locale, e.g. en-US, tr-TR. */
3
+ locale: string
4
+ /** ISO 4217 code, e.g. USD, TRY. */
5
+ currency: string
6
+ /** IANA zone; undefined = the browser's. */
7
+ timeZone?: string
8
+ /** Fixed numeric pattern, or 'auto' for the locale's medium date. */
9
+ dateFormat?: 'auto' | 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
10
+ }
11
+
12
+ /**
13
+ * Locale-aware formatters. Components get them from useFormat()
14
+ * (core/i18n/use-format.ts), which follows the active language and
15
+ * preferences; don't format numbers or dates by hand.
16
+ */
17
+ export function createFormatters({ locale, currency, timeZone, dateFormat = 'auto' }: FormatOptions) {
18
+ const currencyFmt = new Intl.NumberFormat(locale, { style: 'currency', currency })
19
+ const currencyWhole = new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0 })
20
+ const compact = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 })
21
+ const compactCurrency = new Intl.NumberFormat(locale, { style: 'currency', currency, notation: 'compact', maximumFractionDigits: 1 })
22
+ const number = new Intl.NumberFormat(locale)
23
+ const percent = new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 2 })
24
+ const delta = new Intl.NumberFormat(locale, { style: 'percent', signDisplay: 'exceptZero', minimumFractionDigits: 1, maximumFractionDigits: 1 })
25
+ const date = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone })
26
+ const dateTime = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone })
27
+ const dayMonth = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone })
28
+ const weekdayDayMonth = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone })
29
+ const clock = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone })
30
+ const relative = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
31
+ const time = new Intl.DateTimeFormat(locale, { timeStyle: 'short', timeZone })
32
+ const numericParts = new Intl.DateTimeFormat('en-US', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone })
33
+
34
+ const toDate = (value: string | Date) => (typeof value === 'string' ? new Date(value) : value)
35
+
36
+ /** Applies the user's numeric date pattern (in the right time zone). */
37
+ function pattern(d: Date) {
38
+ const parts = Object.fromEntries(numericParts.formatToParts(d).map((p) => [p.type, p.value]))
39
+ return dateFormat.replace('dd', parts.day!).replace('MM', parts.month!).replace('yyyy', parts.year!)
40
+ }
41
+
42
+ return {
43
+ locale,
44
+ /** "$", "₺", "€" — for input prefixes. */
45
+ currencySymbol: currencyFmt.formatToParts(0).find((p) => p.type === 'currency')?.value ?? currency,
46
+ currency: (value: number) => currencyFmt.format(value),
47
+ /** Currency without decimals, for KPIs. */
48
+ currencyWhole: (value: number) => currencyWhole.format(value),
49
+ compact: (value: number) => compact.format(value),
50
+ compactCurrency: (value: number) => compactCurrency.format(value),
51
+ number: (value: number) => number.format(value),
52
+ /** 3.42 → "3.42%" (input is already a percentage). */
53
+ percent: (value: number) => percent.format(value / 100),
54
+ /** 12.4 → "+12.4%" for changes vs a previous period. */
55
+ delta: (value: number) => delta.format(value / 100),
56
+ date: (value: string | Date) => (dateFormat === 'auto' ? date.format(toDate(value)) : pattern(toDate(value))),
57
+ dateTime: (value: string | Date) =>
58
+ dateFormat === 'auto' ? dateTime.format(toDate(value)) : `${pattern(toDate(value))} ${time.format(toDate(value))}`,
59
+ time: (value: string | Date) => time.format(toDate(value)),
60
+ dayMonth: (value: string | Date) => dayMonth.format(toDate(value)),
61
+ weekdayDayMonth: (value: string | Date) => weekdayDayMonth.format(toDate(value)),
62
+ clock: (value: string | Date) => clock.format(toDate(value)),
63
+ relative(value: string | Date) {
64
+ const diff = (toDate(value).getTime() - Date.now()) / 1000
65
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [
66
+ ['day', 86400],
67
+ ['hour', 3600],
68
+ ['minute', 60],
69
+ ]
70
+ for (const [unit, seconds] of units) {
71
+ if (Math.abs(diff) >= seconds) return relative.format(Math.round(diff / seconds), unit)
72
+ }
73
+ return relative.format(0, 'second')
74
+ },
75
+ }
76
+ }
77
+
78
+ export type Formatters = ReturnType<typeof createFormatters>
79
+
80
+ export function initials(name: string) {
81
+ return name
82
+ .split(/\s+/)
83
+ .map((part) => part[0])
84
+ .join('')
85
+ .slice(0, 2)
86
+ .toLocaleUpperCase()
87
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Minimal phone handling: a country calling code plus a national number,
3
+ * stored as E.164 (+905321234567). Lengths are national significant digits.
4
+ */
5
+ export const phoneCountries = [
6
+ { code: 'TR', dial: '+90', min: 10, max: 10 },
7
+ { code: 'US', dial: '+1', min: 10, max: 10 },
8
+ { code: 'GB', dial: '+44', min: 9, max: 10 },
9
+ { code: 'DE', dial: '+49', min: 6, max: 11 },
10
+ { code: 'FR', dial: '+33', min: 9, max: 9 },
11
+ { code: 'NL', dial: '+31', min: 9, max: 9 },
12
+ { code: 'AZ', dial: '+994', min: 9, max: 9 },
13
+ { code: 'AE', dial: '+971', min: 8, max: 9 },
14
+ ] as const
15
+
16
+ export type PhoneCountry = (typeof phoneCountries)[number]['code']
17
+ export const defaultPhoneCountry: PhoneCountry = 'TR'
18
+
19
+ const digits = (value: string) => value.replace(/\D/g, '')
20
+ const country = (code: string) => phoneCountries.find((c) => c.code === code) ?? phoneCountries[0]
21
+
22
+ /** National digits without a trunk prefix (Turkish numbers are often typed as 0532…). */
23
+ function national(code: string, number: string) {
24
+ const d = digits(number)
25
+ return code === 'TR' && d.length === 11 && d.startsWith('0') ? d.slice(1) : d
26
+ }
27
+
28
+ export function isValidPhone(code: string, number: string) {
29
+ const { min, max } = country(code)
30
+ const d = national(code, number)
31
+ if (/[^\d\s()+.-]/.test(number)) return false
32
+ if (code === 'TR' && !/^[2-5]/.test(d)) return false
33
+ return d.length >= min && d.length <= max
34
+ }
35
+
36
+ export function toE164(code: string, number: string) {
37
+ return `${country(code).dial}${national(code, number)}`
38
+ }
39
+
40
+ export function dialCode(code: string) {
41
+ return country(code).dial
42
+ }
43
+
44
+ /** Splits a stored E.164 number back into country + national number. */
45
+ export function splitPhone(e164: string | undefined): { country: PhoneCountry; number: string } {
46
+ if (!e164) return { country: defaultPhoneCountry, number: '' }
47
+ // Longest dial code first so +994 wins over +9x.
48
+ const match = [...phoneCountries].sort((a, b) => b.dial.length - a.dial.length).find((c) => e164.startsWith(c.dial))
49
+ return match ? { country: match.code, number: e164.slice(match.dial.length) } : { country: defaultPhoneCountry, number: digits(e164) }
50
+ }
@@ -0,0 +1,27 @@
1
+ /** Namespaced, JSON-safe localStorage access that never throws. */
2
+ const PREFIX = 'fp:'
3
+
4
+ export const storage = {
5
+ get<T>(key: string): T | null {
6
+ try {
7
+ const raw = localStorage.getItem(PREFIX + key)
8
+ return raw ? (JSON.parse(raw) as T) : null
9
+ } catch {
10
+ return null
11
+ }
12
+ },
13
+ set(key: string, value: unknown) {
14
+ try {
15
+ localStorage.setItem(PREFIX + key, JSON.stringify(value))
16
+ } catch {
17
+ // Storage full or blocked: settings simply won't persist.
18
+ }
19
+ },
20
+ remove(key: string) {
21
+ try {
22
+ localStorage.removeItem(PREFIX + key)
23
+ } catch {
24
+ // ignore
25
+ }
26
+ },
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /**
4
+ * Route `lazy` helper for code-split pages with a default export:
5
+ * { path: 'products', lazy: lazyPage(() => import('./pages/products-list-page')) }
6
+ */
7
+ export const lazyPage = (load: () => Promise<{ default: ComponentType }>) => async () => ({
8
+ Component: (await load()).default,
9
+ })
@@ -0,0 +1,39 @@
1
+ import { LayoutGrid, SlidersHorizontal, Settings } from 'lucide-react'
2
+
3
+ import { appConfig } from '@/app.config'
4
+ import type { Permission } from '@/core/auth/permissions'
5
+ import { enabledModules } from '@/modules/registry'
6
+ import type { NavItem, QuickAction } from './types'
7
+
8
+ /** Core entries present in every project. */
9
+ const coreNav: NavItem[] = [
10
+ { label: 'common:nav.dashboard', path: '/app', icon: LayoutGrid, group: 'overview', end: true, permission: 'dashboard.view' },
11
+ { label: 'common:nav.settings', path: '/app/settings', icon: Settings, group: 'system' },
12
+ ]
13
+
14
+ const coreQuickActions: QuickAction[] = [
15
+ {
16
+ label: 'dashboard:quickActions.appearance',
17
+ description: 'dashboard:quickActions.appearanceDescription',
18
+ path: '/app/settings/preferences',
19
+ icon: SlidersHorizontal,
20
+ },
21
+ ]
22
+
23
+ export const navItems: NavItem[] = [...coreNav, ...enabledModules.flatMap((m) => m.nav)]
24
+
25
+ /** Nav items the user may see, grouped in the order of appConfig.navGroups; empty groups are dropped. */
26
+ export function getNavGroups(can: (permission: Permission) => boolean) {
27
+ return appConfig.navGroups
28
+ .map((group) => ({
29
+ ...group,
30
+ items: navItems.filter((item) => item.group === group.id && (!item.permission || can(item.permission))),
31
+ }))
32
+ .filter((group) => group.items.length > 0)
33
+ }
34
+
35
+ export const quickActions: QuickAction[] = [...enabledModules.flatMap((m) => m.quickActions ?? []), ...coreQuickActions]
36
+
37
+ export function isNavItemActive(item: NavItem, pathname: string) {
38
+ return item.end ? pathname === item.path : pathname === item.path || pathname.startsWith(item.path + '/')
39
+ }
@@ -0,0 +1,55 @@
1
+ import type { ComponentType } from 'react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+ import type { RouteObject } from 'react-router'
4
+
5
+ import type { Permission } from '@/core/auth/permissions'
6
+ import type { I18nKey } from '@/core/i18n/keys'
7
+
8
+ /** A sidebar entry. `group` must match an id in appConfig.navGroups. */
9
+ export type NavItem = {
10
+ /** Translation key, e.g. 'products:nav'. */
11
+ label: I18nKey
12
+ /** Absolute path, e.g. "/app/products". */
13
+ path: string
14
+ icon: LucideIcon
15
+ group: string
16
+ /** Optional hook returning a count for the badge (e.g. items needing attention). */
17
+ useBadge?: () => number | undefined
18
+ /** Match only the exact path when deciding the active state. */
19
+ end?: boolean
20
+ /** Hidden unless the user has this permission (use the same one on the route). */
21
+ permission?: Permission
22
+ }
23
+
24
+ /** Shortcut shown on the dashboard and in the ⌘K menu. */
25
+ export type QuickAction = {
26
+ label: I18nKey
27
+ description?: I18nKey
28
+ path: string
29
+ icon: LucideIcon
30
+ permission?: Permission
31
+ }
32
+
33
+ /**
34
+ * A self-contained feature area. Routes are mounted under /app and rendered
35
+ * inside the app shell. Add a crumb via `handle: { crumb: 'ns:key' }`.
36
+ */
37
+ export type AppModule = {
38
+ id: string
39
+ nav: NavItem[]
40
+ routes: RouteObject[]
41
+ quickActions?: QuickAction[]
42
+ /** Cards shown on the dashboard, e.g. a module's KPIs. Gate them with `<Can>` inside. */
43
+ dashboardWidgets?: ComponentType[]
44
+ }
45
+
46
+ /** Route `handle` shape: breadcrumbs and access control. */
47
+ export type RouteHandle = {
48
+ /** Translation key of the breadcrumb label. */
49
+ crumb?: I18nKey
50
+ /**
51
+ * Required permission for this route and its children. The app shell
52
+ * redirects to /app/no-access when the user lacks it (UI only, not security).
53
+ */
54
+ permission?: Permission
55
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect } from 'react'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3
+
4
+ import { useSession } from '@/core/auth/use-auth'
5
+ import { notificationsService } from './notifications.service'
6
+ import type { AppNotification, NotificationSettings } from './types'
7
+
8
+ /** How often the feed is refetched. A push channel (`subscribe`) makes changes appear sooner. */
9
+ const POLL_INTERVAL = 30_000
10
+
11
+ /** Keys are scoped to the signed-in user, so the role switcher never shows another user's feed. */
12
+ export function useNotificationKeys() {
13
+ const userId = useSession()?.user.id ?? 'anonymous'
14
+ const all = ['notifications', userId] as const
15
+ return { all, list: [...all, 'list'] as const, settings: [...all, 'settings'] as const }
16
+ }
17
+
18
+ export function useNotifications() {
19
+ const keys = useNotificationKeys()
20
+ const qc = useQueryClient()
21
+ const userId = keys.all[1]
22
+
23
+ // Push (SSE / WebSocket) when the service supports it; polling below stays as the fallback.
24
+ useEffect(
25
+ () => notificationsService.subscribe?.(() => void qc.invalidateQueries({ queryKey: ['notifications', userId, 'list'] })),
26
+ [qc, userId],
27
+ )
28
+
29
+ return useQuery({
30
+ queryKey: keys.list,
31
+ queryFn: () => notificationsService.list(),
32
+ refetchInterval: POLL_INTERVAL,
33
+ })
34
+ }
35
+
36
+ export const isUnread = (n: AppNotification) => !n.readAt
37
+
38
+ export function useUnreadNotificationCount() {
39
+ return useNotifications().data?.filter(isUnread).length ?? 0
40
+ }
41
+
42
+ /** Optimistically marks notifications read in the cached feed, then syncs with the server. */
43
+ function useReadMutation<T>(fn: (input: T) => Promise<void>, match: (n: AppNotification, input: T) => boolean) {
44
+ const qc = useQueryClient()
45
+ const keys = useNotificationKeys()
46
+ return useMutation({
47
+ mutationFn: fn,
48
+ onMutate: async (input) => {
49
+ await qc.cancelQueries({ queryKey: keys.list })
50
+ const previous = qc.getQueryData<AppNotification[]>(keys.list)
51
+ const now = new Date().toISOString()
52
+ qc.setQueryData<AppNotification[]>(keys.list, (list) =>
53
+ list?.map((n) => (!n.readAt && match(n, input) ? { ...n, readAt: now } : n)),
54
+ )
55
+ return { previous }
56
+ },
57
+ onError: (_error, _input, context) => qc.setQueryData(keys.list, context?.previous),
58
+ onSettled: () => qc.invalidateQueries({ queryKey: keys.list }),
59
+ })
60
+ }
61
+
62
+ export const useMarkNotificationRead = () =>
63
+ useReadMutation((id: string) => notificationsService.markRead(id), (n, id) => n.id === id)
64
+
65
+ export const useMarkAllNotificationsRead = () =>
66
+ useReadMutation(() => notificationsService.markAllRead(), () => true)
67
+
68
+ export function useNotificationSettings() {
69
+ const keys = useNotificationKeys()
70
+ return useQuery({ queryKey: keys.settings, queryFn: () => notificationsService.settings() })
71
+ }
72
+
73
+ export function useUpdateNotificationSettings() {
74
+ const qc = useQueryClient()
75
+ const keys = useNotificationKeys()
76
+ return useMutation({
77
+ mutationFn: (settings: NotificationSettings) => notificationsService.updateSettings(settings),
78
+ onSuccess: (data) => {
79
+ qc.setQueryData(keys.settings, data)
80
+ // Turning an event off (or on) changes which notifications the feed contains.
81
+ return qc.invalidateQueries({ queryKey: keys.list })
82
+ },
83
+ })
84
+ }