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,73 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+
3
+ import { useSession } from '@/core/auth/use-auth'
4
+ import { accountService } from './account.service'
5
+ import type { PasswordChange } from './types'
6
+
7
+ /** Keys are scoped to the signed-in user so switching users never shows stale data. */
8
+ export function useAccountKeys() {
9
+ const userId = useSession()?.user.id ?? 'anonymous'
10
+ const all = ['account', userId] as const
11
+ return {
12
+ all,
13
+ security: [...all, 'security'] as const,
14
+ sessions: [...all, 'sessions'] as const,
15
+ history: [...all, 'history'] as const,
16
+ }
17
+ }
18
+
19
+ export function useSecurityOverview() {
20
+ const keys = useAccountKeys()
21
+ return useQuery({ queryKey: keys.security, queryFn: () => accountService.security() })
22
+ }
23
+
24
+ export function useChangePassword() {
25
+ const qc = useQueryClient()
26
+ const keys = useAccountKeys()
27
+ return useMutation({
28
+ mutationFn: (input: PasswordChange) => accountService.changePassword(input),
29
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.security }),
30
+ })
31
+ }
32
+
33
+ export function useTwoFactorSetup() {
34
+ return useMutation({ mutationFn: () => accountService.startTwoFactorSetup() })
35
+ }
36
+
37
+ export function useConfirmTwoFactor() {
38
+ const qc = useQueryClient()
39
+ const keys = useAccountKeys()
40
+ return useMutation({
41
+ mutationFn: (code: string) => accountService.confirmTwoFactor(code),
42
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.security }),
43
+ })
44
+ }
45
+
46
+ export function useDisableTwoFactor() {
47
+ const qc = useQueryClient()
48
+ const keys = useAccountKeys()
49
+ return useMutation({
50
+ mutationFn: () => accountService.disableTwoFactor(),
51
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.security }),
52
+ })
53
+ }
54
+
55
+ export function useSessions() {
56
+ const keys = useAccountKeys()
57
+ return useQuery({ queryKey: keys.sessions, queryFn: () => accountService.sessions() })
58
+ }
59
+
60
+ export function useRevokeSession() {
61
+ const qc = useQueryClient()
62
+ const keys = useAccountKeys()
63
+ return useMutation({
64
+ mutationFn: (id: string | 'others') =>
65
+ id === 'others' ? accountService.revokeOtherSessions() : accountService.revokeSession(id),
66
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.sessions }),
67
+ })
68
+ }
69
+
70
+ export function useLoginHistory() {
71
+ const keys = useAccountKeys()
72
+ return useQuery({ queryKey: keys.history, queryFn: () => accountService.loginHistory() })
73
+ }
@@ -0,0 +1,103 @@
1
+ import { Fragment } from 'react'
2
+ import { Controller, useForm } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { toast } from 'sonner'
5
+
6
+ import { Switch } from '@/components/ui/switch'
7
+ import { FormLayout } from '@/core/components/form-page'
8
+ import { Page } from '@/core/components/page'
9
+ import { ErrorState, LoadingState } from '@/core/components/states'
10
+ import { useNotificationSettings, useUpdateNotificationSettings } from '@/core/notifications/hooks'
11
+ import { visibleNotificationEvents, type NotificationGroup, type NotificationSettings } from '@/core/notifications/types'
12
+
13
+ const channels = ['email', 'inApp'] as const
14
+ const groups = [...new Set(visibleNotificationEvents.map((e) => e.group))] as NotificationGroup[]
15
+
16
+ export default function NotificationsPage() {
17
+ const { t } = useTranslation('account')
18
+ const query = useNotificationSettings()
19
+
20
+ return (
21
+ <Page title={t('notifications.title')} description={t('notifications.description')}>
22
+ {query.isPending ? (
23
+ <LoadingState />
24
+ ) : query.error ? (
25
+ <ErrorState error={query.error} onRetry={() => query.refetch()} />
26
+ ) : (
27
+ <NotificationsForm settings={query.data} />
28
+ )}
29
+ </Page>
30
+ )
31
+ }
32
+
33
+ function NotificationsForm({ settings }: { settings: NotificationSettings }) {
34
+ const { t } = useTranslation('account')
35
+ const update = useUpdateNotificationSettings()
36
+ const { control, handleSubmit, formState, reset } = useForm<NotificationSettings>({ defaultValues: settings })
37
+
38
+ const onSubmit = handleSubmit(async (values) => {
39
+ const saved = await update.mutateAsync(values)
40
+ reset(saved)
41
+ toast.success(t('notifications.saved'))
42
+ })
43
+
44
+ return (
45
+ <FormLayout
46
+ onSubmit={onSubmit}
47
+ saveBar="dirty"
48
+ isDirty={formState.isDirty}
49
+ isSubmitting={formState.isSubmitting}
50
+ onCancel={() => reset()}
51
+ >
52
+ <div role="table" aria-label={t('notifications.title')} className="flex flex-col">
53
+ <div role="row" className="grid grid-cols-[1fr_4rem_4rem] items-center gap-2 bg-fill-1 px-4 py-2.5 sm:grid-cols-[1fr_5rem_5rem] md:px-6">
54
+ <span role="columnheader" className="label-caps">
55
+ {t('notifications.channels.event')}
56
+ </span>
57
+ {channels.map((c) => (
58
+ <span key={c} role="columnheader" className="label-caps text-center">
59
+ {t(`notifications.channels.${c}`)}
60
+ </span>
61
+ ))}
62
+ </div>
63
+ {groups.map((group) => (
64
+ <Fragment key={group}>
65
+ <div role="row" className="border-t px-4 pt-4 pb-1 md:px-6">
66
+ <span role="rowheader" className="label-caps">
67
+ {t(`notifications.groups.${group}`)}
68
+ </span>
69
+ </div>
70
+ {visibleNotificationEvents
71
+ .filter((e) => e.group === group)
72
+ .map(({ id }) => {
73
+ const label = t(`notifications.events.${id}.label`)
74
+ return (
75
+ <div key={id} role="row" className="grid grid-cols-[1fr_4rem_4rem] items-center gap-2 px-4 py-3 sm:grid-cols-[1fr_5rem_5rem] md:px-6">
76
+ <div role="cell" className="min-w-0">
77
+ <p className="text-sm font-medium">{label}</p>
78
+ <p className="text-xs text-muted-foreground">{t(`notifications.events.${id}.description`)}</p>
79
+ </div>
80
+ {channels.map((channel) => (
81
+ <div key={channel} role="cell" className="flex justify-center">
82
+ <Controller
83
+ control={control}
84
+ name={`${id}.${channel}`}
85
+ render={({ field }) => (
86
+ <Switch
87
+ checked={field.value}
88
+ onCheckedChange={field.onChange}
89
+ aria-label={t('notifications.toggle', { channel: t(`notifications.channels.${channel}`), event: label })}
90
+ />
91
+ )}
92
+ />
93
+ </div>
94
+ ))}
95
+ </div>
96
+ )
97
+ })}
98
+ </Fragment>
99
+ ))}
100
+ </div>
101
+ </FormLayout>
102
+ )
103
+ }
@@ -0,0 +1,157 @@
1
+ import { useMemo } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Field, FieldContent, FieldLabel, FieldTitle } from '@/components/ui/field'
5
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
7
+ import { FormSection } from '@/core/components/form-page'
8
+ import { Page } from '@/core/components/page'
9
+ import { Segmented } from '@/core/components/segmented'
10
+ import { currentLanguage, setLanguage } from '@/core/i18n/i18n'
11
+ import { languageNames, languages, type Language } from '@/core/i18n/types'
12
+ import { useFormat } from '@/core/i18n/use-format'
13
+ import { createFormatters } from '@/core/lib/format'
14
+ import {
15
+ browserTimeZone,
16
+ dateFormats,
17
+ preferencesStore,
18
+ sidebarDefaults,
19
+ timeZones,
20
+ usePreferences,
21
+ type DateFormat,
22
+ } from '@/core/preferences/preferences-store'
23
+ import { useTheme, type Theme } from '@/core/theme/theme-context'
24
+ import { themeOptions } from '@/core/theme/theme-options'
25
+
26
+ const SAMPLE = new Date(Date.UTC(2026, 11, 31, 9, 30))
27
+
28
+ /** Settings that apply immediately: no save bar. */
29
+ export default function PreferencesPage() {
30
+ const { t } = useTranslation(['account', 'common'])
31
+ const { theme, setTheme } = useTheme()
32
+ const prefs = usePreferences()
33
+ const fmt = useFormat()
34
+ const language = currentLanguage()
35
+
36
+ const zoneItems = useMemo(
37
+ () => [
38
+ { value: 'auto', label: t('preferences.region.auto', { zone: browserTimeZone() }) },
39
+ ...timeZones.map((z) => ({ value: z, label: z.replace(/_/g, ' ') })),
40
+ ],
41
+ [t],
42
+ )
43
+
44
+ const dateItems = dateFormats.map((f) => ({
45
+ value: f,
46
+ label:
47
+ f === 'auto'
48
+ ? t('preferences.region.dateAuto', {
49
+ example: createFormatters({ locale: fmt.locale, currency: 'USD', timeZone: 'UTC' }).date(SAMPLE),
50
+ })
51
+ : f,
52
+ }))
53
+
54
+ return (
55
+ <Page title={t('preferences.title')} description={t('preferences.description')}>
56
+ <div className="flex flex-col divide-y rounded-xl border bg-card shadow-(--shadow-card)">
57
+ <FormSection title={t('preferences.appearance.title')} description={t('preferences.appearance.description')}>
58
+ <RadioGroup
59
+ value={theme}
60
+ onValueChange={(v) => setTheme(v as Theme)}
61
+ className="grid grid-cols-1 gap-3 sm:grid-cols-3"
62
+ aria-label={t('common:theme.label')}
63
+ >
64
+ {themeOptions.map(({ value, icon: Icon }) => (
65
+ <FieldLabel key={value} htmlFor={`theme-${value}`} className="rounded-xl!">
66
+ <Field orientation="horizontal">
67
+ <FieldContent>
68
+ <FieldTitle>
69
+ <Icon className="size-4 text-muted-foreground" />
70
+ {t(`common:theme.${value}`)}
71
+ </FieldTitle>
72
+ </FieldContent>
73
+ <RadioGroupItem value={value} id={`theme-${value}`} />
74
+ </Field>
75
+ </FieldLabel>
76
+ ))}
77
+ </RadioGroup>
78
+ </FormSection>
79
+
80
+ <FormSection title={t('preferences.language.title')} description={t('preferences.language.description')}>
81
+ <RadioGroup
82
+ value={language}
83
+ onValueChange={(v) => setLanguage(v as Language)}
84
+ className="grid grid-cols-1 gap-3 sm:grid-cols-2"
85
+ aria-label={t('common:language.label')}
86
+ >
87
+ {languages.map((lng) => (
88
+ <FieldLabel key={lng} htmlFor={`lang-${lng}`} className="rounded-xl!">
89
+ <Field orientation="horizontal">
90
+ <FieldContent>
91
+ <FieldTitle lang={lng}>
92
+ <span className="tabular-nums text-[11px] text-muted-foreground uppercase">{lng}</span>
93
+ {languageNames[lng]}
94
+ </FieldTitle>
95
+ </FieldContent>
96
+ <RadioGroupItem value={lng} id={`lang-${lng}`} />
97
+ </Field>
98
+ </FieldLabel>
99
+ ))}
100
+ </RadioGroup>
101
+ </FormSection>
102
+
103
+ <FormSection title={t('preferences.region.title')} description={t('preferences.region.description')}>
104
+ <div className="grid gap-4 sm:grid-cols-2">
105
+ <Field>
106
+ <FieldLabel htmlFor="timeZone">{t('preferences.region.timeZone')}</FieldLabel>
107
+ <Select items={zoneItems} value={prefs.timeZone} onValueChange={(v) => v && preferencesStore.set({ timeZone: v })}>
108
+ <SelectTrigger id="timeZone" className="w-full">
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ {zoneItems.map((z) => (
113
+ <SelectItem key={z.value} value={z.value}>
114
+ {z.label}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectContent>
118
+ </Select>
119
+ </Field>
120
+ <Field>
121
+ <FieldLabel htmlFor="dateFormat">{t('preferences.region.dateFormat')}</FieldLabel>
122
+ <Select
123
+ items={dateItems}
124
+ value={prefs.dateFormat}
125
+ onValueChange={(v) => v && preferencesStore.set({ dateFormat: v as DateFormat })}
126
+ >
127
+ <SelectTrigger id="dateFormat" className="w-full tabular-nums">
128
+ <SelectValue />
129
+ </SelectTrigger>
130
+ <SelectContent>
131
+ {dateItems.map((d) => (
132
+ <SelectItem key={d.value} value={d.value} className="tabular-nums">
133
+ {d.label}
134
+ </SelectItem>
135
+ ))}
136
+ </SelectContent>
137
+ </Select>
138
+ </Field>
139
+ </div>
140
+ <p className="text-xs text-muted-foreground">
141
+ {t('preferences.region.example', { value: fmt.dateTime(SAMPLE) })}
142
+ </p>
143
+ </FormSection>
144
+
145
+ <FormSection title={t('preferences.sidebar.title')} description={t('preferences.sidebar.description')}>
146
+ <Segmented
147
+ label={t('preferences.sidebar.title')}
148
+ value={prefs.sidebarDefault}
149
+ onChange={(v) => preferencesStore.set({ sidebarDefault: v })}
150
+ options={sidebarDefaults.map((s) => ({ value: s, label: t(`preferences.sidebar.${s}`) }))}
151
+ className="w-fit"
152
+ />
153
+ </FormSection>
154
+ </div>
155
+ </Page>
156
+ )
157
+ }
@@ -0,0 +1,242 @@
1
+ import { Controller, useForm, useWatch } from 'react-hook-form'
2
+ import { zodResolver } from '@hookform/resolvers/zod'
3
+ import { Trans, useTranslation } from 'react-i18next'
4
+ import { MailCheck } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { z } from 'zod'
7
+
8
+ import { Badge } from '@/components/ui/badge'
9
+ import { Button } from '@/components/ui/button'
10
+ import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
11
+ import { Input } from '@/components/ui/input'
12
+ import { ApiError } from '@/core/api/http'
13
+ import { useAuth } from '@/core/auth/use-auth'
14
+ import { FormFieldError } from '@/core/components/form-error'
15
+ import { FormLayout, FormSection } from '@/core/components/form-page'
16
+ import { ImageUpload } from '@/core/components/image-upload'
17
+ import { PhoneInput } from '@/core/components/phone-input'
18
+ import { Page } from '@/core/components/page'
19
+ import { UserAvatar } from '@/core/components/user-avatar'
20
+ import { vmsg } from '@/core/i18n/keys'
21
+ import { dialCode, isValidPhone, splitPhone, toE164 } from '@/core/lib/phone'
22
+ import { useResolveEmailChange, useUpdateProfile } from '@/core/users/hooks'
23
+ import type { User } from '@/core/users/types'
24
+ import { userErrors } from '@/core/users/users.service'
25
+
26
+ const name = z
27
+ .string()
28
+ .trim()
29
+ .min(1, vmsg('validation.required'))
30
+ .max(50, vmsg('validation.maxChars', { max: 50 }))
31
+
32
+ const schema = z
33
+ .object({
34
+ avatarUrl: z.string().optional(),
35
+ firstName: name,
36
+ lastName: name,
37
+ jobTitle: z.string().trim().max(80, vmsg('validation.maxChars', { max: 80 })),
38
+ email: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
39
+ phoneCountry: z.string(),
40
+ phoneNumber: z.string().trim(),
41
+ })
42
+ .superRefine((v, ctx) => {
43
+ if (v.phoneNumber && !isValidPhone(v.phoneCountry, v.phoneNumber)) {
44
+ ctx.addIssue({
45
+ code: 'custom',
46
+ path: ['phoneNumber'],
47
+ message: vmsg('account:profile.contact.phoneInvalid', { country: dialCode(v.phoneCountry) }),
48
+ })
49
+ }
50
+ })
51
+
52
+ type Values = z.infer<typeof schema>
53
+
54
+ function toValues(user: User): Values {
55
+ const phone = splitPhone(user.phone)
56
+ return {
57
+ avatarUrl: user.avatarUrl,
58
+ firstName: user.firstName,
59
+ lastName: user.lastName,
60
+ jobTitle: user.jobTitle ?? '',
61
+ email: user.email,
62
+ phoneCountry: phone.country,
63
+ phoneNumber: phone.number,
64
+ }
65
+ }
66
+
67
+ export default function ProfilePage() {
68
+ const { user } = useAuth()
69
+ const { t } = useTranslation(['account', 'common'])
70
+ if (!user) return null
71
+ return (
72
+ <Page title={t('profile.title')} description={t('profile.description')}>
73
+ <PendingEmail user={user} />
74
+ <ProfileForm key={user.id} user={user} />
75
+ </Page>
76
+ )
77
+ }
78
+
79
+ function ProfileForm({ user }: { user: User }) {
80
+ const { t } = useTranslation(['account', 'common'])
81
+ const update = useUpdateProfile()
82
+ const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: toValues(user) })
83
+ const { register, control, handleSubmit, formState, reset, setError } = form
84
+ const { errors } = formState
85
+
86
+ const onSubmit = handleSubmit(async (values) => {
87
+ try {
88
+ const updated = await update.mutateAsync({
89
+ id: user.id,
90
+ patch: {
91
+ avatarUrl: values.avatarUrl,
92
+ firstName: values.firstName,
93
+ lastName: values.lastName,
94
+ jobTitle: values.jobTitle || undefined,
95
+ email: values.email,
96
+ phone: values.phoneNumber ? toE164(values.phoneCountry, values.phoneNumber) : undefined,
97
+ },
98
+ })
99
+ reset(toValues(updated))
100
+ toast.success(t('profile.saved'))
101
+ if (updated.pendingEmail && updated.pendingEmail !== user.pendingEmail) {
102
+ toast.info(t('profile.pending.started', { email: updated.pendingEmail }))
103
+ }
104
+ } catch (e) {
105
+ if (e instanceof ApiError && e.message === userErrors.duplicateEmail) {
106
+ setError('email', { message: vmsg('account:profile.contact.emailTaken') })
107
+ return
108
+ }
109
+ toast.error(t('profile.saveFailed'), { description: e instanceof Error ? e.message : undefined })
110
+ }
111
+ })
112
+
113
+ const [firstName, lastName] = useWatch({ control, name: ['firstName', 'lastName'] })
114
+ const preview = { ...user, firstName, lastName }
115
+
116
+ return (
117
+ <FormLayout
118
+ onSubmit={onSubmit}
119
+ saveBar="dirty"
120
+ isDirty={formState.isDirty}
121
+ isSubmitting={formState.isSubmitting}
122
+ onCancel={() => reset()}
123
+ >
124
+ <FormSection title={t('profile.photo.title')} description={t('profile.photo.description')}>
125
+ <Controller
126
+ control={control}
127
+ name="avatarUrl"
128
+ render={({ field }) => (
129
+ <ImageUpload
130
+ value={field.value}
131
+ onChange={(v) => field.onChange(v)}
132
+ label={t('profile.photo.change')}
133
+ onError={(err) => toast.error(t(`profile.photo.${err}`))}
134
+ preview={(src) => <UserAvatar user={{ ...preview, avatarUrl: src }} className="size-16 rounded-xl text-base" />}
135
+ />
136
+ )}
137
+ />
138
+ </FormSection>
139
+
140
+ <FormSection title={t('profile.personal.title')} description={t('profile.personal.description')}>
141
+ <div className="grid gap-4 sm:grid-cols-2">
142
+ <Field data-invalid={!!errors.firstName}>
143
+ <FieldLabel htmlFor="firstName">{t('profile.personal.firstName')}</FieldLabel>
144
+ <Input id="firstName" autoComplete="given-name" aria-invalid={!!errors.firstName} {...register('firstName')} />
145
+ <FormFieldError error={errors.firstName} />
146
+ </Field>
147
+ <Field data-invalid={!!errors.lastName}>
148
+ <FieldLabel htmlFor="lastName">{t('profile.personal.lastName')}</FieldLabel>
149
+ <Input id="lastName" autoComplete="family-name" aria-invalid={!!errors.lastName} {...register('lastName')} />
150
+ <FormFieldError error={errors.lastName} />
151
+ </Field>
152
+ </div>
153
+ <Field data-invalid={!!errors.jobTitle}>
154
+ <FieldLabel htmlFor="jobTitle">{t('profile.personal.jobTitle')}</FieldLabel>
155
+ <Input
156
+ id="jobTitle"
157
+ autoComplete="organization-title"
158
+ placeholder={t('profile.personal.jobTitlePlaceholder')}
159
+ aria-invalid={!!errors.jobTitle}
160
+ {...register('jobTitle')}
161
+ />
162
+ <FormFieldError error={errors.jobTitle} />
163
+ </Field>
164
+ </FormSection>
165
+
166
+ <FormSection title={t('profile.contact.title')} description={t('profile.contact.description')}>
167
+ <Field data-invalid={!!errors.email}>
168
+ <FieldLabel htmlFor="email">{t('profile.contact.email')}</FieldLabel>
169
+ <Input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} {...register('email')} />
170
+ <FormFieldError error={errors.email} />
171
+ </Field>
172
+ <Field data-invalid={!!errors.phoneNumber}>
173
+ <FieldLabel htmlFor="phoneNumber">{t('profile.contact.phone')}</FieldLabel>
174
+ <Controller
175
+ control={control}
176
+ name="phoneCountry"
177
+ render={({ field }) => (
178
+ <PhoneInput
179
+ id="phoneNumber"
180
+ country={field.value}
181
+ onCountryChange={field.onChange}
182
+ countryLabel={t('profile.contact.countryCode')}
183
+ invalid={!!errors.phoneNumber}
184
+ inputProps={register('phoneNumber')}
185
+ />
186
+ )}
187
+ />
188
+ <FieldDescription>{t('profile.contact.phoneHint')}</FieldDescription>
189
+ <FormFieldError error={errors.phoneNumber} />
190
+ </Field>
191
+ </FormSection>
192
+
193
+ <FormSection title={t('profile.role.title')} description={t('profile.role.description')}>
194
+ <div>
195
+ <Badge variant="secondary" size="lg">
196
+ {t(`common:roles.${user.role as 'owner'}`)}
197
+ </Badge>
198
+ </div>
199
+ </FormSection>
200
+ </FormLayout>
201
+ )
202
+ }
203
+
204
+ /** "Verification pending" banner for a requested email change. */
205
+ function PendingEmail({ user }: { user: User }) {
206
+ const { t } = useTranslation(['account', 'common'])
207
+ const resolve = useResolveEmailChange()
208
+ if (!user.pendingEmail) return null
209
+ const email = user.pendingEmail
210
+
211
+ async function run(accept: boolean) {
212
+ await resolve.mutateAsync({ id: user.id, accept })
213
+ toast.success(accept ? t('profile.pending.verified', { email }) : t('profile.pending.cancelled'))
214
+ }
215
+
216
+ return (
217
+ <div role="status" className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-(--shadow-card) sm:flex-row sm:items-center">
218
+ <div className="flex min-w-0 flex-1 gap-3">
219
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-fill-2 text-warning">
220
+ <MailCheck className="size-4" />
221
+ </span>
222
+ <div className="min-w-0">
223
+ <p className="text-sm font-medium">{t('profile.pending.title')}</p>
224
+ <p className="text-xs text-muted-foreground">
225
+ <Trans t={t} i18nKey="profile.pending.description" values={{ email }} components={{ b: <span className="font-medium text-foreground" /> }} />
226
+ </p>
227
+ </div>
228
+ </div>
229
+ <div className="flex flex-wrap gap-2">
230
+ <Button variant="ghost" size="sm" disabled={resolve.isPending} onClick={() => void run(false)}>
231
+ {t('profile.pending.cancel')}
232
+ </Button>
233
+ <Button variant="outline" size="sm" onClick={() => toast.success(t('profile.pending.resent', { email }))}>
234
+ {t('profile.pending.resend')}
235
+ </Button>
236
+ <Button variant="outline" size="sm" disabled={resolve.isPending} onClick={() => void run(true)}>
237
+ {t('profile.pending.simulate')}
238
+ </Button>
239
+ </div>
240
+ </div>
241
+ )
242
+ }