@spree/dashboard-core 1.0.0-rc.1 → 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 +9 -14
  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 +33 -28
  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,12 +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
75
- ? linkedPackagePath(root, name, path.resolve(path.dirname(manifestPath), routes))
76
- : undefined,
87
+ routesDir: resolveDir(marker?.routes),
88
+ authenticatedRoutesDir: resolveDir(marker?.authenticatedRoutes),
89
+ publicRoutesDir: resolveDir(marker?.publicRoutes),
77
90
  })
78
91
  }
79
92
  return manifests
@@ -128,34 +141,24 @@ export function discoverDashboardPlugins({ root, onWarn }: DiscoverOptions): str
128
141
  }
129
142
 
130
143
  /**
131
- * Re-expresses a path inside an installed package through the package's
132
- * `node_modules/<name>` link, rather than the real location Node resolves to.
144
+ * The routes directory of an installed dashboard shell (`@spree/dashboard`,
145
+ * `@spree/seller-dashboard`), at its real location. The shell's `.` export
146
+ * maps to a file in `src/`, so `routes/` is its sibling directory.
133
147
  *
134
- * Under pnpm the real location is `node_modules/.pnpm/<name>@<version>_<hash>/…`,
135
- * which changes with every install. The route generator writes these paths
136
- * into the host's committed `routeTree.gen.ts`, so a real location made the
137
- * file differ on a new project's first dev start and after every upgrade —
138
- * and each rewrite reloaded the open page. The link stays put across installs
139
- * and is the same inside the monorepo, so the file only changes when the
140
- * routes do.
148
+ * The route generator imports these files from the host's `routeTree.gen.ts`.
149
+ * Under pnpm a package is reached through a `node_modules/<name>` link, while
150
+ * its own dependencies sit next to its real location under
151
+ * `node_modules/.pnpm/`. TypeScript does not follow the link for a relative
152
+ * import, so a route file imported through it cannot see the shell's
153
+ * dependencies and the host's type check fails. Plugin routes directories
154
+ * resolve to their real location for the same reason.
141
155
  *
142
156
  * @param root host project root
143
- * @param packageName the package the path belongs to
144
- * @param resolvedPath a path inside that package, as Node resolved it
145
- * @returns the same path through the link, or `resolvedPath` when no link
146
- * leads to it (the package resolving itself, an unusual layout)
157
+ * @param packageName the shell package the host depends on
147
158
  */
148
- export function linkedPackagePath(root: string, packageName: string, resolvedPath: string): string {
149
- const realPath = fs.realpathSync(resolvedPath)
150
- for (let dir = root; ; dir = path.dirname(dir)) {
151
- const link = path.join(dir, 'node_modules', packageName)
152
- if (fs.existsSync(path.join(link, 'package.json'))) {
153
- const inside = path.relative(fs.realpathSync(link), realPath)
154
- if (inside.startsWith('..') || path.isAbsolute(inside)) return resolvedPath
155
- return path.join(link, inside)
156
- }
157
- if (path.dirname(dir) === dir) return resolvedPath
158
- }
159
+ export function resolveShellRoutesDir(root: string, packageName: string): string {
160
+ const require = createRequire(path.join(root, 'package.json'))
161
+ return path.join(path.dirname(fs.realpathSync(require.resolve(packageName))), 'routes')
159
162
  }
160
163
 
161
164
  interface HostManifest {
@@ -168,6 +171,8 @@ interface PluginManifest {
168
171
  dashboard?: {
169
172
  plugin?: boolean
170
173
  routes?: string
174
+ authenticatedRoutes?: string
175
+ publicRoutes?: string
171
176
  }
172
177
  }
173
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