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.
- package/README.md +24 -3
- package/bin/create-freakpunch.mjs +227 -0
- package/files/README.md +19 -0
- package/files/app.config.ts +50 -0
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -6
- package/template/.dockerignore +17 -0
- package/template/.oxlintrc.json +9 -0
- package/template/AGENTS.md +6 -0
- package/template/BACKEND.md +108 -0
- package/template/CLAUDE.md +297 -0
- package/template/Dockerfile +19 -0
- package/template/_gitignore +26 -0
- package/template/components.json +27 -0
- package/template/design/COMPONENTS.md +145 -0
- package/template/design/DESIGN.md +212 -0
- package/template/design/PATTERNS.md +110 -0
- package/template/index.html +25 -0
- package/template/nginx.conf +32 -0
- package/template/package-lock.json +6484 -0
- package/template/package.json +51 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/robots.txt +2 -0
- package/template/src/app.config.ts +50 -0
- package/template/src/app.roles.ts +24 -0
- package/template/src/components/ui/alert-dialog.tsx +181 -0
- package/template/src/components/ui/avatar.tsx +116 -0
- package/template/src/components/ui/badge.tsx +57 -0
- package/template/src/components/ui/breadcrumb.tsx +130 -0
- package/template/src/components/ui/button.tsx +58 -0
- package/template/src/components/ui/calendar.tsx +277 -0
- package/template/src/components/ui/card.tsx +99 -0
- package/template/src/components/ui/chart.tsx +370 -0
- package/template/src/components/ui/checkbox.tsx +28 -0
- package/template/src/components/ui/combobox.tsx +329 -0
- package/template/src/components/ui/command.tsx +191 -0
- package/template/src/components/ui/dialog.tsx +162 -0
- package/template/src/components/ui/dropdown-menu.tsx +271 -0
- package/template/src/components/ui/empty.tsx +103 -0
- package/template/src/components/ui/field.tsx +236 -0
- package/template/src/components/ui/input-group.tsx +263 -0
- package/template/src/components/ui/input.tsx +45 -0
- package/template/src/components/ui/kbd.tsx +31 -0
- package/template/src/components/ui/label.tsx +17 -0
- package/template/src/components/ui/pagination.tsx +134 -0
- package/template/src/components/ui/popover.tsx +87 -0
- package/template/src/components/ui/radio-group.tsx +35 -0
- package/template/src/components/ui/select.tsx +204 -0
- package/template/src/components/ui/separator.tsx +25 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/components/ui/sidebar.tsx +741 -0
- package/template/src/components/ui/skeleton.tsx +13 -0
- package/template/src/components/ui/sonner.tsx +78 -0
- package/template/src/components/ui/spinner.tsx +42 -0
- package/template/src/components/ui/switch.tsx +29 -0
- package/template/src/components/ui/table.tsx +121 -0
- package/template/src/components/ui/tabs.tsx +191 -0
- package/template/src/components/ui/textarea.tsx +17 -0
- package/template/src/components/ui/toggle-group.tsx +94 -0
- package/template/src/components/ui/toggle.tsx +48 -0
- package/template/src/components/ui/tooltip.tsx +91 -0
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/api/http.ts +76 -0
- package/template/src/core/api/mock.ts +88 -0
- package/template/src/core/api/rest.ts +67 -0
- package/template/src/core/api/types.ts +21 -0
- package/template/src/core/audit/audit.service.ts +94 -0
- package/template/src/core/audit/hooks.ts +19 -0
- package/template/src/core/auth/auth-store.ts +52 -0
- package/template/src/core/auth/auth.service.ts +86 -0
- package/template/src/core/auth/can.tsx +39 -0
- package/template/src/core/auth/dev-role-switcher.tsx +64 -0
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +115 -0
- package/template/src/core/auth/require-auth.tsx +16 -0
- package/template/src/core/auth/require-permission.tsx +24 -0
- package/template/src/core/auth/roles.ts +9 -0
- package/template/src/core/auth/types.ts +20 -0
- package/template/src/core/auth/use-auth.ts +19 -0
- package/template/src/core/company/company.service.ts +94 -0
- package/template/src/core/company/hooks.ts +40 -0
- package/template/src/core/company/password-policy.ts +40 -0
- package/template/src/core/company/types.ts +46 -0
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/button-link.tsx +14 -0
- package/template/src/core/components/confirm-dialog.tsx +116 -0
- package/template/src/core/components/data-table/data-table.tsx +297 -0
- package/template/src/core/components/data-table/features.ts +24 -0
- package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
- package/template/src/core/components/data-table/toolbar.tsx +100 -0
- package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
- package/template/src/core/components/date-picker.tsx +71 -0
- package/template/src/core/components/file-attachments.tsx +84 -0
- package/template/src/core/components/form-error.tsx +9 -0
- package/template/src/core/components/form-page.tsx +93 -0
- package/template/src/core/components/image-upload.tsx +86 -0
- package/template/src/core/components/page.tsx +75 -0
- package/template/src/core/components/phone-input.tsx +58 -0
- package/template/src/core/components/search-select.tsx +57 -0
- package/template/src/core/components/segmented.tsx +41 -0
- package/template/src/core/components/states.tsx +97 -0
- package/template/src/core/components/status-badge.tsx +26 -0
- package/template/src/core/components/user-avatar.tsx +23 -0
- package/template/src/core/config/runtime.ts +16 -0
- package/template/src/core/config/types.ts +37 -0
- package/template/src/core/i18n/i18n.ts +69 -0
- package/template/src/core/i18n/i18next.d.ts +12 -0
- package/template/src/core/i18n/keys.ts +37 -0
- package/template/src/core/i18n/language-switcher.tsx +45 -0
- package/template/src/core/i18n/types.ts +17 -0
- package/template/src/core/i18n/use-format.ts +31 -0
- package/template/src/core/layout/app-shell.tsx +50 -0
- package/template/src/core/layout/app-sidebar.tsx +285 -0
- package/template/src/core/layout/command-menu.tsx +117 -0
- package/template/src/core/layout/top-bar.tsx +100 -0
- package/template/src/core/lib/csv.ts +27 -0
- package/template/src/core/lib/format.ts +87 -0
- package/template/src/core/lib/phone.ts +50 -0
- package/template/src/core/lib/storage.ts +27 -0
- package/template/src/core/modules/lazy-page.ts +9 -0
- package/template/src/core/modules/navigation.ts +39 -0
- package/template/src/core/modules/types.ts +55 -0
- package/template/src/core/notifications/hooks.ts +84 -0
- package/template/src/core/notifications/notification-center.tsx +251 -0
- package/template/src/core/notifications/notifications.service.ts +132 -0
- package/template/src/core/notifications/types.ts +70 -0
- package/template/src/core/preferences/preferences-store.ts +70 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/theme/brand-context.tsx +53 -0
- package/template/src/core/theme/brand.ts +29 -0
- package/template/src/core/theme/theme-context.tsx +62 -0
- package/template/src/core/theme/theme-options.ts +8 -0
- package/template/src/core/theme/theme-toggle.tsx +45 -0
- package/template/src/core/users/hooks.ts +51 -0
- package/template/src/core/users/types.ts +37 -0
- package/template/src/core/users/users.service.ts +167 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/index.css +321 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/locales/en/account.ts +180 -0
- package/template/src/locales/en/auth.ts +18 -0
- package/template/src/locales/en/common.ts +151 -0
- package/template/src/locales/en/company.ts +380 -0
- package/template/src/locales/en/dashboard.ts +46 -0
- package/template/src/locales/en/errors.ts +18 -0
- package/template/src/locales/en/notifications.ts +43 -0
- package/template/src/locales/en/settings.ts +25 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +58 -0
- package/template/src/locales/tr/account.ts +184 -0
- package/template/src/locales/tr/auth.ts +22 -0
- package/template/src/locales/tr/common.ts +154 -0
- package/template/src/locales/tr/company.ts +384 -0
- package/template/src/locales/tr/dashboard.ts +50 -0
- package/template/src/locales/tr/errors.ts +22 -0
- package/template/src/locales/tr/notifications.ts +47 -0
- package/template/src/locales/tr/settings.ts +29 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/main.tsx +16 -0
- package/template/src/modules/locales.ts +10 -0
- package/template/src/modules/registry.ts +17 -0
- package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +152 -0
- package/template/src/pages/no-access-page.tsx +11 -0
- package/template/src/pages/not-found-page.tsx +61 -0
- package/template/src/pages/settings/account/account.service.ts +128 -0
- package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
- package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
- package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
- package/template/src/pages/settings/account/hooks.ts +73 -0
- package/template/src/pages/settings/account/notifications-page.tsx +103 -0
- package/template/src/pages/settings/account/preferences-page.tsx +157 -0
- package/template/src/pages/settings/account/profile-page.tsx +242 -0
- package/template/src/pages/settings/account/security-page.tsx +364 -0
- package/template/src/pages/settings/account/types.ts +37 -0
- package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
- package/template/src/pages/settings/company/branding-page.tsx +278 -0
- package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
- package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
- package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
- package/template/src/pages/settings/company/general-page.tsx +234 -0
- package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
- package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
- package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
- package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
- package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
- package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
- package/template/src/pages/settings/company/modules-page.tsx +65 -0
- package/template/src/pages/settings/company/roles-page.tsx +116 -0
- package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
- package/template/src/pages/settings/company/users-page.tsx +262 -0
- package/template/src/pages/settings/settings-layout.tsx +57 -0
- package/template/src/pages/settings/settings-nav.ts +69 -0
- package/template/src/pages/settings/settings-routes.tsx +49 -0
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/providers.tsx +68 -0
- package/template/src/router.tsx +59 -0
- package/template/tools/check-design.mjs +73 -0
- package/template/tools/new-module.mjs +90 -0
- package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
- package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
- package/template/tools/templates/module/hooks.ts.tpl +57 -0
- package/template/tools/templates/module/index.ts.tpl +44 -0
- package/template/tools/templates/module/locales/en.ts.tpl +51 -0
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
- package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
- package/template/tools/templates/module/types.ts.tpl +32 -0
- package/template/tsconfig.app.json +32 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +23 -0
- 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
|
+
}
|