@omnifyjp/shell 0.1.1

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 (63) hide show
  1. package/README.md +476 -0
  2. package/dist/chunk-6JYWZJEY.js +123 -0
  3. package/dist/chunk-6JYWZJEY.js.map +1 -0
  4. package/dist/chunk-ACCHC3AM.js +57 -0
  5. package/dist/chunk-ACCHC3AM.js.map +1 -0
  6. package/dist/chunk-EJEVW4RO.js +49 -0
  7. package/dist/chunk-EJEVW4RO.js.map +1 -0
  8. package/dist/chunk-OHORC3F5.js +72 -0
  9. package/dist/chunk-OHORC3F5.js.map +1 -0
  10. package/dist/chunk-OMIE3Z5N.js +661 -0
  11. package/dist/chunk-OMIE3Z5N.js.map +1 -0
  12. package/dist/chunk-OYE3TXTK.js +37 -0
  13. package/dist/chunk-OYE3TXTK.js.map +1 -0
  14. package/dist/chunk-Q3QWQG6P.js +91 -0
  15. package/dist/chunk-Q3QWQG6P.js.map +1 -0
  16. package/dist/chunk-QNCYBLHC.js +189 -0
  17. package/dist/chunk-QNCYBLHC.js.map +1 -0
  18. package/dist/chunk-SHHZRZMM.js +83 -0
  19. package/dist/chunk-SHHZRZMM.js.map +1 -0
  20. package/dist/chunk-WCRLQ5M5.js +235 -0
  21. package/dist/chunk-WCRLQ5M5.js.map +1 -0
  22. package/dist/chunk-YVUVYTVZ.js +224 -0
  23. package/dist/chunk-YVUVYTVZ.js.map +1 -0
  24. package/dist/components/AppShell.d.ts +27 -0
  25. package/dist/components/AppShell.js +11 -0
  26. package/dist/components/AppShell.js.map +1 -0
  27. package/dist/components/Header.d.ts +11 -0
  28. package/dist/components/Header.js +6 -0
  29. package/dist/components/Header.js.map +1 -0
  30. package/dist/components/OrganizationSelector.d.ts +8 -0
  31. package/dist/components/OrganizationSelector.js +4 -0
  32. package/dist/components/OrganizationSelector.js.map +1 -0
  33. package/dist/components/OrganizationSetupModal.d.ts +5 -0
  34. package/dist/components/OrganizationSetupModal.js +4 -0
  35. package/dist/components/OrganizationSetupModal.js.map +1 -0
  36. package/dist/components/PageContainer.d.ts +105 -0
  37. package/dist/components/PageContainer.js +3 -0
  38. package/dist/components/PageContainer.js.map +1 -0
  39. package/dist/components/ServiceMenu.d.ts +11 -0
  40. package/dist/components/ServiceMenu.js +3 -0
  41. package/dist/components/ServiceMenu.js.map +1 -0
  42. package/dist/components/Sidebar.d.ts +11 -0
  43. package/dist/components/Sidebar.js +5 -0
  44. package/dist/components/Sidebar.js.map +1 -0
  45. package/dist/contexts/OrganizationContext.d.ts +26 -0
  46. package/dist/contexts/OrganizationContext.js +3 -0
  47. package/dist/contexts/OrganizationContext.js.map +1 -0
  48. package/dist/contexts/ThemeContext.d.ts +14 -0
  49. package/dist/contexts/ThemeContext.js +3 -0
  50. package/dist/contexts/ThemeContext.js.map +1 -0
  51. package/dist/hooks/useDateFormat.d.ts +28 -0
  52. package/dist/hooks/useDateFormat.js +4 -0
  53. package/dist/hooks/useDateFormat.js.map +1 -0
  54. package/dist/i18n.d.ts +38 -0
  55. package/dist/i18n.js +3 -0
  56. package/dist/i18n.js.map +1 -0
  57. package/dist/index.d.ts +17 -0
  58. package/dist/index.js +13 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/types.d.ts +89 -0
  61. package/dist/types.js +3 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +63 -0
