@spree/dashboard-core 0.1.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,103 @@
1
+ import { SpreeError } from '@spree/admin-sdk'
2
+ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
3
+
4
+ /**
5
+ * Map a thrown error from a Spree Admin API mutation onto a react-hook-form
6
+ * instance. Two things happen on every call:
7
+ *
8
+ * 1. The server's top-level `error.message` (e.g. "Code can't be blank, Name
9
+ * can't be blank") is set on `formState.errors.root` so `<FormError>`
10
+ * always renders the full failure summary — even when individual fields
11
+ * aren't rendered (nested rules, useFieldArray rows, validations on
12
+ * associations).
13
+ * 2. Per-key entries from `error.details` are also set as per-field errors
14
+ * for any key that names a top-level flat attribute (`code`, `name`,
15
+ * `expires_at`). Nested or dotted keys (`promotion_rules.base`, `rules`
16
+ * on a useFieldArray, `line_items.0.quantity`) only contribute to the
17
+ * root summary — we don't pretend to know which of those have rendered
18
+ * inputs, and "silent" is the worst outcome.
19
+ *
20
+ * Return value:
21
+ * - `true` → the error was rendered into the form, the caller may suppress
22
+ * its toast
23
+ * - `false` → not a SpreeError (network / programming bug). Caller should
24
+ * log + toast as usual.
25
+ *
26
+ * Usage:
27
+ *
28
+ * ```ts
29
+ * const create = useCreateThing()
30
+ *
31
+ * async function onSubmit(values: FormValues) {
32
+ * try {
33
+ * await create.mutateAsync(values)
34
+ * onSuccess()
35
+ * } catch (err) {
36
+ * if (!mapSpreeErrorsToForm(err, form.setError)) throw err
37
+ * }
38
+ * }
39
+ * ```
40
+ *
41
+ * Server response shape (from `Spree::Api::V3::ErrorHandler#render_validation_error`):
42
+ *
43
+ * ```json
44
+ * { "error": {
45
+ * "code": "validation_error",
46
+ * "message": "Code can't be blank, Name can't be blank",
47
+ * "details": { "code": ["can't be blank"], "name": ["can't be blank"] }
48
+ * } }
49
+ * ```
50
+ *
51
+ * `details` keys are AR attribute names. `<form-field>.base` is AR's
52
+ * record-level errors collection on a nested association — we treat both
53
+ * the bare `base` key and any `*.base` key as record-level and route them
54
+ * to root.
55
+ */
56
+ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
57
+ error: unknown,
58
+ setError: UseFormSetError<TFieldValues>,
59
+ ): boolean {
60
+ if (!(error instanceof SpreeError)) return false
61
+
62
+ const { details, message } = error
63
+
64
+ // Always seed the root error with the server's top-level message. Even
65
+ // when details exist, the top-level message is the readable summary —
66
+ // and it's the only place nested/association errors will reliably show.
67
+ if (message) {
68
+ setError('root' as Path<TFieldValues>, { type: 'server', message })
69
+ }
70
+
71
+ if (!details) return true
72
+
73
+ // Per-field errors. Each detail value is `string[]` (one Rails error per
74
+ // entry). Only top-level flat keys get set as per-field errors; nested
75
+ // keys (`promotion_rules.base`, `rules`, `line_items.0.x`) stay on root.
76
+ //
77
+ // We're conservative on purpose: there's no way to know at this layer
78
+ // whether a given path has a rendered input, so anything that smells
79
+ // nested or associative falls back to the root summary that we already
80
+ // set above. Silently dropping errors onto unrendered field paths is a
81
+ // worse failure mode than redundant root messages.
82
+ for (const [field, messages] of Object.entries(details)) {
83
+ if (!messages?.length) continue
84
+ if (!isRenderableFieldKey(field)) continue
85
+
86
+ setError(field as Path<TFieldValues>, {
87
+ type: 'server',
88
+ message: messages.join(', '),
89
+ })
90
+ }
91
+
92
+ return true
93
+ }
94
+
95
+ // `base` (bare) and `*.base` are AR's record-level errors. `*.<n>.*` is an
96
+ // association/collection index path. Anything with `.` is too risky to set
97
+ // as a top-level RHF path — it'd either create a phantom nested error or
98
+ // silently miss a rendered field. Stick to flat snake_case keys.
99
+ function isRenderableFieldKey(key: string): boolean {
100
+ if (key === 'base') return false
101
+ if (key.includes('.')) return false
102
+ return true
103
+ }
@@ -0,0 +1,102 @@
1
+ // Extension form fields — let host-app code and plugins add fields to a
2
+ // built-in resource form (product, category, …) that hydrate from the fetched
3
+ // resource and persist through the form's own Save. The host form spreads its
4
+ // values into the update payload, so a registered field's value reaches the
5
+ // API without any save code on the extension side; the backend only needs to
6
+ // permit the attribute.
7
+ //
8
+ // The registry solves the hydration half: built-in forms reset their values
9
+ // from a mapper over the fetched resource (which knows nothing about
10
+ // extension fields), so each form merges `extensionFormValues(formKey,
11
+ // resource)` into every defaultValues/reset object. The rendering half is a
12
+ // slot widget binding an input via `useHostForm()`.
13
+
14
+ /** One extension field on a built-in resource form. */
15
+ export interface FormFieldRegistration<TResource = unknown> {
16
+ /**
17
+ * The form value key — also the API attribute name submitted on save
18
+ * (read/write symmetry: whatever the serializer exposes is what the
19
+ * controller permits).
20
+ */
21
+ name: string
22
+ /**
23
+ * Seed the field's form value from the fetched resource. Receives `null`
24
+ * on create forms (no resource yet) — return the field's blank value.
25
+ */
26
+ from: (resource: TResource | null) => unknown
27
+ }
28
+
29
+ const registrations = new Map<string, FormFieldRegistration[]>()
30
+
31
+ /**
32
+ * Registry of extension fields per built-in form. Form keys match the form's
33
+ * slot prefix — `'product'` for the product detail form. Registered names
34
+ * must be unique per form; duplicates throw so two plugins can't silently
35
+ * fight over one attribute.
36
+ */
37
+ export const formFields = {
38
+ register<TResource = unknown>(
39
+ formKey: string,
40
+ registration: FormFieldRegistration<TResource>,
41
+ ): void {
42
+ const list = registrations.get(formKey) ?? []
43
+ if (list.some((r) => r.name === registration.name)) {
44
+ throw new Error(
45
+ `Form field "${registration.name}" is already registered on the "${formKey}" form.`,
46
+ )
47
+ }
48
+ registrations.set(formKey, [...list, registration as FormFieldRegistration])
49
+ },
50
+
51
+ /** Remove a registered field. No-op when absent. */
52
+ remove(formKey: string, name: string): void {
53
+ const list = registrations.get(formKey)
54
+ if (!list) return
55
+ registrations.set(
56
+ formKey,
57
+ list.filter((r) => r.name !== name),
58
+ )
59
+ },
60
+ }
61
+
62
+ /**
63
+ * Values for every extension field registered on `formKey`, seeded from
64
+ * `resource` (or blanks when `null`). Built-in forms merge this into their
65
+ * defaultValues and every hydration `reset(...)` so extension fields survive
66
+ * refetch-driven resets.
67
+ */
68
+ export function extensionFormValues(
69
+ formKey: string,
70
+ resource: unknown | null,
71
+ ): Record<string, unknown> {
72
+ const out: Record<string, unknown> = {}
73
+ for (const registration of registrations.get(formKey) ?? []) {
74
+ out[registration.name] = registration.from(resource)
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * Current values of every extension field registered on `formKey`, read from
81
+ * live form state. Host forms spread this into their submit payload: the
82
+ * form's Zod schema only knows first-party fields (its parse strips unknown
83
+ * keys from the submitted values), so extension values are collected from
84
+ * `form.getValues(...)` instead — which also keeps them out of client-side
85
+ * schema validation. Their validation is the server's; a 422 maps back onto
86
+ * the field via `mapSpreeErrorsToForm`.
87
+ */
88
+ export function extensionSubmitValues(
89
+ formKey: string,
90
+ form: { getValues: (name: string) => unknown },
91
+ ): Record<string, unknown> {
92
+ const out: Record<string, unknown> = {}
93
+ for (const registration of registrations.get(formKey) ?? []) {
94
+ out[registration.name] = form.getValues(registration.name)
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** Test-only: clear the registry. Not exported from the package index. */
100
+ export function __resetFormFieldsRegistry(): void {
101
+ registrations.clear()
102
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Treat a blank or whitespace-only string as "not set" — common when mapping
3
+ * form values to API params, where a blank `<input>` should become `undefined`
4
+ * (omit the field) or `null` (clear the server value) rather than passing the
5
+ * empty string through. Returns the trimmed value when present.
6
+ */
7
+ export function blankToUndefined(s: string | null | undefined): string | undefined {
8
+ const trimmed = s?.trim()
9
+ return trimmed ? trimmed : undefined
10
+ }
11
+
12
+ export function blankToNull(s: string | null | undefined): string | null {
13
+ const trimmed = s?.trim()
14
+ return trimmed ? trimmed : null
15
+ }
16
+
17
+ /**
18
+ * Preprocessor for Zod optional-number fields backed by `<input type="number">`.
19
+ * Empty inputs return `''`, which `z.coerce.number()` would coerce to `0` —
20
+ * tripping `.positive()` / `.min()` on otherwise-optional fields. Map empty
21
+ * to `undefined` so the optional branch wins.
22
+ *
23
+ * ```ts
24
+ * usage_limit: z.preprocess(emptyToUndefined, z.coerce.number().int().positive().optional())
25
+ * ```
26
+ */
27
+ export function emptyToUndefined(value: unknown): unknown {
28
+ if (value === '' || value === null) return undefined
29
+ return value
30
+ }
@@ -0,0 +1,19 @@
1
+ import type { Price } from '@spree/admin-sdk'
2
+ import { parseISO } from 'date-fns'
3
+ import { formatInTimeZone } from 'date-fns-tz'
4
+
5
+ export function formatPrice(price: Pick<Price, 'amount' | 'currency' | 'display_amount'> | null) {
6
+ if (!price) return '—'
7
+ return price.display_amount ?? `${price.currency} ${price.amount}`
8
+ }
9
+
10
+ export function formatStoreDateTime(iso: string, timezone: string) {
11
+ return formatInTimeZone(parseISO(iso), timezone, 'PPP p')
12
+ }
13
+
14
+ export function getInitials(fullName: string | null | undefined, fallback: string): string {
15
+ const parts = (fullName ?? '').trim().split(/\s+/).filter(Boolean)
16
+ if (parts.length === 0) return fallback.charAt(0)
17
+ if (parts.length === 1) return parts[0].charAt(0)
18
+ return parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
19
+ }
@@ -0,0 +1,210 @@
1
+ import i18n from 'i18next'
2
+ import { initReactI18next } from 'react-i18next'
3
+ // Relative path, not `@/...`: dashboard-core ships source; the consuming
4
+ // project's `@/*` alias is theirs alone (see Phase 1's rationale).
5
+ import en from '../locales/en.json'
6
+
7
+ // localStorage key for the admin's chosen UI language. Shared by the login
8
+ // screen (pre-auth), the top-bar switcher, and the profile form so a choice
9
+ // made anywhere is honored on the next paint.
10
+ export const ADMIN_LOCALE_STORAGE_KEY = 'spree-admin-locale'
11
+
12
+ // Marks the locale key as auto-applied from a store's `preferred_admin_locale`
13
+ // (vs an explicit choice). Holds the storeId whose default is currently in
14
+ // effect, so crossing into a different store can re-apply that store's default
15
+ // while a genuine choice (switcher/profile/login) still wins everywhere. Mirrors
16
+ // legacy Rails: the `spree_admin_locale` cookie only ever held an explicit
17
+ // login-screen pick; the store default was re-derived per request, never stored.
18
+ const ADMIN_LOCALE_AUTO_STORE_KEY = 'spree-admin-locale-auto-store'
19
+
20
+ function readStorage(key: string): string | null {
21
+ try {
22
+ if (typeof localStorage === 'undefined') return null
23
+ return localStorage.getItem(key)
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ function writeStorage(key: string, value: string): void {
30
+ try {
31
+ if (typeof localStorage !== 'undefined') localStorage.setItem(key, value)
32
+ } catch {
33
+ // Restricted-storage contexts (Safari private mode, sandboxed iframes) throw
34
+ // on write; the choice still applies for this session via the booted `lng`.
35
+ }
36
+ }
37
+
38
+ function clearStorage(key: string): void {
39
+ try {
40
+ if (typeof localStorage !== 'undefined') localStorage.removeItem(key)
41
+ } catch {
42
+ // Ignore — see writeStorage.
43
+ }
44
+ }
45
+
46
+ // A full reload is deliberate for a language change: many labels (table columns,
47
+ // nav, registry titles) are resolved with `i18n.t(...)` at module-load time and
48
+ // don't react to a live `changeLanguage`. Reloading re-runs those at boot in the
49
+ // new language (read from localStorage by `init`). Deferred to the next macrotask
50
+ // so a pending React state flush (e.g. a just-saved form clearing its dirty
51
+ // state) completes first — otherwise a still-armed `beforeunload` dirty-guard
52
+ // would trigger the browser's "unsaved changes" prompt.
53
+ function reloadSoon(): void {
54
+ if (typeof window !== 'undefined') {
55
+ setTimeout(() => window.location.reload(), 0)
56
+ }
57
+ }
58
+
59
+ // Read the stored locale, tolerating restricted-storage contexts where
60
+ // `localStorage` access throws. Returns `null` when there is no stored choice
61
+ // so callers can distinguish "no choice yet" from an explicit value.
62
+ function storedLocale(): string | null {
63
+ return readStorage(ADMIN_LOCALE_STORAGE_KEY)
64
+ }
65
+
66
+ function readStoredLocale(): string {
67
+ return storedLocale() || 'en'
68
+ }
69
+
70
+ /**
71
+ * Reconcile the admin UI language against a store's `preferred_admin_locale`
72
+ * fallback (legacy `base_controller` parity), for an admin with no genuine
73
+ * personal choice. Encodes the precedence:
74
+ * account selected_locale > genuine stored choice > store preferred_admin_locale > 'en'
75
+ *
76
+ * No-op when a higher tier owns the language (an account locale, or a genuine
77
+ * stored choice — locale key set without an auto-marker). Otherwise drives the
78
+ * stored language to this store's current default:
79
+ * - a supported default → adopt it (auto-marked to this store);
80
+ * - a blank/cleared/unsupported default → revert to 'en' IF the prior value was
81
+ * auto-applied (a stale auto-default from this or another store is dropped);
82
+ * - already matching → nothing changes.
83
+ * Reloads (via the boot path) only when the displayed language actually changes.
84
+ *
85
+ * @param code the store's current `preferred_admin_locale`
86
+ * @param storeId the store being entered
87
+ * @param accountLocale the account's `selected_locale` (null when unset)
88
+ * @param supported locale codes the dashboard ships a bundle for
89
+ */
90
+ export function reconcileStoreDefaultLocale(
91
+ code: string | null | undefined,
92
+ storeId: string,
93
+ accountLocale: string | null,
94
+ supported: string[],
95
+ ): void {
96
+ if (accountLocale) return
97
+ const auto = readStorage(ADMIN_LOCALE_AUTO_STORE_KEY)
98
+ const stored = storedLocale()
99
+ // A genuine choice (locale key set, no auto-marker) outranks any store default.
100
+ if (stored != null && auto == null) return
101
+
102
+ const target = code && supported.includes(code) ? code : null
103
+ const current = i18n.resolvedLanguage ?? i18n.language
104
+
105
+ if (target) {
106
+ writeStorage(ADMIN_LOCALE_STORAGE_KEY, target)
107
+ writeStorage(ADMIN_LOCALE_AUTO_STORE_KEY, storeId)
108
+ if (target !== current) reloadSoon()
109
+ } else if (auto != null) {
110
+ // Store has no usable default and the current language was auto-applied —
111
+ // drop it so the UI reverts to the app default ('en').
112
+ clearStorage(ADMIN_LOCALE_STORAGE_KEY)
113
+ clearStorage(ADMIN_LOCALE_AUTO_STORE_KEY)
114
+ if (current !== 'en') reloadSoon()
115
+ }
116
+ }
117
+
118
+ // All non-English core bundles, imported EAGERLY so the active language has its
119
+ // resources synchronously available (registered just after init() below) —
120
+ // no flash, no async race with module-load `i18n.t(...)` calls.
121
+ const coreLocales = import.meta.glob<{ default: Record<string, unknown> }>(
122
+ ['../locales/*.json', '!../locales/en.json'],
123
+ { eager: true },
124
+ )
125
+
126
+ /** Admin-UI locale codes the framework ships a bundle for (including `en`). */
127
+ export function coreLocaleCodes(): string[] {
128
+ return [
129
+ 'en',
130
+ ...Object.keys(coreLocales).map((p) => p.replace('../locales/', '').replace('.json', '')),
131
+ ]
132
+ }
133
+
134
+ /**
135
+ * Record an explicit, genuine UI-language choice (top-bar switcher, profile,
136
+ * login, settings-save) WITHOUT reloading. Persists the locale and clears any
137
+ * store-default marker, so the choice now outranks every store default and
138
+ * won't be superseded on store switches. Use this when the UI already displays
139
+ * `code` (no reload needed) but the choice must still be marked as genuine.
140
+ */
141
+ export function markGenuineLocaleChoice(code: string): void {
142
+ writeStorage(ADMIN_LOCALE_STORAGE_KEY, code)
143
+ clearStorage(ADMIN_LOCALE_AUTO_STORE_KEY)
144
+ }
145
+
146
+ // Switch the admin UI language from an explicit, genuine choice. Marks it as
147
+ // genuine (see `markGenuineLocaleChoice`) and reloads (see `reloadSoon`) so
148
+ // every module-load label re-resolves in the new language.
149
+ export function switchLocale(code: string): void {
150
+ markGenuineLocaleChoice(code)
151
+ reloadSoon()
152
+ }
153
+
154
+ // Bootstrap i18next with the framework's base translation namespace. Side-effect
155
+ // import — `import '@spree/dashboard-core/lib/i18n'` from the consuming app's
156
+ // entry point runs this once before any component calls `useTranslation()`.
157
+ //
158
+ // The app (and any plugin) extends the base by calling
159
+ // `i18n.addResourceBundle('en', 'translation', extraKeys, true, true)`
160
+ // after this side-effect import has settled. The `deep` + `overwrite` flags
161
+ // merge the extension into the base namespace without dropping framework keys.
162
+ //
163
+ // English ships eagerly; `lng` is read from localStorage so a previously
164
+ // chosen language is the initial language on first paint (no flash).
165
+ i18n.use(initReactI18next).init({
166
+ resources: { en: { translation: en } },
167
+ lng: readStoredLocale(),
168
+ fallbackLng: 'en',
169
+ interpolation: { escapeValue: false },
170
+ // Surface missing keys in dev so we notice gaps as we build pages; ship
171
+ // silently humanizing the attribute in prod (a missing key shouldn't blow
172
+ // up the UI).
173
+ saveMissing: import.meta.env.DEV,
174
+ missingKeyHandler: import.meta.env.DEV
175
+ ? (_lngs, _ns, key) => console.warn(`[i18n] Missing key: ${key}`)
176
+ : undefined,
177
+ })
178
+
179
+ // Register the eager non-English core bundles now that i18next is initialized.
180
+ for (const [path, mod] of Object.entries(coreLocales)) {
181
+ const code = path.replace('../locales/', '').replace('.json', '')
182
+ i18n.addResourceBundle(code, 'translation', mod.default, true, true)
183
+ }
184
+
185
+ // Reflect the active language on the root <html> element so the document
186
+ // advertises the right `lang` (a11y, spellcheck, font selection) and `dir`
187
+ // (RTL for Arabic/Hebrew/etc.). `i18n.dir()` consults i18next's built-in RTL
188
+ // language list. Runs at boot; `switchLocale` reloads the page, so the next
189
+ // boot re-applies it for the chosen language — no live listener needed.
190
+ function applyDocumentLanguage(code: string): void {
191
+ if (typeof document === 'undefined') return
192
+ const html = document.documentElement
193
+ html.setAttribute('lang', code)
194
+ html.setAttribute('dir', i18n.dir(code))
195
+ }
196
+
197
+ applyDocumentLanguage(i18n.language || readStoredLocale())
198
+
199
+ /** Document text direction for a locale (`ltr` or `rtl`). */
200
+ export function uiDirection(locale?: string): 'ltr' | 'rtl' {
201
+ return i18n.dir(locale ?? i18n.language) === 'rtl' ? 'rtl' : 'ltr'
202
+ }
203
+
204
+ /** Which edge the primary sidebar docks to for a locale. */
205
+ export function primarySidebarSide(locale?: string): 'left' | 'right' {
206
+ return uiDirection(locale) === 'rtl' ? 'right' : 'left'
207
+ }
208
+
209
+ export { default as i18n } from 'i18next'
210
+ export { Trans, useTranslation } from 'react-i18next'
@@ -0,0 +1,224 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import { type ComponentType, useSyncExternalStore } from 'react'
3
+ import type { SubjectName } from './permissions'
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ export type NavSection = 'main' | 'bottom'
10
+
11
+ /** Ambient context passed to `NavEntry.if` — mirrors `SlotAmbientContext`. */
12
+ export interface NavVisibilityContext {
13
+ permissions?: unknown
14
+ store?: unknown
15
+ user?: unknown
16
+ }
17
+
18
+ export interface NavEntry {
19
+ /** Stable identifier — used for register/remove/update and as the React key. */
20
+ key: string
21
+ /** Visible label. */
22
+ label: string
23
+ /**
24
+ * Path template, prefixed with `/$storeId` at render time. Pass paths like
25
+ * `'/orders'`, NOT `/store_abc/orders`. The leading slash is required.
26
+ */
27
+ path: string
28
+ /** Sidebar icon (top-level entries only — subitems don't render icons). */
29
+ icon?: LucideIcon
30
+ /** Section. Defaults to `'main'`. `'bottom'` pins to the sidebar footer. */
31
+ section?: NavSection
32
+ /** Lower numbers render first. Built-ins use 100/200/300… so plugins can slot in between. */
33
+ position?: number
34
+ /** CanCanCan subject required to see this item. Omit for always-visible. */
35
+ subject?: SubjectName
36
+ /**
37
+ * State-based visibility gate evaluated at render (top-level entries only),
38
+ * e.g. hide Getting Started once the store's setup tasks are all done.
39
+ * Combines with `subject` — both must pass.
40
+ */
41
+ if?: (ctx: NavVisibilityContext) => boolean
42
+ /**
43
+ * Rendered after the label (e.g. a count). A component — not an element —
44
+ * so it can call hooks like useStore and return null to hide itself.
45
+ */
46
+ badge?: ComponentType
47
+ /** Nested children. Children inherit nothing — they declare their own subject + position. */
48
+ children?: NavEntry[]
49
+ }
50
+
51
+ interface NavMutator {
52
+ add(entry: NavEntry): void
53
+ remove(key: string): void
54
+ update(key: string, patch: Partial<Omit<NavEntry, 'key'>>): void
55
+ /** Insert a new entry immediately before `targetKey`. Throws if the target is missing. */
56
+ insertBefore(targetKey: string, entry: NavEntry): void
57
+ /** Insert a new entry immediately after `targetKey`. Throws if the target is missing. */
58
+ insertAfter(targetKey: string, entry: NavEntry): void
59
+ /**
60
+ * Append a child under an existing top-level entry (e.g. nest a plugin page
61
+ * under the built-in `products` menu). Preserves the parent's existing
62
+ * children — unlike `update(parent, { children })`, which replaces them.
63
+ * Throws if the parent is missing or the child key already exists there.
64
+ */
65
+ addChild(parentKey: string, child: NavEntry): void
66
+ /** Remove a child by key from a parent. No-op when either is absent. */
67
+ removeChild(parentKey: string, childKey: string): void
68
+ /** Patch a child of `parentKey`. Throws if the parent or child is missing. */
69
+ updateChild(parentKey: string, childKey: string, patch: Partial<Omit<NavEntry, 'key'>>): void
70
+ }
71
+
72
+ // ============================================================================
73
+ // Registry — module-singleton
74
+ // ============================================================================
75
+
76
+ const entries: NavEntry[] = []
77
+ const listeners = new Set<() => void>()
78
+ let snapshotCache: { main: NavEntry[]; bottom: NavEntry[] } | null = null
79
+
80
+ function notify() {
81
+ snapshotCache = null
82
+ for (const l of listeners) l()
83
+ }
84
+
85
+ function findIndex(key: string): number {
86
+ return entries.findIndex((e) => e.key === key)
87
+ }
88
+
89
+ function ensureUniqueKey(key: string) {
90
+ if (entries.some((e) => e.key === key)) {
91
+ throw new Error(`Nav entry "${key}" already registered. Use nav.update() instead.`)
92
+ }
93
+ }
94
+
95
+ // ============================================================================
96
+ // Public API
97
+ // ============================================================================
98
+
99
+ export const nav: NavMutator = {
100
+ add(entry) {
101
+ ensureUniqueKey(entry.key)
102
+ entries.push(entry)
103
+ notify()
104
+ },
105
+ remove(key) {
106
+ const i = findIndex(key)
107
+ if (i === -1) return
108
+ entries.splice(i, 1)
109
+ notify()
110
+ },
111
+ update(key, patch) {
112
+ const e = entries.find((x) => x.key === key)
113
+ if (!e) throw new Error(`Nav entry "${key}" not found.`)
114
+ Object.assign(e, patch)
115
+ notify()
116
+ },
117
+ insertBefore(targetKey, entry) {
118
+ const i = findIndex(targetKey)
119
+ if (i === -1) throw new Error(`Nav entry "${targetKey}" not found.`)
120
+ ensureUniqueKey(entry.key)
121
+ // Inherit target's position so the relative order survives `getNavEntries`'s sort.
122
+ const target = entries[i]
123
+ const adjusted: NavEntry = {
124
+ ...entry,
125
+ position: entry.position ?? (target.position ?? 100) - 1,
126
+ }
127
+ entries.splice(i, 0, adjusted)
128
+ notify()
129
+ },
130
+ insertAfter(targetKey, entry) {
131
+ const i = findIndex(targetKey)
132
+ if (i === -1) throw new Error(`Nav entry "${targetKey}" not found.`)
133
+ ensureUniqueKey(entry.key)
134
+ const target = entries[i]
135
+ const adjusted: NavEntry = {
136
+ ...entry,
137
+ position: entry.position ?? (target.position ?? 100) + 1,
138
+ }
139
+ entries.splice(i + 1, 0, adjusted)
140
+ notify()
141
+ },
142
+ addChild(parentKey, child) {
143
+ const parent = requireEntry(parentKey)
144
+ if (parent.children?.some((c) => c.key === child.key)) {
145
+ throw new Error(
146
+ `Nav child "${child.key}" already exists under "${parentKey}". Use nav.updateChild().`,
147
+ )
148
+ }
149
+ parent.children = [...(parent.children ?? []), child]
150
+ notify()
151
+ },
152
+ removeChild(parentKey, childKey) {
153
+ const parent = entries.find((e) => e.key === parentKey)
154
+ if (!parent?.children) return
155
+ const next = parent.children.filter((c) => c.key !== childKey)
156
+ if (next.length === parent.children.length) return
157
+ parent.children = next
158
+ notify()
159
+ },
160
+ updateChild(parentKey, childKey, patch) {
161
+ const parent = requireEntry(parentKey)
162
+ const idx = parent.children?.findIndex((c) => c.key === childKey) ?? -1
163
+ if (idx === -1 || !parent.children) {
164
+ throw new Error(`Nav child "${childKey}" not found under "${parentKey}".`)
165
+ }
166
+ // Replace both the child and the array so the patched entry gets a fresh
167
+ // reference in the next snapshot — in-place mutation would defeat any
168
+ // reference-equality memoization on the item component.
169
+ const next = [...parent.children]
170
+ next[idx] = { ...next[idx], ...patch }
171
+ parent.children = next
172
+ notify()
173
+ },
174
+ }
175
+
176
+ function requireEntry(key: string): NavEntry {
177
+ const entry = entries.find((e) => e.key === key)
178
+ if (!entry) throw new Error(`Nav entry "${key}" not found.`)
179
+ return entry
180
+ }
181
+
182
+ function subscribe(listener: () => void): () => void {
183
+ listeners.add(listener)
184
+ return () => {
185
+ listeners.delete(listener)
186
+ }
187
+ }
188
+
189
+ function buildSnapshot(): { main: NavEntry[]; bottom: NavEntry[] } {
190
+ const sortRecursively = (list: NavEntry[]): NavEntry[] =>
191
+ [...list]
192
+ .sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
193
+ .map((e) => (e.children ? { ...e, children: sortRecursively(e.children) } : e))
194
+
195
+ return {
196
+ main: sortRecursively(entries.filter((e) => (e.section ?? 'main') === 'main')),
197
+ bottom: sortRecursively(entries.filter((e) => e.section === 'bottom')),
198
+ }
199
+ }
200
+
201
+ function getSnapshot() {
202
+ if (!snapshotCache) snapshotCache = buildSnapshot()
203
+ return snapshotCache
204
+ }
205
+
206
+ /**
207
+ * Subscribe to nav-registry updates and read the sorted entries. Mirrors
208
+ * `useSlotEntries` from slot-registry.ts. Re-renders only when the registry
209
+ * is mutated; navigations don't trigger updates.
210
+ */
211
+ export function useNavEntries() {
212
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
213
+ }
214
+
215
+ /** Test-only: clear the registry. Not exported from the package index. */
216
+ export function __resetNavRegistry(): void {
217
+ entries.length = 0
218
+ notify()
219
+ }
220
+
221
+ /** Test-only: raw entries in registration order (unsorted, unsectioned). */
222
+ export function __getNavEntries(): readonly NavEntry[] {
223
+ return entries
224
+ }