@spree/dashboard-core 1.0.0-rc.2 → 1.0.0-rc.3

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 (58) hide show
  1. package/dist/vite/discover.js +5 -2
  2. package/package.json +3 -5
  3. package/src/api-client.ts +22 -34
  4. package/src/components/address-form-dialog.test.ts +4 -4
  5. package/src/components/address-form-dialog.tsx +10 -10
  6. package/src/components/app-shell.tsx +12 -1
  7. package/src/components/app-sidebar.tsx +18 -2
  8. package/src/components/country-combobox.tsx +52 -29
  9. package/src/components/currency-select.tsx +16 -10
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/mobile-top-bar.tsx +6 -0
  13. package/src/components/money.tsx +23 -0
  14. package/src/components/preferences-form.tsx +247 -52
  15. package/src/components/resource-table.tsx +7 -3
  16. package/src/hooks/use-display-name.ts +11 -3
  17. package/src/hooks/use-file-upload.ts +51 -0
  18. package/src/hooks/use-import.ts +5 -5
  19. package/src/hooks/use-money-locale.ts +14 -0
  20. package/src/index.ts +8 -3
  21. package/src/lib/currencies.ts +5 -2
  22. package/src/lib/form-mappers.ts +25 -5
  23. package/src/lib/formatters.ts +34 -4
  24. package/src/lib/fulfillment-items.test.ts +2 -1
  25. package/src/lib/fulfillment-items.ts +9 -3
  26. package/src/lib/i18n.ts +4 -4
  27. package/src/lib/iso-codes.ts +45 -0
  28. package/src/lib/money.test.ts +63 -0
  29. package/src/lib/money.ts +99 -0
  30. package/src/{products → lib}/normalize-money.ts +2 -2
  31. package/src/lib/return-refunds.test.ts +62 -0
  32. package/src/lib/return-refunds.ts +70 -0
  33. package/src/lib/route-registry.ts +58 -15
  34. package/src/lib/slot-registry.ts +10 -0
  35. package/src/lib/storefront.ts +2 -2
  36. package/src/locales/ar.json +4 -1
  37. package/src/locales/de.json +4 -1
  38. package/src/locales/en.json +4 -1
  39. package/src/locales/es.json +4 -1
  40. package/src/locales/fr.json +4 -1
  41. package/src/locales/pl.json +4 -1
  42. package/src/locales/zh-CN.json +4 -1
  43. package/src/plugin.ts +5 -5
  44. package/src/products/bulk-variants-dialog.tsx +1 -1
  45. package/src/products/product-bulk-price-editor.tsx +18 -29
  46. package/src/products/product-form-cards.tsx +12 -8
  47. package/src/products/product-form-mapping.ts +15 -11
  48. package/src/products/product-schema.ts +6 -8
  49. package/src/products/product-types.ts +5 -3
  50. package/src/products/use-product-form-data.ts +0 -28
  51. package/src/providers/store-provider.tsx +9 -9
  52. package/src/schemas/stock-location.ts +3 -2
  53. package/src/tables/package-types.tsx +1 -1
  54. package/src/tables/stock-locations.tsx +1 -1
  55. package/src/vite/discover.ts +19 -2
  56. package/src/hooks/use-direct-upload.ts +0 -117
  57. /package/src/{products → lib}/currency-parts.ts +0 -0
  58. /package/src/{products → lib}/normalize-money.test.ts +0 -0
