@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.
- package/dist/vite/discover.js +5 -2
- package/package.json +13 -16
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +18 -13
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +263 -54
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/resource-table.tsx +7 -3
- package/src/components/table-toolbar.tsx +8 -5
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +32 -13
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +47 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/permissions.ts +2 -0
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +21 -4
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +7 -2
- package/src/locales/de.json +7 -2
- package/src/locales/en.json +7 -2
- package/src/locales/es.json +7 -2
- package/src/locales/fr.json +7 -2
- package/src/locales/pl.json +7 -2
- package/src/locales/zh-CN.json +7 -2
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +2 -8
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +31 -25
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +19 -2
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /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
|
-
<
|
|
365
|
-
<
|
|
367
|
+
<Alert variant="warning">
|
|
368
|
+
<TriangleAlertIcon />
|
|
369
|
+
<AlertTitle>
|
|
366
370
|
{t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
|
|
367
|
-
</
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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:
|
|
22
|
+
currencies: CurrencyCode[]
|
|
23
23
|
/** Locales this store is configured to translate content into. */
|
|
24
|
-
locales:
|
|
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:
|
|
29
|
-
defaultCurrency:
|
|
30
|
-
defaultLocale:
|
|
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
|
|
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.
|
|
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?.
|
|
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.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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" />,
|
package/src/vite/discover.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|