@spree/dashboard-core 1.0.0-beta.2 → 1.0.0-beta.4

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 (36) hide show
  1. package/package.json +7 -5
  2. package/src/api-client.ts +43 -0
  3. package/src/components/address-form-dialog.tsx +5 -2
  4. package/src/components/app-sidebar.tsx +16 -2
  5. package/src/components/currency-select.tsx +1 -15
  6. package/src/components/nav-main.tsx +50 -37
  7. package/src/components/sheet-top-bar.tsx +1 -1
  8. package/src/components/table-toolbar.tsx +9 -63
  9. package/src/components/top-bar-breadcrumbs.tsx +9 -2
  10. package/src/hooks/use-switch-admin-locale.ts +11 -2
  11. package/src/lib/currencies.ts +15 -0
  12. package/src/lib/form-mappers.ts +40 -0
  13. package/src/lib/fulfillment-items.test.ts +29 -0
  14. package/src/lib/fulfillment-items.ts +35 -17
  15. package/src/lib/i18n.ts +79 -4
  16. package/src/lib/permissions.ts +1 -1
  17. package/src/lib/settings-nav-registry.ts +27 -0
  18. package/src/lib/slot-registry.ts +18 -0
  19. package/src/lib/table-registry.ts +2 -2
  20. package/src/locales/ar.json +32 -2
  21. package/src/locales/de.json +32 -2
  22. package/src/locales/en.json +32 -2
  23. package/src/locales/es.json +2030 -0
  24. package/src/locales/fr.json +32 -2
  25. package/src/locales/pl.json +32 -2
  26. package/src/locales/zh-CN.json +32 -2
  27. package/src/pages/package-types.tsx +19 -17
  28. package/src/products/normalize-money.test.ts +9 -0
  29. package/src/products/normalize-money.ts +12 -0
  30. package/src/products/product-form-cards.tsx +40 -16
  31. package/src/products/product-form-mapping.test.ts +72 -0
  32. package/src/products/product-form-mapping.ts +3 -1
  33. package/src/products/product-types.ts +1 -0
  34. package/src/products/variants-section.tsx +5 -2
  35. package/src/providers/auth-provider.tsx +3 -3
  36. package/src/schemas/package-type.ts +7 -4
@@ -9,6 +9,7 @@
9
9
  /** The line-item fields the grouping reads. */