@@ -1,3 +1,5 @@
1
+ import type { CountryCode, CurrencyCode } from '@spree/admin-sdk'
2
+
1
3
  /**
2
4
  * What the shared product form reads off a product, its variants and its
3
5
  * media.
@@ -42,12 +44,12 @@ export interface PanelVariant {
42
44
  price?: { amount?: string | null; currency?: string | null } | null
43
45
  prices?: Array<{
44
46
  amount?: string | null
45
- currency?: string | null
47
+ currency?: CurrencyCode | null
46
48
  compare_at_amount?: string | null
47
49
  price_list_id?: string | null
48
50
  }>
49
51
  cost_price?: string | null
50
- cost_currency?: string | null
52
+ cost_currency?: CurrencyCode | null
51
53
  weight?: number | null
52
54
  height?: number | null
53
55
  width?: number | null
@@ -55,7 +57,7 @@ export interface PanelVariant {
55
57
  weight_unit?: string | null
56
58
  dimensions_unit?: string | null
57
59
  hs_code?: string | null
58
- country_of_origin?: string | null
60
+ country_of_origin?: CountryCode | null
59
61
  customs_description?: string | null
60
62
  minimum_order_quantity?: number | null
61
63
  order_multiple?: number | null
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
2
  import type {
3
3
  PanelCollection,
4
4
  PanelDeliveryProfile,
5
- PanelMarket,
6
5
  PanelNamedRecord,
7
6
  PanelOptionType,
8
7
  PanelPackageType,
@@ -244,30 +243,3 @@ export function collectionAutocompleteProps(queryKey: string) {
244
243
  getOptionLabel: (collection: PanelCollection) => collection.name ?? collection.id,
245
244
  }
246
245
  }
247
-
248
- /**
249
- * Resolves a currency to the locale its amounts are typed and shown in, so a
250
- * EUR field accepts `1.234,56` and normalizes it to `1234.56`. Mirrors the
251
- * operator dashboard's `useCurrencyLocale`, reading through the registered
252
- * client so the shared price editor works in either panel; a panel with no
253
- * markets answers undefined and callers fall back to the UI language.
254
- */
255
- export function useFormCurrencyLocale(): (currency: string | undefined) => string | undefined {
256
- const client = getApiClient()
257
-
258
- const { data } = useQuery({
259
- queryKey: useResourceKey('markets', 'panel-form'),
260
- queryFn: async (): Promise<{ data: PanelMarket[] }> =>
261
- (await client.markets?.list({ limit: 100 })) ?? { data: [] },
262
- enabled: Boolean(client.markets),
263
- staleTime: FIVE_MINUTES,
264
- })
265
-
266
- const markets = data?.data ?? []
267
-
268
- return (currency) => {
269
- if (!currency) return undefined
270
-
271
- return markets.find((market) => market.currency === currency)?.default_locale ?? undefined
272
- }
273
- }
@@ -1,4 +1,4 @@
1
- import type { Store } from '@spree/admin-sdk'
1
+ import type { CurrencyCode, LocaleCode, Store } from '@spree/admin-sdk'
2
2
  import { useQuery } from '@tanstack/react-query'
3
3
  import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
4
4
  import { adminClient } from '../client'