package/README.md ADDED
@@ -0,0 +1,476 @@
1
+ # @omnifyjp/shell
2
+
3
+ A production-ready application shell framework for multi-tenant React apps. Provides a complete layout system with collapsible sidebar, header with organization switching, theme management, internationalization (vi/en/ja), and flexible page containers.
4
+
5
+ Built on top of [`@omnifyjp/ui`](https://www.npmjs.com/package/@omnifyjp/ui) components.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @omnifyjp/shell
11
+ # This automatically installs @omnifyjp/ui as a dependency
12
+ ```
13
+
14
+ ### Peer Dependencies
15
+
16
+ ```bash
17
+ npm install react react-dom react-router
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Quick Start
23
+
24
+ ```tsx
25
+ import { createBrowserRouter, RouterProvider } from 'react-router';
26
+ import { AppShell, initOmnifyI18n } from '@omnifyjp/shell';
27
+ import type { ShellConfig } from '@omnifyjp/shell';
28
+ import { LayoutDashboard, Users, Settings } from 'lucide-react';
29
+
30
+ // 1. Initialize i18n with your service translations
31
+ import enLocale from './locales/en.json';
32
+ import viLocale from './locales/vi.json';
33
+
34
+ initOmnifyI18n({
35
+ namespaces: {
36
+ myapp: { en: enLocale, vi: viLocale },
37
+ },
38
+ });
39
+
40
+ // 2. Define shell configuration
41
+ const config: ShellConfig = {
42
+ app: {
43
+ name: 'My App',
44
+ key: 'myapp',
45
+ logo: { icon: LayoutDashboard, text: 'My App', color: 'text-blue-600' },
46
+ },
47
+ sidebar: {
48
+ menuItems: [
49
+ { icon: LayoutDashboard, label: 'Dashboard', path: '/' },
50
+ { icon: Users, label: 'Users', path: '/users' },
51
+ { icon: Settings, label: 'Settings', path: '/settings' },
52
+ ],
53
+ },
54
+ header: {
55
+ user: {
56
+ name: 'John Doe',
57
+ email: 'john@example.com',
58
+ onLogout: () => console.log('logout'),
59
+ },
60
+ },
61
+ organization: {
62
+ organizations: [
63
+ { id: '1', name: 'Acme Corp', shortName: 'AC' },
64
+ ],
65
+ branches: [
66
+ { id: '1', name: 'Tokyo Office', organizationId: '1', location: 'Tokyo' },
67
+ { id: '2', name: 'Hanoi Office', organizationId: '1', location: 'Hanoi' },
68
+ ],
69
+ },
70
+ };
71
+
72
+ // 3. Set up routes with AppShell as the layout
73
+ const router = createBrowserRouter([
74
+ {
75
+ path: '/',
76
+ element: <AppShell config={config} />,
77
+ children: [
78
+ { index: true, element: <Dashboard /> },
79
+ { path: 'users', element: <Users /> },
80
+ { path: 'settings', element: <Settings /> },
81
+ ],
82
+ },
83
+ ]);
84
+
85
+ function App() {
86
+ return <RouterProvider router={router} />;
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Core Components
93
+
94
+ ### AppShell
95
+
96
+ The main application wrapper. Combines `ThemeProvider` + `I18nextProvider` + `OrganizationProvider`, then renders the sidebar, header, and a React Router `<Outlet />`.
97
+
98
+ ```tsx
99
+ import { AppShell } from '@omnifyjp/shell';
100
+
101
+ <AppShell config={config} extra={<ChatWidget />} />
102
+ ```
103
+
104
+ | Prop | Type | Description |
105
+ |------|------|-------------|
106
+ | `config` | `ShellConfig` | Full shell configuration (see below) |
107
+ | `extra` | `ReactNode` | Optional floating elements (e.g., chat widget) |
108
+
109
+ ### Sidebar
110
+
111
+ Collapsible left navigation with context switching, expandable groups, and tooltips when collapsed.
112
+
113
+ Features:
114
+ - Logo area with app icon
115
+ - Main menu items with nested groups
116
+ - Context switching popover (e.g., project switcher with search)
117
+ - Recent items section
118
+ - Collapse/expand toggle
119
+ - Organization selector badge
120
+
121
+ ### Header
122
+
123
+ Top navigation bar with:
124
+ - Organization/branch selector
125
+ - Service menu (grid of available apps)
126
+ - Global search input
127
+ - Notification dropdown with count badge
128
+ - Custom action buttons
129
+ - User menu (profile, settings, logout)
130
+
131
+ ### PageContainer
132
+
133
+ Flexible page layout wrapper with three variants:
134
+
135
+ ```tsx
136
+ import {
137
+ StandardPageContainer,
138
+ SplitPageContainer,
139
+ FullWidthPageContainer,
140
+ } from '@omnifyjp/shell';
141
+
142
+ // Standard — padded page with title bar
143
+ <StandardPageContainer title="Users" subtitle="Manage team members" extra={<Button>Add User</Button>}>
144
+ <UserTable />
145
+ </StandardPageContainer>
146
+
147
+ // Split — two-column layout with sidebar
148
+ <SplitPageContainer title="Settings" sidebar={<SettingsNav />} sidebarPosition="left">
149
+ <SettingsForm />
150
+ </SplitPageContainer>
151
+
152
+ // Full width — no padding (for Kanban boards, Gantt charts)
153
+ <FullWidthPageContainer>
154
+ <KanbanBoard />
155
+ </FullWidthPageContainer>
156
+ ```
157
+
158
+ | Prop | Type | Default | Description |
159
+ |------|------|---------|-------------|
160
+ | `title` | `string` | — | Page heading |
161
+ | `subtitle` | `string` | — | Description text below title |
162
+ | `extra` | `ReactNode` | — | Header actions (buttons, etc.) |
163
+ | `children` | `ReactNode` | — | Main content |
164
+ | `footer` | `ReactNode` | — | Footer content |
165
+ | `variant` | `'standard' \| 'split' \| 'full'` | `'standard'` | Layout mode |
166
+ | `sidebar` | `ReactNode` | — | Sidebar content (split mode) |
167
+ | `sidebarPosition` | `'left' \| 'right'` | `'right'` | Sidebar placement |
168
+ | `sidebarWidth` | `string` | `'w-80'` | Sidebar Tailwind width class |
169
+
170
+ ### OrganizationSelector
171
+
172
+ Two-step modal for switching organizations and branches:
173
+
174
+ 1. **Step 1**: Select organization (shows description + branch count)
175
+ 2. **Step 2**: Select branch (searchable, shows location)
176
+
177
+ ```tsx
178
+ import { OrganizationSelector } from '@omnifyjp/shell';
179
+
180
+ <OrganizationSelector variant="header" /> // in header
181
+ <OrganizationSelector variant="sidebar" /> // in sidebar
182
+ ```
183
+
184
+ ### ServiceMenu
185
+
186
+ Searchable dropdown grid listing all available services/apps:
187
+
188
+ ```tsx
189
+ const config: ShellConfig = {
190
+ services: [
191
+ {
192
+ category: 'HR',
193
+ items: [
194
+ { icon: Users, label: 'Attendance', url: '/attendance', color: 'bg-blue-600' },
195
+ { icon: Calendar, label: 'Leave', url: '/leave', color: 'bg-green-600' },
196
+ ],
197
+ },
198
+ ],
199
+ };
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Configuration: `ShellConfig`
205
+
206
+ ```typescript
207
+ interface ShellConfig {
208
+ app: {
209
+ name: string; // App display name
210
+ key: string; // App identifier
211
+ logo: {
212
+ icon: LucideIcon; // Logo icon
213
+ text: string; // Logo text
214
+ color?: string; // Icon color class
215
+ };
216
+ };
217
+
218
+ sidebar: {
219
+ menuItems: SidebarMenuItem[]; // Navigation items
220
+ contextMenu?: { // Context switcher (e.g., project selector)
221
+ paramName: string; // URL param name
222
+ getItems: () => ContextItem[];
223
+ getHeader?: () => ReactNode;
224
+ getFooterItems?: () => ContextItem[];
225
+ };
226
+ recentItems?: { // Recent items section
227
+ title: string;
228
+ items: { label: string; path: string; badge?: string; color?: string }[];
229
+ };
230
+ footer?: ReactNode; // Custom sidebar footer
231
+ };
232
+
233
+ services?: ServiceCategory[]; // Service menu grid
234
+
235
+ header?: {
236
+ searchPlaceholder?: string;
237
+ showSearch?: boolean; // Default: true
238
+ actions?: ReactNode; // Custom header buttons
239
+ notifications?: {
240
+ count: number;
241
+ content: ReactNode;
242
+ };
243
+ user?: {
244
+ name: string;
245
+ email?: string;
246
+ avatar?: string;
247
+ onLogout?: () => void;
248
+ };
249
+ };
250
+
251
+ organization?: {
252
+ organizations: Organization[];
253
+ branches: Branch[];
254
+ onOrganizationChange?: (org: Organization) => void;
255
+ onBranchChange?: (branch: Branch) => void;
256
+ };
257
+ }
258
+ ```
259
+
260
+ ### SidebarMenuItem
261
+
262
+ ```typescript
263
+ interface SidebarMenuItem {
264
+ icon: LucideIcon;
265
+ label: string;
266
+ path?: string; // Omit for collapsible groups
267
+ badge?: number; // Notification count
268
+ children?: SidebarMenuItem[]; // Nested items
269
+ }
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Context Providers
275
+
276
+ ### ThemeProvider
277
+
278
+ Dark mode management with system preference detection:
279
+
280
+ ```tsx
281
+ import { useTheme } from '@omnifyjp/shell';
282
+
283
+ function ThemeToggle() {
284
+ const { theme, setTheme } = useTheme();
285
+ // theme: 'light' | 'dark' | 'system'
286
+
287
+ return (
288
+ <select value={theme} onChange={(e) => setTheme(e.target.value)}>
289
+ <option value="light">Light</option>
290
+ <option value="dark">Dark</option>
291
+ <option value="system">System</option>
292
+ </select>
293
+ );
294
+ }
295
+ ```
296
+
297
+ Persists selection to `localStorage.omnify_theme`.
298
+
299
+ ### OrganizationProvider
300
+
301
+ Multi-tenant organization/branch management:
302
+
303
+ ```tsx
304
+ import { useOrganization } from '@omnifyjp/shell';
305
+
306
+ function OrgInfo() {
307
+ const {
308
+ selectedOrganization, // Organization | null
309
+ selectedBranch, // Branch | null
310
+ setSelectedOrganization,
311
+ setSelectedBranch,
312
+ getBranchesByOrg, // (orgId: string) => Branch[]
313
+ isSetupComplete, // true when both selected
314
+ } = useOrganization();
315
+
316
+ return <p>{selectedOrganization?.name} — {selectedBranch?.name}</p>;
317
+ }
318
+ ```
319
+
320
+ Persists selections to `localStorage.selectedOrganizationId` and `localStorage.selectedBranchId`.
321
+
322
+ ---
323
+
324
+ ## i18n (Internationalization)
325
+
326
+ ### Supported Languages
327
+
328
+ | Code | Language |
329
+ |------|----------|
330
+ | `vi` | Tieng Viet (default, fallback) |
331
+ | `en` | English |
332
+ | `ja` | Japanese |
333
+
334
+ ### Shell Namespace
335
+
336
+ The shell comes pre-loaded with translations for common UI strings: sidebar labels, header actions, status/priority names, settings pages, login forms, organization selection, date picker, etc.
337
+
338
+ ### Adding Service Translations
339
+
340
+ ```tsx
341
+ import { initOmnifyI18n } from '@omnifyjp/shell';
342
+
343
+ // Add your service namespace
344
+ initOmnifyI18n({
345
+ namespaces: {
346
+ myapp: {
347
+ en: { dashboard: { title: 'Dashboard' } },
348
+ vi: { dashboard: { title: 'Bang dieu khien' } },
349
+ ja: { dashboard: { title: 'dasshubodo' } },
350
+ },
351
+ },
352
+ });
353
+
354
+ // Use in components
355
+ import { useTranslation } from 'react-i18next';
356
+
357
+ function Dashboard() {
358
+ const { t } = useTranslation(['myapp', 'shell']);
359
+ return <h1>{t('dashboard.title')}</h1>;
360
+ // Falls back to 'shell' namespace for common keys
361
+ }
362
+ ```
363
+
364
+ ### Changing Language
365
+
366
+ ```tsx
367
+ import { changeLanguage } from '@omnifyjp/shell';
368
+
369
+ await changeLanguage('en'); // Persists to localStorage
370
+ ```
371
+
372
+ ---
373
+
374
+ ## Hooks
375
+
376
+ ### useDateFormat
377
+
378
+ Locale-aware date formatting with timezone support:
379
+
380
+ ```tsx
381
+ import { useDateFormat } from '@omnifyjp/shell';
382
+
383
+ function EventDate({ date }: { date: Date }) {
384
+ const {
385
+ formatDate, // '01/15/2026' (locale-dependent)
386
+ formatDateTime, // '01/15/2026 14:30'
387
+ formatRelativeTime, // '5 minutes ago'
388
+ formatShortDate, // '01/15'
389
+ formatMonthYear, // 'January 2026'
390
+ formatDayOfWeek, // 'Mon'
391
+ formatDateLong, // 'January 15, 2026'
392
+ timezone, // 'Asia/Ho_Chi_Minh'
393
+ setTimezone, // Change timezone
394
+ dateFnsLocale, // date-fns Locale object
395
+ } = useDateFormat();
396
+
397
+ return <time>{formatDateTime(date)}</time>;
398
+ }
399
+ ```
400
+
401
+ Supported timezones: `Asia/Ho_Chi_Minh`, `Asia/Tokyo`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `UTC`.
402
+
403
+ ---
404
+
405
+ ## Package Exports
406
+
407
+ ```
408
+ @omnifyjp/shell → all exports (barrel)
409
+ @omnifyjp/shell/components/AppShell → AppShell
410
+ @omnifyjp/shell/components/Sidebar → Sidebar
411
+ @omnifyjp/shell/components/Header → Header
412
+ @omnifyjp/shell/components/PageContainer → PageContainer, Standard/Split/FullWidth variants
413
+ @omnifyjp/shell/components/ServiceMenu → ServiceMenu
414
+ @omnifyjp/shell/components/OrganizationSelector → OrganizationSelector
415
+ @omnifyjp/shell/contexts/ThemeContext → ThemeProvider, useTheme
416
+ @omnifyjp/shell/contexts/OrganizationContext → OrganizationProvider, useOrganization
417
+ @omnifyjp/shell/hooks/useDateFormat → useDateFormat, timezoneLabels
418
+ @omnifyjp/shell/i18n → i18n, initOmnifyI18n, changeLanguage
419
+ @omnifyjp/shell/types → ShellConfig, Organization, Branch, etc.
420
+ ```
421
+
422
+ ---
423
+
424
+ ## TypeScript
425
+
426
+ Full type definitions are included. Key types:
427
+
428
+ ```typescript
429
+ import type {
430
+ ShellConfig,
431
+ SidebarMenuItem,
432
+ ServiceCategory,
433
+ ServiceItem,
434
+ Organization,
435
+ Branch,
436
+ PageContainerProps,
437
+ } from '@omnifyjp/shell';
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Dependencies
443
+
444
+ This package depends on:
445
+
446
+ | Package | Purpose |
447
+ |---------|---------|
448
+ | `@omnifyjp/ui` | UI component primitives (auto-installed) |
449
+ | `i18next` | Internationalization core |
450
+ | `react-i18next` | React i18n bindings |
451
+ | `date-fns` | Date formatting |
452
+ | `lucide-react` | Icons |
453
+ | `sonner` | Toast notifications |
454
+
455
+ Peer dependencies (you provide):
456
+
457
+ | Package | Version |
458
+ |---------|---------|
459
+ | `react` | >=18 |
460
+ | `react-dom` | >=18 |
461
+ | `react-router` | >=7 |
462
+
463
+ ---
464
+
465
+ ## Related Packages
466
+
467
+ | Package | Description |
468
+ |---------|-------------|
469
+ | [`@omnifyjp/ui`](https://www.npmjs.com/package/@omnifyjp/ui) | 53 Shadcn primitives + 14 domain components |
470
+ | [`@omnifyjp/editor`](https://www.npmjs.com/package/@omnifyjp/editor) | Rich text editors (Tiptap + BlockNote) |
471
+
472
+ ---
473
+
474
+ ## License
475
+
476
+ MIT
@@ -0,0 +1,123 @@
1
+ import { changeLanguage } from './chunk-OMIE3Z5N.js';
2
+ import { useState, useCallback } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { format, formatDistanceToNow } from 'date-fns';
5
+ import { ja, enUS, vi } from 'date-fns/locale';
6
+
7
+ var timezoneLabels = {
8
+ "Asia/Ho_Chi_Minh": "(UTC+7) Ho Chi Minh",
9
+ "Asia/Tokyo": "(UTC+9) Tokyo",
10
+ "America/New_York": "(UTC-5) New York",
11
+ "America/Los_Angeles": "(UTC-8) Los Angeles",
12
+ "Europe/London": "(UTC+0) London",
13
+ "UTC": "(UTC+0) UTC"
14
+ };
15
+ var dateFnsLocales = {
16
+ vi: vi,
17
+ en: enUS,
18
+ ja: ja
19
+ };
20
+ var dateFormatPatterns = {
21
+ vi: "dd/MM/yyyy",
22
+ en: "MM/dd/yyyy",
23
+ ja: "yyyy/MM/dd"
24
+ };
25
+ var dateTimeFormatPatterns = {
26
+ vi: "dd/MM/yyyy HH:mm",
27
+ en: "MM/dd/yyyy HH:mm",
28
+ ja: "yyyy/MM/dd HH:mm"
29
+ };
30
+ var shortDatePatterns = {
31
+ vi: "dd/MM",
32
+ en: "MM/dd",
33
+ ja: "MM/dd"
34
+ };
35
+ var localeTags = {
36
+ vi: "vi-VN",
37
+ en: "en-US",
38
+ ja: "ja-JP"
39
+ };
40
+ function toDate(d) {
41
+ return typeof d === "string" ? new Date(d) : d;
42
+ }
43
+ function loadSavedTimezone() {
44
+ if (typeof window === "undefined") return "Asia/Ho_Chi_Minh";
45
+ const saved = localStorage.getItem("omnify_timezone");
46
+ if (saved && saved in timezoneLabels) return saved;
47
+ return "Asia/Ho_Chi_Minh";
48
+ }
49
+ function useDateFormat() {
50
+ const { i18n } = useTranslation();
51
+ const language = i18n.language || "vi";
52
+ const locale = dateFnsLocales[language] || vi;
53
+ const [timezone, setTimezoneState] = useState(() => loadSavedTimezone());
54
+ const setTimezone = useCallback((tz) => {
55
+ localStorage.setItem("omnify_timezone", tz);
56
+ setTimezoneState(tz);
57
+ }, []);
58
+ const setLanguage = useCallback((lang) => {
59
+ changeLanguage(lang);
60
+ }, []);
61
+ const formatDate = useCallback(
62
+ (date) => {
63
+ return format(toDate(date), dateFormatPatterns[language] || "dd/MM/yyyy", { locale });
64
+ },
65
+ [language, locale]
66
+ );
67
+ const formatDateTime = useCallback(
68
+ (date) => {
69
+ return format(toDate(date), dateTimeFormatPatterns[language] || "dd/MM/yyyy HH:mm", { locale });
70
+ },
71
+ [language, locale]
72
+ );
73
+ const formatRelativeTime = useCallback(
74
+ (date) => {
75
+ return formatDistanceToNow(toDate(date), { addSuffix: true, locale });
76
+ },
77
+ [locale]
78
+ );
79
+ const formatShortDate = useCallback(
80
+ (date) => {
81
+ return format(toDate(date), shortDatePatterns[language] || "dd/MM", { locale });
82
+ },
83
+ [language, locale]
84
+ );
85
+ const formatMonthYear = useCallback(
86
+ (date) => {
87
+ return format(toDate(date), "MMMM yyyy", { locale });
88
+ },
89
+ [locale]
90
+ );
91
+ const formatDayOfWeek = useCallback(
92
+ (date) => {
93
+ return format(toDate(date), "EEE", { locale });
94
+ },
95
+ [locale]
96
+ );
97
+ const formatDateLong = useCallback(
98
+ (date) => {
99
+ return format(toDate(date), "PPP", { locale });
100
+ },
101
+ [locale]
102
+ );
103
+ return {
104
+ language,
105
+ timezone,
106
+ setLanguage,
107
+ setTimezone,
108
+ formatDate,
109
+ formatDateTime,
110
+ formatRelativeTime,
111
+ formatShortDate,
112
+ formatMonthYear,
113
+ formatDayOfWeek,
114
+ formatDateLong,
115
+ dateFnsLocale: locale,
116
+ localeTag: localeTags[language] || "vi-VN",
117
+ languageNames: { vi: "Ti\u1EBFng Vi\u1EC7t", en: "English", ja: "\u65E5\u672C\u8A9E" }
118
+ };
119
+ }
120
+
121
+ export { timezoneLabels, useDateFormat };
122
+ //# sourceMappingURL=chunk-6JYWZJEY.js.map
123
+ //# sourceMappingURL=chunk-6JYWZJEY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/useDateFormat.ts"],"names":["viLocale","jaLocale"],"mappings":";;;;;;AAkBO,IAAM,cAAA,GAAoD;AAAA,EAC/D,kBAAA,EAAoB,qBAAA;AAAA,EACpB,YAAA,EAAc,eAAA;AAAA,EACd,kBAAA,EAAoB,kBAAA;AAAA,EACpB,qBAAA,EAAuB,qBAAA;AAAA,EACvB,eAAA,EAAiB,gBAAA;AAAA,EACjB,KAAA,EAAO;AACT;AAEA,IAAM,cAAA,GAAoD;AAAA,EACxD,EAAA,EAAIA,EAAA;AAAA,EACJ,EAAA,EAAI,IAAA;AAAA,EACJ,EAAA,EAAIC;AACN,CAAA;AAEA,IAAM,kBAAA,GAAwD;AAAA,EAC5D,EAAA,EAAI,YAAA;AAAA,EACJ,EAAA,EAAI,YAAA;AAAA,EACJ,EAAA,EAAI;AACN,CAAA;AAEA,IAAM,sBAAA,GAA4D;AAAA,EAChE,EAAA,EAAI,kBAAA;AAAA,EACJ,EAAA,EAAI,kBAAA;AAAA,EACJ,EAAA,EAAI;AACN,CAAA;AAEA,IAAM,iBAAA,GAAuD;AAAA,EAC3D,EAAA,EAAI,OAAA;AAAA,EACJ,EAAA,EAAI,OAAA;AAAA,EACJ,EAAA,EAAI;AACN,CAAA;AAEA,IAAM,UAAA,GAAgD;AAAA,EACpD,EAAA,EAAI,OAAA;AAAA,EACJ,EAAA,EAAI,OAAA;AAAA,EACJ,EAAA,EAAI;AACN,CAAA;AAEA,SAAS,OAAO,CAAA,EAAwB;AACtC,EAAA,OAAO,OAAO,CAAA,KAAM,QAAA,GAAW,IAAI,IAAA,CAAK,CAAC,CAAA,GAAI,CAAA;AAC/C;AAEA,SAAS,iBAAA,GAAuC;AAC9C,EAAA,IAAI,OAAO,MAAA,KAAW,WAAA,EAAa,OAAO,kBAAA;AAC1C,EAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,OAAA,CAAQ,iBAAiB,CAAA;AACpD,EAAA,IAAI,KAAA,IAAS,KAAA,IAAS,cAAA,EAAgB,OAAO,KAAA;AAC7C,EAAA,OAAO,kBAAA;AACT;AAMO,SAAS,aAAA,GAAgB;AAC9B,EAAA,MAAM,EAAE,IAAA,EAAK,GAAI,cAAA,EAAe;AAChC,EAAA,MAAM,QAAA,GAAY,KAAK,QAAA,IAAY,IAAA;AACnC,EAAA,MAAM,MAAA,GAAS,cAAA,CAAe,QAAQ,CAAA,IAAKD,EAAA;AAE3C,EAAA,MAAM,CAAC,QAAA,EAAU,gBAAgB,IAAI,QAAA,CAA4B,MAAM,mBAAmB,CAAA;AAE1F,EAAA,MAAM,WAAA,GAAc,WAAA,CAAY,CAAC,EAAA,KAA0B;AACzD,IAAA,YAAA,CAAa,OAAA,CAAQ,mBAAmB,EAAE,CAAA;AAC1C,IAAA,gBAAA,CAAiB,EAAE,CAAA;AAAA,EACrB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,WAAA,GAAc,WAAA,CAAY,CAAC,IAAA,KAA4B;AAC3D,IAAA,cAAA,CAAe,IAAI,CAAA;AAAA,EACrB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,UAAA,GAAa,WAAA;AAAA,IACjB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA,EAAG,kBAAA,CAAmB,QAAQ,CAAA,IAAK,YAAA,EAAc,EAAE,MAAA,EAAQ,CAAA;AAAA,IACtF,CAAA;AAAA,IACA,CAAC,UAAU,MAAM;AAAA,GACnB;AAEA,EAAA,MAAM,cAAA,GAAiB,WAAA;AAAA,IACrB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA,EAAG,sBAAA,CAAuB,QAAQ,CAAA,IAAK,kBAAA,EAAoB,EAAE,MAAA,EAAQ,CAAA;AAAA,IAChG,CAAA;AAAA,IACA,CAAC,UAAU,MAAM;AAAA,GACnB;AAEA,EAAA,MAAM,kBAAA,GAAqB,WAAA;AAAA,IACzB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,mBAAA,CAAoB,OAAO,IAAI,CAAA,EAAG,EAAE,SAAA,EAAW,IAAA,EAAM,QAAQ,CAAA;AAAA,IACtE,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,MAAM,eAAA,GAAkB,WAAA;AAAA,IACtB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA,EAAG,iBAAA,CAAkB,QAAQ,CAAA,IAAK,OAAA,EAAS,EAAE,MAAA,EAAQ,CAAA;AAAA,IAChF,CAAA;AAAA,IACA,CAAC,UAAU,MAAM;AAAA,GACnB;AAEA,EAAA,MAAM,eAAA,GAAkB,WAAA;AAAA,IACtB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,OAAO,MAAA,CAAO,IAAI,GAAG,WAAA,EAAa,EAAE,QAAQ,CAAA;AAAA,IACrD,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,MAAM,eAAA,GAAkB,WAAA;AAAA,IACtB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,OAAO,MAAA,CAAO,IAAI,GAAG,KAAA,EAAO,EAAE,QAAQ,CAAA;AAAA,IAC/C,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,MAAM,cAAA,GAAiB,WAAA;AAAA,IACrB,CAAC,IAAA,KAAgC;AAC/B,MAAA,OAAO,OAAO,MAAA,CAAO,IAAI,GAAG,KAAA,EAAO,EAAE,QAAQ,CAAA;AAAA,IAC/C,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AAEA,EAAA,OAAO;AAAA,IACL,QAAA;AAAA,IACA,QAAA;AAAA,IACA,WAAA;AAAA,IACA,WAAA;AAAA,IACA,UAAA;AAAA,IACA,cAAA;AAAA,IACA,kBAAA;AAAA,IACA,eAAA;AAAA,IACA,eAAA;AAAA,IACA,eAAA;AAAA,IACA,cAAA;AAAA,IACA,aAAA,EAAe,MAAA;AAAA,IACf,SAAA,EAAW,UAAA,CAAW,QAAQ,CAAA,IAAK,OAAA;AAAA,IACnC,eAAe,EAAE,EAAA,EAAI,wBAAc,EAAA,EAAI,SAAA,EAAW,IAAI,oBAAA;AAAM,GAC9D;AACF","file":"chunk-6JYWZJEY.js","sourcesContent":["import { useCallback, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { format, formatDistanceToNow } from 'date-fns';\nimport { vi as viLocale } from 'date-fns/locale';\nimport { enUS } from 'date-fns/locale';\nimport { ja as jaLocale } from 'date-fns/locale';\nimport type { Locale } from 'date-fns';\nimport { changeLanguage } from '../i18n';\nimport type { SupportedLanguage } from '../i18n';\n\nexport type SupportedTimezone =\n | 'Asia/Ho_Chi_Minh'\n | 'Asia/Tokyo'\n | 'America/New_York'\n | 'America/Los_Angeles'\n | 'Europe/London'\n | 'UTC';\n\nexport const timezoneLabels: Record<SupportedTimezone, string> = {\n 'Asia/Ho_Chi_Minh': '(UTC+7) Ho Chi Minh',\n 'Asia/Tokyo': '(UTC+9) Tokyo',\n 'America/New_York': '(UTC-5) New York',\n 'America/Los_Angeles': '(UTC-8) Los Angeles',\n 'Europe/London': '(UTC+0) London',\n 'UTC': '(UTC+0) UTC',\n};\n\nconst dateFnsLocales: Record<SupportedLanguage, Locale> = {\n vi: viLocale,\n en: enUS,\n ja: jaLocale,\n};\n\nconst dateFormatPatterns: Record<SupportedLanguage, string> = {\n vi: 'dd/MM/yyyy',\n en: 'MM/dd/yyyy',\n ja: 'yyyy/MM/dd',\n};\n\nconst dateTimeFormatPatterns: Record<SupportedLanguage, string> = {\n vi: 'dd/MM/yyyy HH:mm',\n en: 'MM/dd/yyyy HH:mm',\n ja: 'yyyy/MM/dd HH:mm',\n};\n\nconst shortDatePatterns: Record<SupportedLanguage, string> = {\n vi: 'dd/MM',\n en: 'MM/dd',\n ja: 'MM/dd',\n};\n\nconst localeTags: Record<SupportedLanguage, string> = {\n vi: 'vi-VN',\n en: 'en-US',\n ja: 'ja-JP',\n};\n\nfunction toDate(d: Date | string): Date {\n return typeof d === 'string' ? new Date(d) : d;\n}\n\nfunction loadSavedTimezone(): SupportedTimezone {\n if (typeof window === 'undefined') return 'Asia/Ho_Chi_Minh';\n const saved = localStorage.getItem('omnify_timezone');\n if (saved && saved in timezoneLabels) return saved as SupportedTimezone;\n return 'Asia/Ho_Chi_Minh';\n}\n\n/**\n * Hook that provides date formatting utilities.\n * Reads the current language from i18next automatically.\n */\nexport function useDateFormat() {\n const { i18n } = useTranslation();\n const language = (i18n.language || 'vi') as SupportedLanguage;\n const locale = dateFnsLocales[language] || viLocale;\n\n const [timezone, setTimezoneState] = useState<SupportedTimezone>(() => loadSavedTimezone());\n\n const setTimezone = useCallback((tz: SupportedTimezone) => {\n localStorage.setItem('omnify_timezone', tz);\n setTimezoneState(tz);\n }, []);\n\n const setLanguage = useCallback((lang: SupportedLanguage) => {\n changeLanguage(lang);\n }, []);\n\n const formatDate = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), dateFormatPatterns[language] || 'dd/MM/yyyy', { locale });\n },\n [language, locale]\n );\n\n const formatDateTime = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), dateTimeFormatPatterns[language] || 'dd/MM/yyyy HH:mm', { locale });\n },\n [language, locale]\n );\n\n const formatRelativeTime = useCallback(\n (date: Date | string): string => {\n return formatDistanceToNow(toDate(date), { addSuffix: true, locale });\n },\n [locale]\n );\n\n const formatShortDate = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), shortDatePatterns[language] || 'dd/MM', { locale });\n },\n [language, locale]\n );\n\n const formatMonthYear = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), 'MMMM yyyy', { locale });\n },\n [locale]\n );\n\n const formatDayOfWeek = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), 'EEE', { locale });\n },\n [locale]\n );\n\n const formatDateLong = useCallback(\n (date: Date | string): string => {\n return format(toDate(date), 'PPP', { locale });\n },\n [locale]\n );\n\n return {\n language,\n timezone,\n setLanguage,\n setTimezone,\n formatDate,\n formatDateTime,\n formatRelativeTime,\n formatShortDate,\n formatMonthYear,\n formatDayOfWeek,\n formatDateLong,\n dateFnsLocale: locale,\n localeTag: localeTags[language] || 'vi-VN',\n languageNames: { vi: 'Tiếng Việt', en: 'English', ja: '日本語' } as Record<SupportedLanguage, string>,\n };\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '@omnifyjp/ui/components/button';
4
+ import { Input } from '@omnifyjp/ui/components/input';
5
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem } from '@omnifyjp/ui/components/dropdown-menu';
6
+ import { Grid3X3, Search } from 'lucide-react';
7
+ import { jsxs, jsx } from 'react/jsx-runtime';
8
+
9
+ // src/components/ServiceMenu.tsx
10
+ function ServiceMenu({ categories }) {
11
+ const [search, setSearch] = useState("");
12
+ const { t } = useTranslation("shell");
13
+ const filteredCategories = categories.map((cat) => ({
14
+ ...cat,
15
+ items: cat.items.filter(
16
+ (s) => s.label.toLowerCase().includes(search.toLowerCase())
17
+ )
18
+ })).filter((cat) => cat.items.length > 0);
19
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
20
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(Button, { variant: "outline", className: "gap-2", children: [
21
+ /* @__PURE__ */ jsx(Grid3X3, { className: "w-4 h-4" }),
22
+ t("header.services")
23
+ ] }) }),
24
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "start", className: "w-[340px] p-0", children: [
25
+ /* @__PURE__ */ jsx("div", { className: "p-2 border-b", children: /* @__PURE__ */ jsxs("div", { className: "relative", children: [
26
+ /* @__PURE__ */ jsx(Search, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" }),
27
+ /* @__PURE__ */ jsx(
28
+ Input,
29
+ {
30
+ placeholder: t("header.searchServices"),
31
+ value: search,
32
+ onChange: (e) => setSearch(e.target.value),
33
+ className: "h-8 pl-8 text-sm"
34
+ }
35
+ )
36
+ ] }) }),
37
+ /* @__PURE__ */ jsx("div", { className: "max-h-[420px] overflow-y-auto p-1", children: filteredCategories.length === 0 ? /* @__PURE__ */ jsx("div", { className: "py-6 text-center text-sm text-muted-foreground", children: t("header.noServiceFound") }) : filteredCategories.map((category) => /* @__PURE__ */ jsxs("div", { children: [
38
+ /* @__PURE__ */ jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-medium px-2 py-1.5", children: category.category }),
39
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-0.5 px-1 mb-1", children: category.items.map((service) => /* @__PURE__ */ jsxs(
40
+ DropdownMenuItem,
41
+ {
42
+ className: "flex items-center gap-2.5 px-2 py-2 rounded-md cursor-pointer",
43
+ children: [
44
+ /* @__PURE__ */ jsx("div", { className: `w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${service.color}`, children: /* @__PURE__ */ jsx(service.icon, { className: "w-4 h-4" }) }),
45
+ /* @__PURE__ */ jsx("span", { className: "text-sm truncate", children: service.label })
46
+ ]
47
+ },
48
+ service.label
49
+ )) })
50
+ ] }, category.category)) })
51
+ ] })
52
+ ] });
53
+ }
54
+
55
+ export { ServiceMenu };
56
+ //# sourceMappingURL=chunk-ACCHC3AM.js.map
57
+ //# sourceMappingURL=chunk-ACCHC3AM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/ServiceMenu.tsx"],"names":[],"mappings":"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","file":"chunk-ACCHC3AM.js","sourcesContent":["import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '@omnifyjp/ui/components/button';\nimport { Input } from '@omnifyjp/ui/components/input';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuTrigger,\n} from '@omnifyjp/ui/components/dropdown-menu';\nimport { Grid3X3, Search } from 'lucide-react';\nimport type { ServiceCategory } from '../types';\n\ninterface ServiceMenuProps {\n categories: ServiceCategory[];\n}\n\nexport function ServiceMenu({ categories }: ServiceMenuProps) {\n const [search, setSearch] = useState('');\n const { t } = useTranslation('shell');\n\n const filteredCategories = categories\n .map((cat) => ({\n ...cat,\n items: cat.items.filter((s) =>\n s.label.toLowerCase().includes(search.toLowerCase())\n ),\n }))\n .filter((cat) => cat.items.length > 0);\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" className=\"gap-2\">\n <Grid3X3 className=\"w-4 h-4\" />\n {t('header.services')}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"w-[340px] p-0\">\n <div className=\"p-2 border-b\">\n <div className=\"relative\">\n <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground\" />\n <Input\n placeholder={t('header.searchServices')}\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"h-8 pl-8 text-sm\"\n />\n </div>\n </div>\n <div className=\"max-h-[420px] overflow-y-auto p-1\">\n {filteredCategories.length === 0 ? (\n <div className=\"py-6 text-center text-sm text-muted-foreground\">\n {t('header.noServiceFound')}\n </div>\n ) : (\n filteredCategories.map((category) => (\n <div key={category.category}>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground font-medium px-2 py-1.5\">\n {category.category}\n </DropdownMenuLabel>\n <div className=\"grid grid-cols-2 gap-0.5 px-1 mb-1\">\n {category.items.map((service) => (\n <DropdownMenuItem\n key={service.label}\n className=\"flex items-center gap-2.5 px-2 py-2 rounded-md cursor-pointer\"\n >\n <div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${service.color}`}>\n <service.icon className=\"w-4 h-4\" />\n </div>\n <span className=\"text-sm truncate\">{service.label}</span>\n </DropdownMenuItem>\n ))}\n </div>\n </div>\n ))\n )}\n </div>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"]}