@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.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- 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
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -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
|
+
}
|