10
10
  export interface GroupableLineItem {
11
11
  id: string
12
+ variant_id?: string | null
12
13
  name: string
13
14
  options_text?: string | null
14
15
  quantity: number
@@ -37,7 +38,10 @@ export interface GroupableFulfillment {
37
38
  * line item it descends from, plus how many of its units this group holds.
38
39
  */
39
40
  export interface FulfillmentItemRow {
40
- /** Stable within one group — a line item appears at most once per group. */
41
+ /**
42
+ * Stable within one group — a line item appears at most once per group,
43
+ * plus once per replacement variant an exchange ships against it.
44
+ */
41
45
  key: string
42
46
  lineItem: GroupableLineItem | null
43
47
  /** Falls back to the fulfillment item's own copy when the line item is gone. */
@@ -94,20 +98,39 @@ export function fulfilledQuantities(fulfillments: GroupableFulfillment[]): Map<s
94
98
  return fulfilled
95
99
  }
96
100
 
97
- /** Builds a row, preferring the line item's own copy over the fulfillment item's. */
101
+ /**
102
+ * An exchange ships its replacement against the original line item, so the
103
+ * unit's variant is the only thing that names what is actually in the box.
104
+ */
105
+ function isReplacement(
106
+ lineItem: GroupableLineItem | undefined,
107
+ item: Pick<GroupableFulfillmentItem, 'variant_id'> | null,
108
+ ): boolean {
109
+ return Boolean(
110
+ lineItem?.variant_id && item?.variant_id && lineItem.variant_id !== item.variant_id,
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Builds a row, preferring the line item's own copy over the fulfillment
116
+ * item's — unless the unit is a replacement, whose name, image and price the
117
+ * line item does not describe.
118
+ */
98
119
  function buildRow(
99
120
  key: string,
100
121
  lineItem: GroupableLineItem | undefined,
101
- fallback: Pick<GroupableFulfillmentItem, 'name' | 'options_text'> | null,
122
+ fallback: Pick<GroupableFulfillmentItem, 'variant_id' | 'name' | 'options_text'> | null,
102
123
  quantity: number,
103
124
  ): FulfillmentItemRow {
125
+ const copy = isReplacement(lineItem, fallback) ? undefined : lineItem
126
+
104
127
  return {
105
128
  key,
106
129
  lineItem: lineItem ?? null,
107
- name: lineItem?.name ?? fallback?.name ?? '',
108
- optionsText: lineItem?.options_text ?? fallback?.options_text ?? null,
109
- thumbnailUrl: lineItem?.thumbnail_url ?? null,
110
- displayPrice: lineItem?.display_price ?? null,
130
+ name: copy?.name ?? fallback?.name ?? '',
131
+ optionsText: copy?.options_text ?? fallback?.options_text ?? null,
132
+ thumbnailUrl: copy?.thumbnail_url ?? null,
133
+ displayPrice: copy?.display_price ?? null,
111
134
  quantity,
112
135
  }
113
136
  }
@@ -128,7 +151,10 @@ export function fulfillmentItemRows(
128
151
  for (const item of fulfillment.fulfillment_items ?? []) {
129
152
  // A fulfillment item with no line item still ships; key it by variant so
130
153
  // it renders rather than vanishing.
131
- const key = item.line_item_id ?? item.variant_id
154
+ const lineItem = item.line_item_id ? byId.get(item.line_item_id) : undefined
155
+ const key = isReplacement(lineItem, item)
156
+ ? `${item.line_item_id}:${item.variant_id}`
157
+ : (item.line_item_id ?? item.variant_id)
132
158
  if (!key) continue
133
159
 
134
160
  const existing = rows.get(key)
@@ -137,15 +163,7 @@ export function fulfillmentItemRows(
137
163
  continue
138
164
  }
139
165
 
140
- rows.set(
141
- key,
142
- buildRow(
143
- key,
144
- item.line_item_id ? byId.get(item.line_item_id) : undefined,
145
- item,
146
- item.quantity,
147
- ),
148
- )
166
+ rows.set(key, buildRow(key, lineItem, item, item.quantity))
149
167
  }
150
168
 
151
169
  return [...rows.values()]
package/src/lib/i18n.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import i18n from 'i18next'
2
2
  import { initReactI18next } from 'react-i18next'
3
+ import { intlDisplayName } from '../hooks/use-display-name'
3
4
  // Relative path, not `@/...`: dashboard-core ships source; the consuming
4
5
  // project's `@/*` alias is theirs alone (see Phase 1's rationale).
5
6
  import en from '../locales/en.json'
@@ -126,10 +127,84 @@ const coreLocales = import.meta.glob<{ default: Record<string, unknown> }>(
126
127
 
127
128
  /** Admin-UI locale codes the framework ships a bundle for (including `en`). */
128
129
  export function coreLocaleCodes(): string[] {
129
- return [
130
- 'en',
131
- ...Object.keys(coreLocales).map((p) => p.replace('../locales/', '').replace('.json', '')),
132
- ]
130
+ return localeCodesFromBundlePaths(Object.keys(coreLocales))
131
+ }
132
+
133
+ /**
134
+ * Turns a panel's locale-bundle paths into the `{ code, name }` pairs its
135
+ * language pickers render, with `en` always first.
136
+ *
137
+ * The glob itself has to stay in the calling package — `import.meta.glob` is a
138
+ * compile-time transform resolved against the file that writes it, so hoisting
139
+ * it here would scan the framework's own locales instead of the panel's. What
140
+ * IS shared is everything after that: stripping the path down to a code, and
141
+ * naming each language in its own words.
142
+ *
143
+ * @param paths bundle paths from the caller's `import.meta.glob`, e.g.
144
+ * `./locales/de.json`
145
+ */
146
+ export function localesFromBundlePaths(paths: string[]): Array<{ code: string; name: string }> {
147
+ return localeCodesFromBundlePaths(paths).map((code) => ({ code, name: localeEndonym(code) }))
148
+ }
149
+
150
+ // `en` first, then a code per bundle path. The glob hands back whatever prefix
151
+ // the calling file used (`./locales/`, `../locales/`), so match either.
152
+ function localeCodesFromBundlePaths(paths: string[]): string[] {
153
+ return ['en', ...paths.map((path) => path.replace(/^.*\/locales\//, '').replace('.json', ''))]
154
+ }
155
+
156
+ // Each language's endonym (its own name: `Deutsch`, `中文`). `Intl.DisplayNames`
157
+ // answers in the language's own convention, so some come back lowercase
158
+ // (`français`, `polski`); capitalize the first letter by THAT language's casing
159
+ // rules rather than the current locale's. Scripts without case are unaffected,
160
+ // and an unknown code falls back to itself.
161
+ function localeEndonym(code: string): string {
162
+ const name = intlDisplayName('language', code, code) ?? code
163
+
164
+ return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
165
+ }
166
+
167
+ // The languages the running panel can display, as it declared them. `null`
168
+ // until a panel does, which accepts every code — the behaviour before panels
169
+ // declared anything.
170
+ let uiLocaleCodes: readonly string[] | null = null
171
+
172
+ /**
173
+ * Declares the languages this panel can display, so a saved preference for one
174
+ * it does not ship is never applied.
175
+ *
176
+ * The account's language is shared by every panel a person signs in to, and
177
+ * panels ship different sets: a language picked in one may not exist in the
178
+ * other. Applied anyway, the framework's own strings would switch while the
179
+ * panel's stayed in English. Call once at boot with the panel's own list.
180
+ *
181
+ * @param codes locale codes the panel ships a bundle for, e.g. `['en', 'de']`
182
+ */
183
+ export function setUiLocales(codes: readonly string[]): void {
184
+ uiLocaleCodes = codes
185
+ }
186
+
187
+ /** @returns whether the running panel can display `code` */
188
+ export function isUiLocale(code: string): boolean {
189
+ return uiLocaleCodes === null || uiLocaleCodes.includes(code)
190
+ }
191
+
192
+ /**
193
+ * The language a new session should switch the page to, or `null` when the
194
+ * page is already right.
195
+ *
196
+ * The account's saved language wins when the panel can display it. When it
197
+ * cannot, the page keeps the language it booted in — unless that one is not
198
+ * displayable either, which a panel reached before this check existed can be
199
+ * left in, and then it goes back to English.
200
+ *
201
+ * @param saved the account's `selected_locale`
202
+ * @param stored the language the page booted in
203
+ */
204
+ export function sessionLocale(saved: string | null | undefined, stored: string): string | null {
205
+ const target = saved && isUiLocale(saved) ? saved : isUiLocale(stored) ? stored : 'en'
206
+
207
+ return target === stored ? null : target
133
208
  }
134
209
 
135
210
  /**
@@ -15,7 +15,7 @@ export const Subject = {
15
15
  Order: 'Spree::Order',
16
16
  SavedReport: 'Spree::SavedReport',
17
17
  ReportExport: 'Spree::Exports::Report',
18
- Customer: 'Spree::User',
18
+ Customer: 'Spree::Customer',
19
19
  CustomerGroup: 'Spree::CustomerGroup',
20
20
  AdminUser: 'Spree::AdminUser',
21
21
  ApiKey: 'Spree::ApiKey',
@@ -23,6 +23,16 @@ export interface SettingsNavEntry {
23
23
  label?: string
24
24
  /** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
25
25
  labelKey?: string
26
+ /**
27
+ * What the breadcrumb calls this page, when the sidebar label alone would
28
+ * read badly there. The sidebar shows an entry beneath its group header, so
29
+ * it can be named relative to that header ("Settings" under Marketplace);
30
+ * the breadcrumb has no such header and would repeat itself. Falls back to
31
+ * the sidebar label when unset, which is the case for nearly every entry.
32
+ */
33
+ breadcrumbLabel?: string
34
+ /** i18n key for `breadcrumbLabel`, resolved at render. Takes precedence over it. */
35
+ breadcrumbLabelKey?: string
26
36
  /**
27
37
  * One-line summary of what the page is for. Shown on the settings landing
28
38
  * page under the entry's label; the sidebar ignores it.
@@ -166,6 +176,23 @@ export function useSettingsNav() {
166
176
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
167
177
  }
168
178
 
179
+ /**
180
+ * What the breadcrumb calls a settings entry: its own breadcrumb label when it
181
+ * declares one, otherwise the sidebar label.
182
+ *
183
+ * @param entry Entry from the settings nav registry.
184
+ * @param t Translation function used to resolve the key form.
185
+ * @returns The label to render in the trail.
186
+ */
187
+ export function resolveSettingsBreadcrumbLabel(
188
+ entry: SettingsNavEntry,
189
+ t: (key: string) => string,
190
+ ): string {
191
+ if (entry.breadcrumbLabelKey) return t(entry.breadcrumbLabelKey)
192
+ if (entry.breadcrumbLabel) return entry.breadcrumbLabel
193
+ return resolveNavLabel(entry, t)
194
+ }
195
+
169
196
  /**
170
197
  * Whether a settings entry matches a search term. Label, description and the
171
198
  * entry's own `keywords` all count, so the word a merchant knows ("VAT",
@@ -1,3 +1,4 @@
1
+ import type { AdminUser } from '@spree/admin-sdk'
1
2
  import type { ComponentType } from 'react'
2
3
  import { useSyncExternalStore } from 'react'
3
4
 
@@ -26,6 +27,23 @@ export interface SlotEntry<TContext = unknown> {
26
27
  if?: (ctx: TContext & SlotAmbientContext) => boolean
27
28
  }
28
29
 
30
+ // ============================================================================
31
+ // Built-in slot names shared with plugins
32
+ // ============================================================================
33
+
34
+ /**
35
+ * The screen a signed-in admin sees when they hold a role on no store — not
36
+ * yet invited to one, or removed from all of them. Registering an entry
37
+ * replaces the built-in message, for example to render a different call to
38
+ * action or send the user elsewhere from an effect.
39
+ */
40
+ export const NO_STORE_ACCESS_SLOT = 'no_store_access'
41
+
42
+ export interface NoStoreAccessSlotContext {
43
+ user: AdminUser
44
+ signOut: () => Promise<void>
45
+ }
46
+
29
47
  // ============================================================================
30
48
  // Registry — module-singleton keyed by slot name
31
49
  // ============================================================================
@@ -85,13 +85,13 @@ export interface ResourceFilterConfig<R extends { id: string } = { id: string }>
85
85
 
86
86
  /**
87
87
  * Models that `TagCombobox` can target for tag autocomplete. The three
88
- * first-class taggables (Product, User, Order) get autocomplete hints, but
88
+ * first-class taggables (Product, Customer, Order) get autocomplete hints, but
89
89
  * the union is open — apps can pass any Ruby class string the backend's
90
90
  * `TagsController#allowed_taggable_types` accepts (override that method
91
91
  * server-side to extend). Use `Subject.Product` etc. to avoid stringly-typed
92
92
  * callsites for the built-ins.
93
93
  */
94
- export type TaggableType = 'Spree::Product' | 'Spree::User' | 'Spree::Order' | (string & {})
94
+ export type TaggableType = 'Spree::Product' | 'Spree::Customer' | 'Spree::Order' | (string & {})
95
95
 
96
96
  /**
97
97
  * Column definition. Discriminated union on `filterType`:
@@ -125,6 +125,11 @@
125
125
  "title": "مرحبًا بعودتك"
126
126
  },
127
127
  "logout": "تسجيل الخروج",
128
+ "no_store_access": {
129
+ "description": "لقد سجّلت الدخول، لكن حسابك ليس عضوًا في أي متجر بعد. اطلب من مسؤول المتجر دعوتك، أو سجّل الدخول بحساب آخر.",
130
+ "signed_in_as": "تم تسجيل الدخول باسم {{email}}",
131
+ "title": "ليس لديك صلاحية الوصول إلى أي متجر"
132
+ },
128
133
  "reset_password": {
129
134
  "confirm_password_label": "تأكيد كلمة المرور الجديدة",
130
135
  "error": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
@@ -429,7 +434,6 @@
429
434
  "filter_select_placeholder": "اختر…",
430
435
  "filter_text_placeholder": "تصفية…",
431
436
  "filter_to_placeholder": "التصفية إلى…",
432
- "more_conditions": "شروط إضافية",
433
437
  "operators": {
434
438
  "after": "بعد",
435
439
  "before": "قبل",
@@ -686,6 +690,32 @@
686
690
  "quantity": {
687
691
  "label": "الكمية"
688
692
  },
693
+ "setup": {
694
+ "country_code": {
695
+ "help": "من أين تشحن. يمكن لمتجرك البيع في جميع أنحاء العالم — هذا يحدد فقط العملة والمستودع ومناطق التوصيل الأولية، ويمكن تغييرها لاحقًا.",
696
+ "label": "البلد",
697
+ "placeholder": "ابحث عن البلدان"
698
+ },
699
+ "currency": {
700
+ "help": "عملتك الافتراضية. أضف المزيد في أي وقت.",
701
+ "label": "العملة",
702
+ "placeholder": "ابحث عن العملات"
703
+ },
704
+ "locale": {
705
+ "help": "لغتك الافتراضية. أضف المزيد في أي وقت.",
706
+ "label": "اللغة"
707
+ },
708
+ "password_confirmation": {
709
+ "label": "تأكيد كلمة المرور"
710
+ },
711
+ "sample_data": {
712
+ "help": "يضيف في الخلفية منتجات وفئات وعملاء وطلبات تجريبية لاستكشاف لوحة التحكم. يستغرق بضع دقائق ويقوم بتنزيل صور المنتجات.",
713
+ "label": "تحميل بيانات تجريبية"
714
+ },
715
+ "store_name": {
716
+ "label": "اسم المتجر"
717
+ }
718
+ },
689
719
  "shipping": {
690
720
  "label": "الشحن"
691
721
  },
@@ -1887,7 +1917,7 @@
1887
1917
  },
1888
1918
  "create_line_items": {
1889
1919
  "name": "إنشاء عناصر سطرية",
1890
- "description": "تعبئة سلة التسوق بالكمية المحددة من المتغير"
1920
+ "description": "يضيف الكمية المحددة من المتغير إلى السلة مجانًا"
1891
1921
  },
1892
1922
  "free_shipping": {
1893
1923
  "name": "الشحن مجانًا",
@@ -125,6 +125,11 @@
125
125
  "title": "Willkommen zurück"
126
126
  },
127
127
  "logout": "Abmelden",
128
+ "no_store_access": {
129
+ "description": "Sie sind angemeldet, aber Ihr Konto gehört noch keinem Store an. Bitten Sie einen Store-Administrator um eine Einladung oder melden Sie sich mit einem anderen Konto an.",
130
+ "signed_in_as": "Angemeldet als {{email}}",
131
+ "title": "Sie haben keinen Zugriff auf einen Store"
132
+ },
128
133
  "reset_password": {
129
134
  "confirm_password_label": "Neues Passwort bestätigen",
130
135
  "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
@@ -429,7 +434,6 @@
429
434
  "filter_select_placeholder": "Auswählen…",
430
435
  "filter_text_placeholder": "Filtern…",
431
436
  "filter_to_placeholder": "Filtern auf…",
432
- "more_conditions": "Weitere Bedingungen",
433
437
  "operators": {
434
438
  "after": "nach",
435
439
  "before": "vor",
@@ -686,6 +690,32 @@
686
690
  "quantity": {
687
691
  "label": "Menge"
688
692
  },
693
+ "setup": {
694
+ "country_code": {
695
+ "help": "Von wo Sie versenden. Ihr Shop kann weltweit verkaufen — dies legt nur Startwährung, Lager und Liefergebiete fest, alle später änderbar.",
696
+ "label": "Land",
697
+ "placeholder": "Länder durchsuchen"
698
+ },
699
+ "currency": {
700
+ "help": "Ihre Standardwährung. Jederzeit erweiterbar.",
701
+ "label": "Währung",
702
+ "placeholder": "Währungen durchsuchen"
703
+ },
704
+ "locale": {
705
+ "help": "Ihre Standardsprache. Jederzeit erweiterbar.",
706
+ "label": "Sprache"
707
+ },
708
+ "password_confirmation": {
709
+ "label": "Passwort bestätigen"
710
+ },
711
+ "sample_data": {
712
+ "help": "Fügt im Hintergrund Demo-Produkte, Kategorien, Kunden und Bestellungen hinzu, damit Sie das Dashboard erkunden können. Dauert einige Minuten und lädt Produktbilder herunter.",
713
+ "label": "Beispieldaten laden"
714
+ },
715
+ "store_name": {
716
+ "label": "Shopname"
717
+ }
718
+ },
689
719
  "shipping": {
690
720
  "label": "Versand"
691
721
  },
@@ -1882,7 +1912,7 @@
1882
1912
  },
1883
1913
  "create_line_items": {
1884
1914
  "name": "Bestellpositionen erstellen",
1885
- "description": "Fügt dem Warenkorb die angegebene Menge der Variante hinzu"
1915
+ "description": "Fügt dem Warenkorb die angegebene Menge der Variante kostenlos hinzu"
1886
1916
  },
1887
1917
  "free_shipping": {
1888
1918
  "name": "Kostenloser Versand",
@@ -125,6 +125,11 @@
125
125
  "title": "Welcome back"
126
126
  },
127
127
  "logout": "Sign out",
128
+ "no_store_access": {
129
+ "description": "You're signed in, but your account isn't a member of any store yet. Ask a store administrator to invite you, or sign in with a different account.",
130
+ "signed_in_as": "Signed in as {{email}}",
131
+ "title": "You don't have access to any store"
132
+ },
128
133
  "reset_password": {
129
134
  "confirm_password_label": "Confirm new password",
130
135
  "error": "Something went wrong. Please try again.",
@@ -431,7 +436,6 @@
431
436
  "filter_select_placeholder": "Select…",
432
437
  "filter_text_placeholder": "Filter…",
433
438
  "filter_to_placeholder": "Filter to…",
434
- "more_conditions": "More conditions",
435
439
  "operators": {
436
440
  "after": "after",
437
441
  "before": "before",
@@ -688,6 +692,32 @@
688
692
  "quantity": {
689
693
  "label": "Quantity"
690
694
  },
695
+ "setup": {
696
+ "country_code": {
697
+ "help": "Where you ship from. Your store can sell worldwide — this just sets the starting currency, warehouse and delivery zones, all editable later.",
698
+ "label": "Country",
699
+ "placeholder": "Search countries"
700
+ },
701
+ "currency": {
702
+ "help": "Your default currency. Add more anytime.",
703
+ "label": "Currency",
704
+ "placeholder": "Search currencies"
705
+ },
706
+ "locale": {
707
+ "help": "Your default language. Add more anytime.",
708
+ "label": "Language"
709
+ },
710
+ "password_confirmation": {
711
+ "label": "Confirm password"
712
+ },
713
+ "sample_data": {
714
+ "help": "Adds demo products, categories, customers and orders in the background so you can explore the dashboard. Takes a few minutes and downloads product images.",
715
+ "label": "Load sample data"
716
+ },
717
+ "store_name": {
718
+ "label": "Store name"
719
+ }
720
+ },
691
721
  "shipping": {
692
722
  "label": "Shipping"
693
723
  },
@@ -1884,7 +1914,7 @@
1884
1914
  },
1885
1915
  "create_line_items": {
1886
1916
  "name": "Create line items",
1887
- "description": "Populates the cart with the specified quantity of variant"
1917
+ "description": "Adds the specified quantity of a variant to the cart free of charge"
1888
1918
  },
1889
1919
  "free_shipping": {
1890
1920
  "name": "Free shipping",