@@ -19,15 +19,15 @@ interface StoreContextValue {
19
19
  store: Store | null
20
20
  storeId: string
21
21
  isLoading: boolean
22
- currencies: string[]
22
+ currencies: CurrencyCode[]
23
23
  /** Locales this store is configured to translate content into. */
24
- locales: string[]
24
+ locales: LocaleCode[]
25
25
  /** Every locale a merchant may translate content into — the full canonical
26
26
  * set, independent of which locales the store currently uses. Drives locale
27
27
  * pickers so a new locale can always be added. */
28
- availableLocales: string[]
29
- defaultCurrency: string
30
- defaultLocale: string
28
+ availableLocales: LocaleCode[]
29
+ defaultCurrency: CurrencyCode
30
+ defaultLocale: LocaleCode
31
31
  /** ISO country the store sells from — used to pre-fill new address forms. */
32
32
  defaultCountry: string
33
33
  /** IANA timezone for the store (e.g. `Europe/Berlin`). Falls back to the
@@ -64,7 +64,7 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
64
64
  // that path already owns the locale via `switchAdminLocale`, and re-applying
65
65
  // would race that PATCH and could reload before it lands. Tracking the
66
66
  // storeId it last ran for re-arms it per store, so a multi-store admin still
67
- // inherits each store's preferred_admin_locale.
67
+ // inherits each store's admin_locale.
68
68
  const localeFallbackStoreRef = useRef<string | null>(null)
69
69
 
70
70
  const query = useQuery({
@@ -83,7 +83,7 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
83
83
 
84
84
  localeFallbackStoreRef.current = storeId
85
85
  reconcileStoreDefaultLocale(
86
- store.preferred_admin_locale,
86
+ store.admin_locale,
87
87
  storeId,
88
88
  accountLocaleRef.current,
89
89
  coreLocaleCodes(),
@@ -99,7 +99,7 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
99
99
  // A store's zone preference defaults to the host app's `Time.zone.name`,
100
100
  // which may be a Rails name ("Eastern Time (US & Canada)") that Intl and
101
101
  // date-fns-tz reject — falling back keeps every date control working.
102
- const timezone = resolveTimezone(store?.preferred_timezone)
102
+ const timezone = resolveTimezone(store?.timezone)
103
103
 
104
104
  return (
105
105
  <StoreContext.Provider
@@ -2,6 +2,7 @@ import { requiredMessage } from '@spree/dashboard-ui'
2
2
  import { z } from 'zod/v4'
3
3
  import type { PanelStockLocation, PanelStockLocationCreateParams } from '../api-client'
4
4
  import { blankToUndefined, emptyToUndefined } from '../lib/form-mappers'
5
+ import { countryCodeSchema } from '../lib/iso-codes'
5
6
 
6
7
  // Labels live in `en.json` under `admin.stock_locations.kinds.*` and
7
8
  // `admin.stock_locations.pickup_stock_policies.*`. Consumers map these
@@ -27,7 +28,7 @@ export const stockLocationFormSchema = z.object({
27
28
  zipcode: z.string().optional(),
28
29
  phone: z.string().optional(),
29
30
  company: z.string().optional(),
30
- country_code: z.string().optional(),
31
+ country_code: countryCodeSchema.or(z.literal('')).optional(),
31
32
  state_code: z.string().optional(),
32
33
  state_name: z.string().optional(),
33
34
  pickup_enabled: z.boolean(),
@@ -114,7 +115,7 @@ export function formValuesToParams(v: StockLocationFormValues): PanelStockLocati
114
115
  zipcode: blankToUndefined(v.zipcode),
115
116
  phone: blankToUndefined(v.phone),
116
117
  company: blankToUndefined(v.company),
117
- country_code: blankToUndefined(v.country_code),
118
+ country_code: v.country_code || undefined,
118
119
  state_code: blankToUndefined(v.state_code),
119
120
  state_name: blankToUndefined(v.state_name),
120
121
  pickup_enabled: v.pickup_enabled,
@@ -8,7 +8,7 @@ defineTable<PanelPackageType>('package-types', {
8
8
  docsPath: 'settings/package-types',
9
9
  description: i18n.t('admin.package_types.description'),
10
10
  title: i18n.t('admin.package_types.table_title'),
11
- searchParam: 'name_cont',
11
+ searchParam: 'search',
12
12
  searchPlaceholder: i18n.t('admin.package_types.search_placeholder'),
13
13
  defaultSort: { field: 'name', direction: 'asc' },
14
14
  emptyIcon: <PackageIcon className="size-8 text-muted-foreground" />,
@@ -8,7 +8,7 @@ defineTable<PanelStockLocation>('stock-locations', {
8
8
  docsPath: 'settings/locations',
9
9
  description: i18n.t('admin.stock_locations.description'),
10
10
  title: i18n.t('admin.settings_nav.items.stock_locations'),
11
- searchParam: 'name_cont',
11
+ searchParam: 'search',
12
12
  searchPlaceholder: i18n.t('admin.stock_locations.search_placeholder'),
13
13
  defaultSort: { field: 'name', direction: 'asc' },
14
14
  emptyIcon: <WarehouseIcon className="size-8 text-muted-foreground" />,
@@ -39,6 +39,17 @@ export interface DashboardPluginManifest {
39
39
  * Compiled into the host's route tree by `@spree/dashboard/vite`.
40
40
  */
41
41
  routesDir?: string
42
+ /**
43
+ * Absolute path to the plugin's directory of signed-in pages outside any
44
+ * store (`"authenticatedRoutes"` in the marker), mounted at the root inside
45
+ * the dashboard's auth guard.
46
+ */
47
+ authenticatedRoutesDir?: string
48
+ /**
49
+ * Absolute path to the plugin's directory of public pages
50
+ * (`"publicRoutes"` in the marker), mounted at the root with no sign-in.
51
+ */
52
+ publicRoutesDir?: string
42
53
  }
43
54
 
