@spree/dashboard-core 1.0.0-rc.2 → 1.0.0-rc.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 (68) hide show
  1. package/dist/vite/discover.js +5 -2
  2. package/package.json +13 -16
  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 +18 -13
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/import-wizard-dialog.tsx +8 -11
  13. package/src/components/mobile-top-bar.tsx +6 -0
  14. package/src/components/money.tsx +23 -0
  15. package/src/components/preferences-form.tsx +263 -54
  16. package/src/components/resource-combobox.tsx +5 -0
  17. package/src/components/resource-picker-sheet.tsx +18 -12
  18. package/src/components/resource-table.tsx +7 -3
  19. package/src/components/table-toolbar.tsx +8 -5
  20. package/src/hooks/use-display-name.ts +11 -3
  21. package/src/hooks/use-file-upload.ts +51 -0
  22. package/src/hooks/use-import.ts +5 -5
  23. package/src/hooks/use-money-locale.ts +14 -0
  24. package/src/index.ts +8 -3
  25. package/src/lib/currencies.ts +32 -13
  26. package/src/lib/form-mappers.ts +25 -5
  27. package/src/lib/formatters.ts +34 -4
  28. package/src/lib/fulfillment-items.test.ts +2 -1
  29. package/src/lib/fulfillment-items.ts +9 -3
  30. package/src/lib/i18n.ts +4 -4
  31. package/src/lib/iso-codes.ts +47 -0
  32. package/src/lib/money.test.ts +63 -0
  33. package/src/lib/money.ts +99 -0
  34. package/src/{products → lib}/normalize-money.ts +2 -2
  35. package/src/lib/permissions.ts +2 -0
  36. package/src/lib/return-refunds.test.ts +62 -0
  37. package/src/lib/return-refunds.ts +70 -0
  38. package/src/lib/route-registry.ts +58 -15
  39. package/src/lib/settings-nav-registry.test.ts +34 -0
  40. package/src/lib/settings-nav-registry.ts +21 -4
  41. package/src/lib/slot-registry.ts +10 -0
  42. package/src/lib/storefront.ts +2 -2
  43. package/src/locales/ar.json +7 -2
  44. package/src/locales/de.json +7 -2
  45. package/src/locales/en.json +7 -2
  46. package/src/locales/es.json +7 -2
  47. package/src/locales/fr.json +7 -2
  48. package/src/locales/pl.json +7 -2
  49. package/src/locales/zh-CN.json +7 -2
  50. package/src/plugin.ts +5 -5
  51. package/src/products/bulk-variants-dialog.tsx +2 -8
  52. package/src/products/inventory-section.tsx +2 -2
  53. package/src/products/product-bulk-price-editor.tsx +18 -29
  54. package/src/products/product-form-cards.tsx +12 -8
  55. package/src/products/product-form-mapping.ts +15 -11
  56. package/src/products/product-schema.ts +6 -8
  57. package/src/products/product-types.ts +5 -3
  58. package/src/products/use-product-form-data.ts +0 -28
  59. package/src/products/variants-options-builder.tsx +30 -27
  60. package/src/products/variants-section.tsx +31 -25
  61. package/src/providers/store-provider.tsx +9 -9
  62. package/src/schemas/stock-location.ts +3 -2
  63. package/src/tables/package-types.tsx +1 -1
  64. package/src/tables/stock-locations.tsx +1 -1
  65. package/src/vite/discover.ts +19 -2
  66. package/src/hooks/use-direct-upload.ts +0 -117
  67. /package/src/{products → lib}/currency-parts.ts +0 -0
  68. /package/src/{products → lib}/normalize-money.test.ts +0 -0
@@ -16,6 +16,9 @@ import {
16
16
  import { CSS } from '@dnd-kit/utilities'
17
17
  import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
18
18
  import {
19
+ Alert,
20
+ AlertDescription,
21
+ AlertTitle,
19
22
  Button,
20
23
  Card,
21
24
  CardContent,
@@ -31,7 +34,7 @@ import {
31
34
  TableHeader,
32
35
  TableRow,
33
36
  } from '@spree/dashboard-ui'
34
- import { PencilIcon, SwatchBookIcon, XIcon } from '@spree/dashboard-ui/icons'
37
+ import { PencilIcon, SwatchBookIcon, TriangleAlertIcon, XIcon } from '@spree/dashboard-ui/icons'
35
38
  import { type CSSProperties, useEffect, useMemo, useState } from 'react'
36
39
  import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
37
40
  import { useTranslation } from 'react-i18next'
@@ -361,31 +364,34 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
361
364
  />
362
365
 
363
366
  {orphanedKeys.length > 0 && (
364
- <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40">
365
- <p className="font-medium text-amber-900 dark:text-amber-100">
367
+ <Alert variant="warning">
368
+ <TriangleAlertIcon />
369
+ <AlertTitle>
366
370
  {t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
367
- </p>
368
- {orphanedEntries.length > 0 && (
369
- <ul className="mt-1 list-disc pl-5 text-amber-900 dark:text-amber-200">
370
- {orphanedEntries.map((entry) => (
371
- <li key={entry.key}>{entry.label}</li>
372
- ))}
373
- </ul>
374
- )}
375
- <div className="mt-2 flex items-center justify-end gap-2">
376
- <Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
377
- {t('admin.products.variants.keep')}
378
- </Button>
379
- <Button
380
- type="button"
381
- variant="destructive"
382
- size="sm"
383
- onClick={handleConfirmDropOrphans}
384
- >
385
- {t('admin.actions.remove')}
386
- </Button>
387
- </div>
388
- </div>
371
+ </AlertTitle>
372
+ <AlertDescription>
373
+ {orphanedEntries.length > 0 && (
374
+ <ul className="list-disc pl-5">
375
+ {orphanedEntries.map((entry) => (
376
+ <li key={entry.key}>{entry.label}</li>
377
+ ))}
378
+ </ul>
379
+ )}
380
+ <div className="mt-2 flex items-center justify-end gap-2">
381
+ <Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
382
+ {t('admin.products.variants.keep')}
383
+ </Button>
384
+ <Button
385
+ type="button"
386
+ variant="destructive"
387
+ size="sm"
388
+ onClick={handleConfirmDropOrphans}
389
+ >
390
+ {t('admin.actions.remove')}
391
+ </Button>
392
+ </div>
393
+ </AlertDescription>
394
+ </Alert>
389
395
  )}
390
396
 
391
397
  {fields.length > 0 && !awaitingOptionValues && (
@@ -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