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,26 @@
1
+ import { Badge } from '@/components/ui/badge'
2
+ import { cn } from '@/lib/utils'
3
+
4
+ export type StatusTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
5
+
6
+ const dot: Record<StatusTone, string> = {
7
+ success: 'bg-success',
8
+ warning: 'bg-warning',
9
+ danger: 'bg-danger',
10
+ info: 'bg-info',
11
+ neutral: 'bg-muted-foreground/50',
12
+ }
13
+
14
+ /** Neutral outline pill with a semantic color dot: color only where it carries meaning. */
15
+ export function StatusBadge({ tone, children, className }: { tone: StatusTone; children: React.ReactNode; className?: string }) {
16
+ return (
17
+ <Badge variant="outline" className={cn('gap-1.5 font-medium', className)}>
18
+ <span aria-hidden className={cn('size-1.5 rounded-full', dot[tone])} />
19
+ {children}
20
+ </Badge>
21
+ )
22
+ }
23
+
24
+ export function StatusDot({ tone, className }: { tone: StatusTone; className?: string }) {
25
+ return <span aria-hidden className={cn('inline-block size-1.5 shrink-0 rounded-full', dot[tone], className)} />
26
+ }
@@ -0,0 +1,23 @@
1
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
2
+ import { initials } from '@/core/lib/format'
3
+ import { fullName, type User } from '@/core/users/types'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ /** Square-ish avatar: uploaded image, else initials (or the email's first letter for invites). */
7
+ export function UserAvatar({
8
+ user,
9
+ className,
10
+ }: {
11
+ user: Pick<User, 'firstName' | 'lastName' | 'email' | 'avatarUrl'>
12
+ className?: string
13
+ }) {
14
+ const name = fullName(user)
15
+ return (
16
+ <Avatar className={cn('size-8 rounded-lg', className)}>
17
+ {user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" className="rounded-lg object-cover" />}
18
+ <AvatarFallback className="rounded-lg bg-fill-2 text-xs font-medium">
19
+ {name ? initials(name) : user.email[0]?.toUpperCase()}
20
+ </AvatarFallback>
21
+ </Avatar>
22
+ )
23
+ }
@@ -0,0 +1,16 @@
1
+ import { appConfig } from '@/app.config'
2
+ import type { AppConfig, FeatureId } from './types'
3
+
4
+ // Typed as AppConfig so a project's app.config.ts may omit newer optional keys.
5
+ const config: AppConfig = appConfig
6
+
7
+ /** Optional built-in areas are on unless app.config.ts switches them off. */
8
+ export const isFeatureOn = (feature: FeatureId) => config.features?.[feature] ?? true
9
+
10
+ export const isDemo = config.demo ?? false
11
+
12
+ export const authMode = config.auth?.mode ?? 'mock'
13
+ export const csrfUrl = config.auth?.csrfUrl
14
+
15
+ /** `/` shows the landing page, or goes straight to the panel. */
16
+ export const showLanding = config.landing ?? true
@@ -0,0 +1,37 @@
1
+ import type { I18nKey } from '@/core/i18n/keys'
2
+ import type { BrandSetting } from '@/core/theme/brand'
3
+ import type { ModuleId } from '@/modules/registry'
4
+
5
+ export type Workspace = { id: string; name: string; plan?: I18nKey }
6
+
7
+ export type NavGroupConfig = { id: string; label: I18nKey }
8
+
9
+ /** Built-in areas a project can switch off; see core/config/runtime.ts. */
10
+ export type FeatureId =
11
+ | 'notifications'
12
+ | 'users'
13
+ | 'roles'
14
+ | 'securityPolicies'
15
+ | 'modules'
16
+ | 'integrations'
17
+ | 'auditLog'
18
+ | 'dangerZone'
19
+
20
+ /** mock: demo data; bearer: token in the Authorization header; cookie: session cookie + XSRF (Laravel Sanctum). */
21
+ export type AuthMode = 'mock' | 'bearer' | 'cookie'
22
+
23
+ export type AppConfig = {
24
+ name: string
25
+ brand: BrandSetting
26
+ workspaces: Workspace[]
27
+ navGroups: NavGroupConfig[]
28
+ modules: Partial<Record<ModuleId, boolean>>
29
+ currency: string
30
+ apiBaseUrl: string
31
+ /** The public demo: sample data, any password, no setup wizard. Real installs leave this off. */
32
+ demo?: boolean
33
+ /** Show the marketing landing page at `/`; when false, `/` opens the panel. Default true. */
34
+ landing?: boolean
35
+ features?: Partial<Record<FeatureId, boolean>>
36
+ auth?: { mode: AuthMode; csrfUrl?: string }
37
+ }
@@ -0,0 +1,69 @@
1
+ import i18n from 'i18next'
2
+ import { initReactI18next } from 'react-i18next'
3
+
4
+ import { customPermissions, customRoles, type CustomRole, type LocalizedText } from '@/app.roles'
5
+ import { storage } from '@/core/lib/storage'
6
+ import { namespaces, resources } from '@/locales'
7
+ import { languages, type Language } from './types'
8
+
9
+ /** localStorage key (`fp:lang`). */
10
+ const STORAGE_KEY = 'lang'
11
+
12
+ const isLanguage = (value: unknown): value is Language => languages.includes(value as Language)
13
+
14
+ /** Saved choice first; on the first visit Turkish browsers get TR, everyone else EN. */
15
+ function detectLanguage(): Language {
16
+ const saved = storage.get<string>(STORAGE_KEY)
17
+ if (isLanguage(saved)) return saved
18
+ const browser = typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language])
19
+ return browser.some((l) => l?.toLowerCase().startsWith('tr')) ? 'tr' : 'en'
20
+ }
21
+
22
+ void i18n.use(initReactI18next).init({
23
+ resources,
24
+ lng: detectLanguage(),
25
+ fallbackLng: 'en',
26
+ supportedLngs: languages,
27
+ ns: namespaces,
28
+ defaultNS: 'common',
29
+ interpolation: { escapeValue: false }, // React already escapes.
30
+ returnNull: false,
31
+ })
32
+
33
+ // Labels of project-defined roles and permissions live in src/app.roles.ts.
34
+ for (const lng of languages) {
35
+ const extraRoles = Object.entries(customRoles as Record<string, CustomRole>)
36
+ const extraPermissions = customPermissions as readonly { id: string; label: LocalizedText }[]
37
+ const pick = (list: [string, LocalizedText][]) => Object.fromEntries(list.map(([id, text]) => [id, text[lng]]))
38
+ i18n.addResourceBundle(lng, 'common', { roles: pick(extraRoles.map(([id, r]) => [id, r.label])) }, true, true)
39
+ i18n.addResourceBundle(
40
+ lng,
41
+ 'company',
42
+ {
43
+ roles: {
44
+ descriptions: pick(extraRoles.map(([id, r]) => [id, r.description])),
45
+ // Same id as customResourceId() in core/auth/permissions.ts.
46
+ resources: pick(extraPermissions.map((p) => [`custom_${p.id.replaceAll('.', '_')}`, p.label])),
47
+ },
48
+ },
49
+ true,
50
+ true,
51
+ )
52
+ }
53
+
54
+ function syncDocument(lng: string) {
55
+ document.documentElement.lang = lng
56
+ }
57
+ syncDocument(i18n.language)
58
+ i18n.on('languageChanged', syncDocument)
59
+
60
+ export function setLanguage(lng: Language) {
61
+ storage.set(STORAGE_KEY, lng)
62
+ void i18n.changeLanguage(lng)
63
+ }
64
+
65
+ export function currentLanguage(): Language {
66
+ return isLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en'
67
+ }
68
+
69
+ export { i18n }
@@ -0,0 +1,12 @@
1
+ import 'i18next'
2
+
3
+ import type { Resources } from '@/locales'
4
+
5
+ // Typed keys: t('…') only accepts keys that exist in the English resources.
6
+ declare module 'i18next' {
7
+ interface CustomTypeOptions {
8
+ defaultNS: 'common'
9
+ resources: Resources
10
+ returnNull: false
11
+ }
12
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import type { ParseKeys, TOptions } from 'i18next'
4
+
5
+ import { namespaces, type Namespace } from '@/locales'
6
+
7
+ /**
8
+ * A key from any namespace, written `ns:key` (or a bare `common` key).
9
+ * Use it in config-like data that is translated later: nav labels, route
10
+ * crumbs, quick actions, validation messages.
11
+ */
12
+ export type I18nKey = ParseKeys<Namespace[]>
13
+
14
+ type AnyT = (key: I18nKey, options?: TOptions) => string
15
+
16
+ /** `t` for I18nKey values. In screens prefer useTranslation(['<ns>', 'common']). */
17
+ export function useAnyT(): AnyT {
18
+ const { t } = useTranslation(namespaces)
19
+ return useCallback<AnyT>((key, options) => (t as unknown as AnyT)(key, options), [t])
20
+ }
21
+
22
+ /**
23
+ * Validation message for Zod schemas. Schemas are created once at module
24
+ * level, so they store a key (plus values) that FormFieldError translates
25
+ * at render time, and errors switch language instantly.
26
+ *
27
+ * z.string().min(2, vmsg('validation.minChars', { min: 2 }))
28
+ */
29
+ export function vmsg(key: I18nKey, values?: Record<string, string | number>) {
30
+ return values ? `${key}|${JSON.stringify(values)}` : key
31
+ }
32
+
33
+ export function translateMessage(t: AnyT, message: string) {
34
+ const [key, json] = message.split(/\|(.*)/s)
35
+ const values = json ? (JSON.parse(json) as Record<string, unknown>) : undefined
36
+ return t(key as I18nKey, { ...values, defaultValue: message })
37
+ }
@@ -0,0 +1,45 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { Languages } from 'lucide-react'
3
+
4
+ import { Button } from '@/components/ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuGroup,
9
+ DropdownMenuLabel,
10
+ DropdownMenuRadioGroup,
11
+ DropdownMenuRadioItem,
12
+ DropdownMenuTrigger,
13
+ } from '@/components/ui/dropdown-menu'
14
+ import { currentLanguage, setLanguage } from './i18n'
15
+ import { languageNames, languages, type Language } from './types'
16
+
17
+ /** Ghost button + menu; sits next to ThemeToggle in headers. */
18
+ export function LanguageSwitcher() {
19
+ const { t } = useTranslation()
20
+ const active = currentLanguage()
21
+ return (
22
+ <DropdownMenu>
23
+ <DropdownMenuTrigger
24
+ render={
25
+ <Button variant="ghost" size="sm" aria-label={t('language.change')} className="gap-1 px-2 text-muted-foreground">
26
+ <Languages />
27
+ <span className="tabular-nums text-[11px] uppercase">{active}</span>
28
+ </Button>
29
+ }
30
+ />
31
+ <DropdownMenuContent align="end" className="w-40">
32
+ <DropdownMenuGroup>
33
+ <DropdownMenuLabel>{t('language.label')}</DropdownMenuLabel>
34
+ <DropdownMenuRadioGroup value={active} onValueChange={(v) => setLanguage(v as Language)}>
35
+ {languages.map((lng) => (
36
+ <DropdownMenuRadioItem key={lng} value={lng} lang={lng}>
37
+ {languageNames[lng]}
38
+ </DropdownMenuRadioItem>
39
+ ))}
40
+ </DropdownMenuRadioGroup>
41
+ </DropdownMenuGroup>
42
+ </DropdownMenuContent>
43
+ </DropdownMenu>
44
+ )
45
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shape of a translation file derived from the English source. Turkish (and
3
+ * any future language) files are typed with this, so a missing or extra key
4
+ * is a compile error:
5
+ *
6
+ * const tr: Messages<typeof en> = { … }
7
+ */
8
+ export type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
9
+
10
+ export const languages = ['en', 'tr'] as const
11
+ export type Language = (typeof languages)[number]
12
+
13
+ /** BCP 47 locale used by Intl for each language. */
14
+ export const intlLocales: Record<Language, string> = { en: 'en-US', tr: 'tr-TR' }
15
+
16
+ /** Native names: always shown in their own language. */
17
+ export const languageNames: Record<Language, string> = { en: 'English', tr: 'Türkçe' }
@@ -0,0 +1,31 @@
1
+ import { useMemo } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { appConfig } from '@/app.config'
5
+ import { useCompanySettings } from '@/core/company/hooks'
6
+ import { createFormatters } from '@/core/lib/format'
7
+ import { usePreferences } from '@/core/preferences/preferences-store'
8
+ import { intlLocales, type Language } from './types'
9
+
10
+ /**
11
+ * Formatters for the active language, the user's time zone and date format,
12
+ * and the company currency. Re-renders the caller when any of them change.
13
+ * Use in components (and pass to memoized table columns as a dependency).
14
+ */
15
+ export function useFormat() {
16
+ const { i18n } = useTranslation()
17
+ const { timeZone, dateFormat } = usePreferences()
18
+ const lng = (i18n.resolvedLanguage ?? 'en') as Language
19
+ const currency = useCompanySettings().data?.general.currency ?? appConfig.currency
20
+
21
+ return useMemo(
22
+ () =>
23
+ createFormatters({
24
+ locale: intlLocales[lng] ?? intlLocales.en,
25
+ currency,
26
+ timeZone: timeZone === 'auto' ? undefined : timeZone,
27
+ dateFormat,
28
+ }),
29
+ [lng, currency, timeZone, dateFormat],
30
+ )
31
+ }
@@ -0,0 +1,50 @@
1
+ import { useState } from 'react'
2
+ import { Outlet } from 'react-router'
3
+
4
+ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
5
+ import { RequireAuth } from '@/core/auth/require-auth'
6
+ import { RequirePermissions } from '@/core/auth/require-permission'
7
+ import { storage } from '@/core/lib/storage'
8
+ import { preferencesStore } from '@/core/preferences/preferences-store'
9
+ import { AppSidebar } from './app-sidebar'
10
+ import { CommandMenuProvider } from './command-menu'
11
+ import { TopBar } from './top-bar'
12
+
13
+ const SIDEBAR_KEY = 'sidebar-open'
14
+
15
+ /**
16
+ * Warm canvas with the sidebar sitting on it and the page in a floating,
17
+ * rounded panel inset from the canvas edges. The panel scrolls internally.
18
+ */
19
+ export function AppShell() {
20
+ // Settings → Preferences → Sidebar: remember the last state, or always start expanded/collapsed.
21
+ const [sidebarOpen, setSidebarOpen] = useState(() => {
22
+ const start = preferencesStore.get().sidebarDefault
23
+ return start === 'remember' ? (storage.get<boolean>(SIDEBAR_KEY) ?? true) : start === 'expanded'
24
+ })
25
+
26
+ return (
27
+ <RequireAuth>
28
+ <CommandMenuProvider>
29
+ <SidebarProvider
30
+ open={sidebarOpen}
31
+ onOpenChange={(open) => {
32
+ storage.set(SIDEBAR_KEY, open)
33
+ setSidebarOpen(open)
34
+ }}
35
+ className="bg-canvas"
36
+ >
37
+ <AppSidebar />
38
+ <SidebarInset className="min-w-0 md:peer-data-[variant=inset]:h-[calc(100svh-var(--panel-inset)*2)] md:peer-data-[variant=inset]:overflow-hidden md:peer-data-[variant=inset]:rounded-(--radius-panel) md:peer-data-[variant=inset]:shadow-(--shadow-panel) md:peer-data-[variant=inset]:ring-1 md:peer-data-[variant=inset]:ring-border">
39
+ <TopBar />
40
+ <div id="main" className="flex-1 overflow-y-auto px-(--page-gutter-mobile) py-6 md:px-(--page-gutter) md:py-8">
41
+ <RequirePermissions>
42
+ <Outlet />
43
+ </RequirePermissions>
44
+ </div>
45
+ </SidebarInset>
46
+ </SidebarProvider>
47
+ </CommandMenuProvider>
48
+ </RequireAuth>
49
+ )
50
+ }
@@ -0,0 +1,285 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Link, useLocation, useNavigate } from 'react-router'
4
+ import { Check, ChevronsUpDown, LogOut, Plus, Search, UserRound } from 'lucide-react'
5
+
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuGroup,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from '@/components/ui/dropdown-menu'
15
+ import { Kbd } from '@/components/ui/kbd'
16
+ import {
17
+ Sidebar,
18
+ SidebarContent,
19
+ SidebarFooter,
20
+ SidebarGroup,
21
+ SidebarGroupContent,
22
+ SidebarGroupLabel,
23
+ SidebarHeader,
24
+ SidebarMenu,
25
+ SidebarMenuBadge,
26
+ SidebarMenuButton,
27
+ SidebarMenuItem,
28
+ SidebarRail,
29
+ useSidebar,
30
+ } from '@/components/ui/sidebar'
31
+ import { appConfig } from '@/app.config'
32
+ import { useAppName } from '@/core/company/use-app-name'
33
+ import type { Workspace } from '@/core/config/types'
34
+ import { useAuth } from '@/core/auth/use-auth'
35
+ import { useCompanySettings } from '@/core/company/hooks'
36
+ import { useTheme } from '@/core/theme/theme-context'
37
+ import { useAnyT } from '@/core/i18n/keys'
38
+ import { storage } from '@/core/lib/storage'
39
+ import { usePermissions } from '@/core/auth/can'
40
+ import { getNavGroups, isNavItemActive } from '@/core/modules/navigation'
41
+ import { fullName } from '@/core/users/types'
42
+ import { UserAvatar } from '@/core/components/user-avatar'
43
+ import type { NavItem } from '@/core/modules/types'
44
+ import { cn } from '@/lib/utils'
45
+ import { isMac, useCommandMenu } from './command-menu'
46
+
47
+ /** Raised, bordered pill for the active item; brand-colored icon. */
48
+ const navButtonClass = cn(
49
+ 'h-8 text-[13px] text-muted-foreground hover:text-foreground',
50
+ 'data-active:bg-raised data-active:text-foreground data-active:shadow-(--shadow-raised) data-active:ring-1 data-active:ring-border data-active:hover:bg-raised',
51
+ 'data-active:[&>svg]:text-primary',
52
+ )
53
+
54
+
55
+ export function AppSidebar() {
56
+ const t = useAnyT()
57
+ const { can } = usePermissions()
58
+ const navGroups = getNavGroups(can)
59
+ return (
60
+ <Sidebar variant="inset" collapsible="icon">
61
+ <SidebarHeader className="gap-2 pb-0">
62
+ <WorkspaceSwitcher />
63
+ <SearchButton />
64
+ </SidebarHeader>
65
+
66
+ <SidebarContent className="pt-2">
67
+ {navGroups.map((group) => (
68
+ <SidebarGroup key={group.id} className="py-1.5">
69
+ <SidebarGroupLabel className="label-caps h-7 text-[10.5px]">{t(group.label)}</SidebarGroupLabel>
70
+ <SidebarGroupContent>
71
+ <SidebarMenu className="gap-0.5">
72
+ {group.items.map((item) => (
73
+ <NavMenuItem key={item.path} item={item} />
74
+ ))}
75
+ </SidebarMenu>
76
+ </SidebarGroupContent>
77
+ </SidebarGroup>
78
+ ))}
79
+ </SidebarContent>
80
+
81
+ <SidebarFooter>
82
+ <UserCard />
83
+ </SidebarFooter>
84
+ <SidebarRail />
85
+ </Sidebar>
86
+ )
87
+ }
88
+
89
+ function NavMenuItem({ item }: { item: NavItem }) {
90
+ const { pathname } = useLocation()
91
+ const { setOpenMobile } = useSidebar()
92
+ const badge = item.useBadge?.()
93
+ const t = useAnyT()
94
+ const label = t(item.label)
95
+ const active = isNavItemActive(item, pathname)
96
+ const Icon = item.icon
97
+
98
+ return (
99
+ <SidebarMenuItem>
100
+ <SidebarMenuButton
101
+ isActive={active}
102
+ tooltip={label}
103
+ className={navButtonClass}
104
+ render={<Link to={item.path} aria-current={active ? 'page' : undefined} onClick={() => setOpenMobile(false)} />}
105
+ >
106
+ <Icon />
107
+ <span>{label}</span>
108
+ </SidebarMenuButton>
109
+ {badge ? (
110
+ <SidebarMenuBadge className="top-1.5! rounded-md bg-fill-2 tabular-nums text-[10.5px]">{badge}</SidebarMenuBadge>
111
+ ) : null}
112
+ </SidebarMenuItem>
113
+ )
114
+ }
115
+
116
+ function LogoTile({ name, logo, className }: { name: string; logo?: string; className?: string }) {
117
+ if (logo) {
118
+ return (
119
+ <div className={cn('flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg', className)}>
120
+ <img src={logo} alt="" className="max-h-full max-w-full object-contain" />
121
+ </div>
122
+ )
123
+ }
124
+ return (
125
+ <div
126
+ className={cn(
127
+ 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-foreground text-sm font-semibold text-background',
128
+ className,
129
+ )}
130
+ >
131
+ {name[0]?.toUpperCase()}
132
+ </div>
133
+ )
134
+ }
135
+
136
+ function WorkspaceSwitcher() {
137
+ const workspaces: Workspace[] = appConfig.workspaces
138
+ const appName = useAppName()
139
+ const [activeId, setActiveId] = useState(() => storage.get<string>('workspace') ?? workspaces[0]!.id)
140
+ const active = workspaces.find((w) => w.id === activeId) ?? workspaces[0]!
141
+ const t = useAnyT()
142
+ // The active workspace shows the company logo from Settings → Company → Branding.
143
+ const branding = useCompanySettings().data?.branding
144
+ const { resolvedTheme } = useTheme()
145
+ const logo = resolvedTheme === 'dark' ? (branding?.logoDark ?? branding?.logoLight) : (branding?.logoLight ?? branding?.logoDark)
146
+
147
+ const select = (id: string) => {
148
+ storage.set('workspace', id)
149
+ setActiveId(id)
150
+ }
151
+
152
+ // One workspace: just the company name, nothing to switch.
153
+ if (workspaces.length === 1) {
154
+ return (
155
+ <SidebarMenu>
156
+ <SidebarMenuItem>
157
+ <div className="flex h-12 items-center gap-2 rounded-xl bg-raised p-2 shadow-(--shadow-raised) ring-1 ring-border group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:shadow-none group-data-[collapsible=icon]:ring-0">
158
+ <LogoTile name={appName} logo={logo} />
159
+ <span className="truncate text-sm font-semibold group-data-[collapsible=icon]:hidden">{appName}</span>
160
+ </div>
161
+ </SidebarMenuItem>
162
+ </SidebarMenu>
163
+ )
164
+ }
165
+
166
+ return (
167
+ <SidebarMenu>
168
+ <SidebarMenuItem>
169
+ <DropdownMenu>
170
+ <DropdownMenuTrigger
171
+ render={
172
+ <SidebarMenuButton
173
+ size="lg"
174
+ className="h-12 rounded-xl bg-raised shadow-(--shadow-raised) ring-1 ring-border hover:bg-raised data-popup-open:bg-raised group-data-[collapsible=icon]:ring-0 group-data-[collapsible=icon]:shadow-none"
175
+ aria-label={t('shell.switchWorkspace')}
176
+ />
177
+ }
178
+ >
179
+ <LogoTile name={active.name} logo={logo} />
180
+ <div className="grid min-w-0 flex-1 text-left leading-tight">
181
+ <span className="truncate text-sm font-semibold">{active.name}</span>
182
+ {active.plan && <span className="truncate text-2xs text-muted-foreground">{t(active.plan)}</span>}
183
+ </div>
184
+ <ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
185
+ </DropdownMenuTrigger>
186
+ <DropdownMenuContent align="start" className="w-(--anchor-width) min-w-60">
187
+ <DropdownMenuGroup>
188
+ <DropdownMenuLabel className="label-caps">{t('shell.workspaces')}</DropdownMenuLabel>
189
+ {workspaces.map((w) => (
190
+ <DropdownMenuItem key={w.id} onClick={() => select(w.id)}>
191
+ <LogoTile name={w.name} className="size-6 rounded-md text-xs" />
192
+ <span className="flex-1 truncate">{w.name}</span>
193
+ {w.id === active.id && <Check className="text-primary" />}
194
+ </DropdownMenuItem>
195
+ ))}
196
+ </DropdownMenuGroup>
197
+ <DropdownMenuSeparator />
198
+ <DropdownMenuItem disabled>
199
+ <Plus />
200
+ {t('shell.addWorkspace')}
201
+ </DropdownMenuItem>
202
+ </DropdownMenuContent>
203
+ </DropdownMenu>
204
+ </SidebarMenuItem>
205
+ </SidebarMenu>
206
+ )
207
+ }
208
+
209
+ function SearchButton() {
210
+ const { open } = useCommandMenu()
211
+ const { t } = useTranslation()
212
+ const shortcut = isMac ? '⌘K' : 'Ctrl K'
213
+ return (
214
+ <SidebarMenu>
215
+ <SidebarMenuItem>
216
+ <SidebarMenuButton
217
+ onClick={open}
218
+ tooltip={t('shell.search')}
219
+ aria-label={t('shell.searchShortcut', { shortcut })}
220
+ aria-keyshortcuts={isMac ? 'Meta+K' : 'Control+K'}
221
+ className="h-8 rounded-lg bg-fill-1 text-muted-foreground ring-1 ring-border/60 hover:bg-fill-2 group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:ring-0"
222
+ >
223
+ <Search />
224
+ <span>{t('shell.search')}</span>
225
+ <Kbd className="ml-auto tabular-nums text-[10.5px] group-data-[collapsible=icon]:hidden">{shortcut}</Kbd>
226
+ </SidebarMenuButton>
227
+ </SidebarMenuItem>
228
+ </SidebarMenu>
229
+ )
230
+ }
231
+
232
+ function UserCard() {
233
+ const { user, logout } = useAuth()
234
+ const navigate = useNavigate()
235
+ const { t } = useTranslation()
236
+ if (!user) return null
237
+
238
+ return (
239
+ <SidebarMenu>
240
+ <SidebarMenuItem>
241
+ <DropdownMenu>
242
+ <DropdownMenuTrigger
243
+ render={
244
+ <SidebarMenuButton
245
+ size="lg"
246
+ className="h-12 rounded-xl bg-raised shadow-(--shadow-raised) ring-1 ring-border hover:bg-raised data-popup-open:bg-raised group-data-[collapsible=icon]:ring-0 group-data-[collapsible=icon]:shadow-none"
247
+ aria-label={t('shell.accountMenu')}
248
+ />
249
+ }
250
+ >
251
+ <UserAvatar user={user} className="size-8" />
252
+ <div className="grid min-w-0 flex-1 text-left leading-tight">
253
+ <span className="truncate text-sm font-medium">{fullName(user)}</span>
254
+ <span className="truncate text-2xs text-muted-foreground">{t(`roles.${user.role as 'owner'}`)}</span>
255
+ </div>
256
+ <ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
257
+ </DropdownMenuTrigger>
258
+ <DropdownMenuContent side="top" align="start" className="w-(--anchor-width) min-w-60">
259
+ <DropdownMenuGroup>
260
+ <DropdownMenuLabel className="flex flex-col">
261
+ <span className="text-sm font-medium text-foreground">{fullName(user)}</span>
262
+ <span className="text-xs font-normal text-muted-foreground">{user.email}</span>
263
+ </DropdownMenuLabel>
264
+ </DropdownMenuGroup>
265
+ <DropdownMenuSeparator />
266
+ <DropdownMenuItem onClick={() => navigate('/app/settings/profile')}>
267
+ <UserRound />
268
+ {t('shell.profile')}
269
+ </DropdownMenuItem>
270
+ <DropdownMenuSeparator />
271
+ <DropdownMenuItem
272
+ onClick={async () => {
273
+ await logout()
274
+ navigate('/login')
275
+ }}
276
+ >
277
+ <LogOut />
278
+ {t('shell.signOut')}
279
+ </DropdownMenuItem>
280
+ </DropdownMenuContent>
281
+ </DropdownMenu>
282
+ </SidebarMenuItem>
283
+ </SidebarMenu>
284
+ )
285
+ }