44
55
  /**
@@ -68,10 +79,14 @@ export function discoverDashboardPluginManifests(
68
79
  manifests.push({ name })
69
80
  continue
70
81
  }
71
- const routes = pkg.spree?.dashboard?.routes
82
+ const marker = pkg.spree?.dashboard
83
+ const resolveDir = (dir?: string) =>
84
+ dir ? path.resolve(path.dirname(manifestPath), dir) : undefined
72
85
  manifests.push({
73
86
  name,
74
- routesDir: routes ? path.resolve(path.dirname(manifestPath), routes) : undefined,
87
+ routesDir: resolveDir(marker?.routes),
88
+ authenticatedRoutesDir: resolveDir(marker?.authenticatedRoutes),
89
+ publicRoutesDir: resolveDir(marker?.publicRoutes),
75
90
  })
76
91
  }
77
92
  return manifests
@@ -156,6 +171,8 @@ interface PluginManifest {
156
171
  dashboard?: {
157
172
  plugin?: boolean
158
173
  routes?: string
174
+ authenticatedRoutes?: string
175
+ publicRoutes?: string
159
176
  }
160
177
  }
161
178
  }
@@ -1,117 +0,0 @@
1
- import { useMutation } from '@tanstack/react-query'
2
- import SparkMD5 from 'spark-md5'
3
- import { getApiClient } from '../api-client'
4
-
5
- function computeMD5Checksum(file: File): Promise<string> {
6
- return new Promise((resolve, reject) => {
7
- const chunkSize = 2097152 // 2MB chunks
8
- const spark = new SparkMD5.ArrayBuffer()
9
- const reader = new FileReader()
10
- let currentChunk = 0
11
- const chunks = Math.ceil(file.size / chunkSize)
12
-
13
- reader.onload = (e) => {
14
- spark.append(e.target!.result as ArrayBuffer)
15
- currentChunk++
16
-
17
- if (currentChunk < chunks) {
18
- loadNext()
19
- } else {
20
- // Active Storage expects base64-encoded raw MD5 digest
21
- resolve(btoa(spark.end(true)))
22
- }
23
- }
24
-
25
- reader.onerror = () => reject(reader.error)
26
-
27
- function loadNext() {
28
- const start = currentChunk * chunkSize
29
- const end = Math.min(start + chunkSize, file.size)
30
- reader.readAsArrayBuffer(file.slice(start, end))
31
- }
32
-
33
- loadNext()
34
- })
35
- }
36
-
37
- interface UploadResult {
38
- signedId: string
39
- previewUrl: string
40
- }
41
-
42
- function sameOriginIfRailsDisk(url: string): string {
43
- try {
44
- const parsed = new URL(url)
45
- if (parsed.pathname.startsWith('/rails/active_storage/')) {
46
- return parsed.pathname + parsed.search
47
- }
48
- return url
49
- } catch {
50
- return url
51
- }
52
- }
53
-
54
- interface DirectUploadOptions {
55
- /**
56
- * Upload to private storage instead of the public bucket. Required for files
57
- * that are only ever served through an authorized, signed link — attaching a
58
- * blob never moves it between services, so this has to be decided up front.
59
- */
60
- private?: boolean
61
- }
62
-
63
- export function useDirectUpload(options: DirectUploadOptions = {}) {
64
- return useMutation({
65
- mutationFn: async (file: File): Promise<UploadResult> => {
66
- const checksum = await computeMD5Checksum(file)
67
-
68
- // Step 1: Get presigned upload URL
69
- let response: {
70
- direct_upload: { url: string; headers: Record<string, string> }
71
- signed_id: string
72
- }
73
- try {
74
- const presign = getApiClient().createDirectUpload
75
- if (!presign) throw new Error('This panel cannot upload files.')
76
-
77
- response = await presign({
78
- blob: {
79
- filename: file.name,
80
- byte_size: file.size,
81
- checksum,
82
- content_type: file.type || 'application/octet-stream',
83
- },
84
- ...(options.private ? { private: true } : {}),
85
- })
86
- } catch (err) {
87
- throw new Error(`Presign failed: ${err instanceof Error ? err.message : err}`)
88
- }
89
-
90
- // Step 2: Upload file directly to storage.
91
- //
92
- // Active Storage's Disk service issues an absolute URL against the Rails
93
- // origin (e.g. http://localhost:3000/rails/active_storage/disk/...). In
94
- // dev the SPA runs on a different port and that controller doesn't speak
95
- // CORS, so the browser blocks the cross-origin PUT before any response
96
- // ("Failed to fetch"). When the URL points at a `/rails/active_storage/`
97
- // path we drop the host so the request goes through Vite's `/rails`
98
- // proxy and stays same-origin. Production S3 URLs are untouched.
99
- const uploadUrl = sameOriginIfRailsDisk(response.direct_upload.url)
100
- const uploadResponse = await fetch(uploadUrl, {
101
- method: 'PUT',
102
- headers: response.direct_upload.headers,
103
- body: file,
104
- })
105
-
106
- if (!uploadResponse.ok) {
107
- const text = await uploadResponse.text().catch(() => '')
108
- throw new Error(`Storage upload failed (${uploadResponse.status}): ${text}`)
109
- }
110
-
111
- return {
112
- signedId: response.signed_id,
113
- previewUrl: URL.createObjectURL(file),
114
- }
115
- },
116
- })
117
- }
File without changes
File without changes