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

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,126 @@
1
+ import { requiredMessage } from '@spree/dashboard-ui'
2
+ import { z } from 'zod/v4'
3
+ import type { PanelStockLocation, PanelStockLocationCreateParams } from '../api-client'
4
+ import { blankToUndefined, emptyToUndefined } from '../lib/form-mappers'
5
+
6
+ // Labels live in `en.json` under `admin.stock_locations.kinds.*` and
7
+ // `admin.stock_locations.pickup_stock_policies.*`. Consumers map these
8
+ // values to translated labels at render time.
9
+ export const STOCK_LOCATION_KINDS = ['warehouse', 'store', 'fulfillment_center'] as const
10
+ export const PICKUP_STOCK_POLICIES = ['local', 'any'] as const
11
+ export type PickupStockPolicy = (typeof PICKUP_STOCK_POLICIES)[number]
12
+
13
+ export const stockLocationFormSchema = z.object({
14
+ name: z
15
+ .string()
16
+ .trim()
17
+ .min(1, { error: requiredMessage('name') }),
18
+ admin_name: z.string().optional(),
19
+ kind: z.enum(STOCK_LOCATION_KINDS),
20
+ active: z.boolean(),
21
+ default: z.boolean(),
22
+ propagate_all_variants: z.boolean(),
23
+ backorderable_default: z.boolean(),
24
+ address1: z.string().optional(),
25
+ address2: z.string().optional(),
26
+ city: z.string().optional(),
27
+ zipcode: z.string().optional(),
28
+ phone: z.string().optional(),
29
+ company: z.string().optional(),
30
+ country_code: z.string().optional(),
31
+ state_code: z.string().optional(),
32
+ state_name: z.string().optional(),
33
+ pickup_enabled: z.boolean(),
34
+ returns_enabled: z.boolean(),
35
+ pickup_stock_policy: z.enum(PICKUP_STOCK_POLICIES),
36
+ pickup_ready_in_minutes: z.preprocess(
37
+ emptyToUndefined,
38
+ z.coerce.number().int().min(0).optional().nullable(),
39
+ ),
40
+ pickup_instructions: z.string().optional(),
41
+ })
42
+
43
+ export type StockLocationFormValues = z.infer<typeof stockLocationFormSchema>
44
+
45
+ export const STOCK_LOCATION_DEFAULTS: StockLocationFormValues = {
46
+ name: '',
47
+ admin_name: '',
48
+ kind: 'warehouse',
49
+ active: true,
50
+ default: false,
51
+ propagate_all_variants: false,
52
+ backorderable_default: false,
53
+ address1: '',
54
+ address2: '',
55
+ city: '',
56
+ zipcode: '',
57
+ phone: '',
58
+ company: '',
59
+ country_code: '',
60
+ state_code: '',
61
+ state_name: '',
62
+ pickup_enabled: false,
63
+ // Locations accept returns unless a merchant narrows it, which is how
64
+ // goods behaved before the flag existed.
65
+ returns_enabled: true,
66
+ pickup_stock_policy: 'local',
67
+ pickup_ready_in_minutes: null,
68
+ pickup_instructions: '',
69
+ }
70
+
71
+ export function stockLocationToFormValues(sl: PanelStockLocation): StockLocationFormValues {
72
+ return {
73
+ name: sl.name,
74
+ admin_name: sl.admin_name ?? '',
75
+ kind: STOCK_LOCATION_KINDS.includes(sl.kind as (typeof STOCK_LOCATION_KINDS)[number])
76
+ ? (sl.kind as (typeof STOCK_LOCATION_KINDS)[number])
77
+ : 'warehouse',
78
+ active: sl.active,
79
+ default: sl.default,
80
+ propagate_all_variants: sl.propagate_all_variants ?? false,
81
+ backorderable_default: sl.backorderable_default ?? false,
82
+ address1: sl.address1 ?? '',
83
+ address2: sl.address2 ?? '',
84
+ city: sl.city ?? '',
85
+ zipcode: sl.zipcode ?? '',
86
+ phone: sl.phone ?? '',
87
+ company: sl.company ?? '',
88
+ country_code: sl.country_code ?? '',
89
+ state_code: sl.state_code ?? '',
90
+ state_name: sl.state_name ?? '',
91
+ pickup_enabled: sl.pickup_enabled ?? false,
92
+ returns_enabled: sl.returns_enabled ?? true,
93
+ pickup_stock_policy: PICKUP_STOCK_POLICIES.includes(sl.pickup_stock_policy as PickupStockPolicy)
94
+ ? (sl.pickup_stock_policy as PickupStockPolicy)
95
+ : 'local',
96
+ pickup_ready_in_minutes: sl.pickup_ready_in_minutes ?? null,
97
+ pickup_instructions: sl.pickup_instructions ?? '',
98
+ }
99
+ }
100
+
101
+ // Drops blank strings → undefined so we don't overwrite null fields with "".
102
+ export function formValuesToParams(v: StockLocationFormValues): PanelStockLocationCreateParams {
103
+ return {
104
+ name: v.name,
105
+ admin_name: blankToUndefined(v.admin_name),
106
+ kind: v.kind,
107
+ active: v.active,
108
+ default: v.default,
109
+ propagate_all_variants: v.propagate_all_variants,
110
+ backorderable_default: v.backorderable_default,
111
+ address1: blankToUndefined(v.address1),
112
+ address2: blankToUndefined(v.address2),
113
+ city: blankToUndefined(v.city),
114
+ zipcode: blankToUndefined(v.zipcode),
115
+ phone: blankToUndefined(v.phone),
116
+ company: blankToUndefined(v.company),
117
+ country_code: blankToUndefined(v.country_code),
118
+ state_code: blankToUndefined(v.state_code),
119
+ state_name: blankToUndefined(v.state_name),
120
+ pickup_enabled: v.pickup_enabled,
121
+ returns_enabled: v.returns_enabled,
122
+ pickup_stock_policy: v.pickup_stock_policy,
123
+ pickup_ready_in_minutes: v.pickup_ready_in_minutes ?? null,
124
+ pickup_instructions: blankToUndefined(v.pickup_instructions),
125
+ }
126
+ }
@@ -0,0 +1,74 @@
1
+ import { RelativeTime, ResourceNameCell } from '@spree/dashboard-ui'
2
+ import { PackageIcon } from '@spree/dashboard-ui/icons'
3
+ import i18n from 'i18next'
4
+ import type { PanelPackageType } from '../api-client'
5
+ import { defineTable } from '../lib/table-registry'
6
+
7
+ defineTable<PanelPackageType>('package-types', {
8
+ docsPath: 'settings/package-types',
9
+ description: i18n.t('admin.package_types.description'),
10
+ title: i18n.t('admin.package_types.table_title'),
11
+ searchParam: 'name_cont',
12
+ searchPlaceholder: i18n.t('admin.package_types.search_placeholder'),
13
+ defaultSort: { field: 'name', direction: 'asc' },
14
+ emptyIcon: <PackageIcon className="size-8 text-muted-foreground" />,
15
+ emptyMessage: i18n.t('admin.package_types.empty'),
16
+ columns: [
17
+ {
18
+ key: 'name',
19
+ label: i18n.t('admin.fields.package_type.name.label'),
20
+ sortable: true,
21
+ default: true,
22
+ render: (packageType) => (
23
+ <ResourceNameCell
24
+ id={packageType.id}
25
+ // Omitted on a row this panel may read but not write, so the row
26
+ // click bridge never opens an edit sheet whose save could only
27
+ // 404. A seller sees the marketplace's packaging to know what they
28
+ // may pack into; it is not theirs to change.
29
+ dataAttr={packageType.editable === false ? undefined : 'data-package-type-id'}
30
+ name={packageType.name}
31
+ // This owner's own box, called out because every parcel quote from
32
+ // their warehouse is built on it.
33
+ secondary={packageType.default ? i18n.t('admin.package_types.default_badge') : undefined}
34
+ />
35
+ ),
36
+ },
37
+ {
38
+ key: 'kind',
39
+ label: i18n.t('admin.fields.package_type.kind.label'),
40
+ sortable: true,
41
+ default: true,
42
+ render: (packageType) => i18n.t(`admin.package_types.kinds.${packageType.kind}`),
43
+ },
44
+ {
45
+ key: 'dimensions',
46
+ label: i18n.t('admin.package_types.dimensions_column'),
47
+ default: true,
48
+ className: 'text-sm text-muted-foreground whitespace-nowrap',
49
+ // Three numbers mean nothing without the unit they were measured in —
50
+ // the same figures read as inches or centimetres differ sixteenfold by
51
+ // volume, which is what the freight tiers compare.
52
+ render: (packageType) =>
53
+ packageType.length && packageType.width && packageType.height
54
+ ? `${packageType.length} × ${packageType.width} × ${packageType.height} ${packageType.dimensions_unit}`
55
+ : '—',
56
+ },
57
+ {
58
+ key: 'volume',
59
+ label: i18n.t('admin.package_types.volume_column'),
60
+ className: 'text-sm text-muted-foreground whitespace-nowrap',
61
+ render: (packageType) =>
62
+ packageType.volume ? `${Number(packageType.volume).toFixed(3)} m³` : '—',
63
+ },
64
+ {
65
+ key: 'created_at',
66
+ label: i18n.t('admin.fields.created_at.label'),
67
+ sortable: true,
68
+ filterable: true,
69
+ filterType: 'date',
70
+ className: 'text-sm text-muted-foreground whitespace-nowrap',
71
+ render: (packageType) => <RelativeTime iso={packageType.created_at} />,
72
+ },
73
+ ],
74
+ })
@@ -0,0 +1,124 @@
1
+ import { ActiveBadge, ResourceNameCell } from '@spree/dashboard-ui'
2
+ import { WarehouseIcon } from '@spree/dashboard-ui/icons'
3
+ import i18n from 'i18next'
4
+ import type { PanelStockLocation } from '../api-client'
5
+ import { defineTable } from '../lib/table-registry'
6
+
7
+ defineTable<PanelStockLocation>('stock-locations', {
8
+ docsPath: 'settings/locations',
9
+ description: i18n.t('admin.stock_locations.description'),
10
+ title: i18n.t('admin.settings_nav.items.stock_locations'),
11
+ searchParam: 'name_cont',
12
+ searchPlaceholder: i18n.t('admin.stock_locations.search_placeholder'),
13
+ defaultSort: { field: 'name', direction: 'asc' },
14
+ emptyIcon: <WarehouseIcon className="size-8 text-muted-foreground" />,
15
+ emptyMessage: i18n.t('admin.stock_locations.empty'),
16
+ columns: [
17
+ {
18
+ key: 'name',
19
+ label: i18n.t('admin.fields.name.label'),
20
+ sortable: true,
21
+ default: true,
22
+ render: (sl) => (
23
+ <ResourceNameCell
24
+ id={sl.id}
25
+ dataAttr="data-stock-location-id"
26
+ name={sl.name}
27
+ secondary={sl.admin_name}
28
+ />
29
+ ),
30
+ },
31
+ {
32
+ key: 'kind',
33
+ label: i18n.t('admin.stock_locations.columns.type'),
34
+ sortable: true,
35
+ filterable: true,
36
+ default: true,
37
+ filterType: 'enum',
38
+ filterOptions: [
39
+ { value: 'warehouse', label: i18n.t('admin.stock_locations.kinds.warehouse') },
40
+ { value: 'store', label: i18n.t('admin.stock_locations.kinds.store') },
41
+ {
42
+ value: 'fulfillment_center',
43
+ label: i18n.t('admin.stock_locations.kinds.fulfillment_center'),
44
+ },
45
+ ],
46
+ quickFilter: true,
47
+ render: (sl) => {
48
+ const key = `admin.stock_locations.kinds.${sl.kind}`
49
+ const label = i18n.exists(key) ? i18n.t(key) : sl.kind.replace('_', ' ')
50
+ return <span className="capitalize">{label}</span>
51
+ },
52
+ },
53
+ {
54
+ key: 'city',
55
+ label: i18n.t('admin.stock_locations.columns.location'),
56
+ default: true,
57
+ render: (sl) => {
58
+ const parts = [sl.city, sl.state_text || sl.state_code, sl.country_code]
59
+ .filter(Boolean)
60
+ .join(', ')
61
+ return parts || '—'
62
+ },
63
+ },
64
+ {
65
+ key: 'pickup_enabled',
66
+ label: i18n.t('admin.stock_locations.columns.pickup'),
67
+ sortable: true,
68
+ filterable: true,
69
+ default: true,
70
+ filterType: 'boolean',
71
+ render: (sl) => (
72
+ <ActiveBadge
73
+ active={sl.pickup_enabled}
74
+ activeLabel={i18n.t('admin.stock_locations.badges.enabled')}
75
+ dashWhenInactive
76
+ />
77
+ ),
78
+ },
79
+ {
80
+ key: 'returns_enabled',
81
+ label: i18n.t('admin.stock_locations.columns.returns'),
82
+ sortable: true,
83
+ filterable: true,
84
+ default: true,
85
+ filterType: 'boolean',
86
+ render: (sl) => (
87
+ <ActiveBadge
88
+ active={sl.returns_enabled ?? true}
89
+ activeLabel={i18n.t('admin.stock_locations.badges.enabled')}
90
+ dashWhenInactive
91
+ />
92
+ ),
93
+ },
94
+ {
95
+ key: 'active',
96
+ label: i18n.t('admin.fields.active.label'),
97
+ sortable: true,
98
+ filterable: true,
99
+ default: true,
100
+ filterType: 'boolean',
101
+ render: (sl) => (
102
+ <ActiveBadge
103
+ active={sl.active}
104
+ activeLabel={i18n.t('admin.fields.active.label')}
105
+ inactiveLabel={i18n.t('admin.stock_locations.badges.inactive')}
106
+ />
107
+ ),
108
+ },
109
+ {
110
+ key: 'default',
111
+ label: i18n.t('admin.stock_locations.columns.default'),
112
+ default: true,
113
+ filterable: true,
114
+ filterType: 'boolean',
115
+ render: (sl) => (
116
+ <ActiveBadge
117
+ active={sl.default}
118
+ activeLabel={i18n.t('admin.stock_locations.badges.default')}
119
+ dashWhenInactive
120
+ />
121
+ ),
122
+ },
123
+ ],
124
+ })
package/src/vite/index.ts CHANGED
@@ -21,6 +21,17 @@ export interface SpreeDashboardPluginOptions {
21
21
  */
22
22
  cssEntry?: string
23
23
 
24
+ /**
25
+ * Shell packages whose source Tailwind should scan, on top of
26
+ * `@spree/dashboard-core` and `@spree/dashboard-ui`. Defaults to
27
+ * `['@spree/dashboard']`, the operator's dashboard. A different panel — a
28
+ * seller's, or a host's own shell — passes its own package name here so its
29
+ * components' classes survive the build. Unresolvable names are skipped
30
+ * rather than erroring: a custom dashboard composed straight from core + ui
31
+ * has no shell at all.
32
+ */
33
+ shellPackages?: string[]
34
+
24
35
  /**
25
36
  * Names of dashboard plugin packages installed in the host app. Each name
26
37
  * must be a resolvable npm package specifier — the same string you'd pass
@@ -83,11 +94,29 @@ export interface SpreeDashboardPluginOptions {
83
94
  *
84
95
  * spreeDashboardPlugin({
85
96
  * cssEntry: './src/admin.css',
86
- * plugins: ['@my-store/vendor-portal-plugin'], // explicit whitelist opt-out
97
+ * plugins: ['@my-store/seller-portal-plugin'], // explicit whitelist opt-out
87
98
  * })
88
99
  */
89
100
  export function spreeDashboardPlugin(options: SpreeDashboardPluginOptions = {}): PluginOption[] {
90
- return [dashboardTailwindSourcePlugin(options), tailwindcss()]
101
+ return [dashboardProseMirrorDedupePlugin(), dashboardTailwindSourcePlugin(options), tailwindcss()]
102
+ }
103
+
104
+ /**
105
+ * TipTap 3.31's `@tiptap/pm` asks for `prosemirror-model` ^1.25.11, but
106
+ * `prosemirror-schema-list` still resolves 1.25.4. Two copies in the
107
+ * bundle make `wrapInList` throw. Force Vite onto one module.
108
+ */
109
+ function dashboardProseMirrorDedupePlugin(): Plugin {
110
+ return {
111
+ name: 'spree:dashboard-prosemirror-dedupe',
112
+ config() {
113
+ return {
114
+ resolve: {
115
+ dedupe: ['prosemirror-model', 'prosemirror-view', '@tiptap/pm', '@tiptap/core'],
116
+ },
117
+ }
118
+ },
119
+ }
91
120
  }
92
121
 
93
122
  /** Module specifier hosts import to activate discovered dashboard plugins. */
@@ -155,11 +184,15 @@ function dashboardTailwindSourcePlugin(options: SpreeDashboardPluginOptions): Pl
155
184
  pluginErrors.push({ pkg, kind: 'unresolved' })
156
185
  }
157
186
  }
158
- // The app shell is optional: present when the host builds on the full
159
- // `@spree/dashboard` package, absent for custom dashboards composed
160
- // directly from core + ui — hence no error when unresolved.
161
- const shellDir = resolvePackageSourceDir('@spree/dashboard', fromHost)
162
- if (shellDir) resolvedSources.push(shellDir)
187
+ // The app shell is optional: present when the host builds on a full
188
+ // shell package, absent for custom dashboards composed directly from
189
+ // core + ui — hence no error when unresolved. Which shell varies by
190
+ // panel: `@spree/dashboard` for the operator's, `@spree/seller-dashboard`
191
+ // for a seller's, or a host's own.
192
+ for (const shell of options.shellPackages ?? ['@spree/dashboard']) {
193
+ const shellDir = resolvePackageSourceDir(shell, fromHost)
194
+ if (shellDir) resolvedSources.push(shellDir)
195
+ }
163
196
  for (const pkg of pluginPackages) {
164
197
  const dir = resolvePackageSourceDir(pkg, fromHost)
165
198
  if (dir) {
@@ -1,209 +0,0 @@
1
- import {
2
- Avatar,
3
- AvatarFallback,
4
- AvatarImage,
5
- Button,
6
- DropdownMenu,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- DropdownMenuSeparator,
10
- DropdownMenuTrigger,
11
- LanguageMenuItems,
12
- SidebarTrigger,
13
- ThemeMenuItems,
14
- } from '@spree/dashboard-ui'
15
- import { Link } from '@tanstack/react-router'
16
- import {
17
- BookOpenIcon,
18
- ExternalLinkIcon,
19
- LogOutIcon,
20
- MailIcon,
21
- MessageCircleIcon,
22
- SearchIcon,
23
- UserIcon,
24
- } from 'lucide-react'
25
- import { useTranslation } from 'react-i18next'
26
- import { useAuth } from '../hooks/use-auth'
27
- import { useCommandPalette } from '../hooks/use-command-palette'
28
- import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
29
- import { getInitials } from '../lib/formatters'
30
- import { i18n } from '../lib/i18n'
31
- import { storefrontHref } from '../lib/storefront'
32
- import { useStore } from '../providers/store-provider'
33
-
34
- const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
35
-
36
- /**
37
- * Top-bar shell for the admin SPA. Three-slot layout:
38
- * [☰ sidebar toggle] [global search] [view store · user menu]
39
- *
40
- * The search trigger opens the global ⌘K command palette (provider mounted at
41
- * the layout root). The keyboard shortcut is registered in the provider, not
42
- * here, so it works regardless of focus location.
43
- */
44
- /**
45
- * @param uiLocales Admin UI languages for the in-menu switcher. The app owns
46
- * which locale bundles ship (see the dashboard's `getAvailableUiLocales`) and
47
- * injects them here, so this core component stays free of bundle knowledge.
48
- */
49
- export function TopBar({
50
- uiLocales = [],
51
- }: {
52
- uiLocales?: ReadonlyArray<{ code: string; name: string }>
53
- }) {
54
- return (
55
- <header className="sticky top-0 z-40 flex h-header-height shrink-0 items-center gap-3 bg-background/90 px-4 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/75">
56
- <SidebarTrigger className="-ml-1 h-8 w-8" />
57
-
58
- <div className="flex flex-1 justify-center">
59
- <SearchTrigger />
60
- </div>
61
-
62
- <div className="flex items-center gap-2">
63
- <ViewStoreLink />
64
- <TopBarUser uiLocales={uiLocales} />
65
- </div>
66
- </header>
67
- )
68
- }
69
-
70
- // ---------------------------------------------------------------------------
71
- // Search trigger — opens the global command palette
72
- // ---------------------------------------------------------------------------
73
-
74
- function SearchTrigger() {
75
- const { t } = useTranslation()
76
- const { setOpen } = useCommandPalette()
77
-
78
- return (
79
- <button
80
- type="button"
81
- onClick={() => setOpen(true)}
82
- className="flex w-full max-w-md items-center gap-2 rounded-lg border border-border bg-muted/40 px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted"
83
- >
84
- <SearchIcon className="size-4" />
85
- <span className="flex-1 text-left">{t('admin.components.command_palette.placeholder')}</span>
86
- <kbd className="hidden rounded border bg-background px-1.5 py-0.5 font-mono text-xs sm:inline-flex">
87
- {IS_MAC ? '⌘K' : 'Ctrl+K'}
88
- </kbd>
89
- </button>
90
- )
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // View Store link
95
- // ---------------------------------------------------------------------------
96
-
97
- function ViewStoreLink() {
98
- const { t } = useTranslation()
99
- const { store } = useStore()
100
- const href = storefrontHref(store)
101
- if (!href) return null
102
-
103
- return (
104
- <Button asChild variant="ghost" size="sm">
105
- <a href={href} target="_blank" rel="noreferrer">
106
- <ExternalLinkIcon className="size-4" />
107
- <span className="hidden sm:inline">{t('admin.account.view_store')}</span>
108
- </a>
109
- </Button>
110
- )
111
- }
112
-
113
- // ---------------------------------------------------------------------------
114
- // User menu
115
- // ---------------------------------------------------------------------------
116
-
117
- function TopBarUser({ uiLocales }: { uiLocales: ReadonlyArray<{ code: string; name: string }> }) {
118
- const { t } = useTranslation()
119
- const { user, logout } = useAuth()
120
- const { store } = useStore()
121
- const switchAdminLocale = useSwitchAdminLocale()
122
- if (!user) return null
123
-
124
- const initials = getInitials(user.full_name, user.email)
125
-
126
- // Switching the admin UI language persists to the account, mirrors it into
127
- // the auth context, and reloads — see useSwitchAdminLocale for why all three
128
- // steps are required.
129
- const handleSelectLocale = (code: string) => {
130
- void switchAdminLocale(code)
131
- }
132
-
133
- return (
134
- <DropdownMenu>
135
- <DropdownMenuTrigger asChild>
136
- <button
137
- type="button"
138
- aria-label={t('admin.a11y.user_menu')}
139
- className="flex items-center gap-2 rounded-lg p-1 transition-colors hover:bg-accent"
140
- >
141
- <Avatar className="size-7">
142
- {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
143
- <AvatarFallback className="bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
144
- {initials}
145
- </AvatarFallback>
146
- </Avatar>
147
- </button>
148
- </DropdownMenuTrigger>
149
- <DropdownMenuContent align="end" className="w-56" sideOffset={8}>
150
- <div className="flex items-center gap-2 p-1.5">
151
- <Avatar className="size-8">
152
- {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
153
- <AvatarFallback className="bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
154
- {initials}
155
- </AvatarFallback>
156
- </Avatar>
157
- <div className="grid min-w-0 flex-1 text-sm leading-tight">
158
- <span className="truncate font-medium text-foreground">
159
- {user.full_name || user.email}
160
- </span>
161
- {user.full_name && (
162
- <span className="truncate text-xs text-muted-foreground">{user.email}</span>
163
- )}
164
- </div>
165
- </div>
166
- <DropdownMenuSeparator />
167
- <ThemeMenuItems />
168
- {/* Compact nested submenu; self-hides when < 2 languages are installed. */}
169
- <LanguageMenuItems
170
- label={t('admin.account.language.label')}
171
- locales={uiLocales}
172
- value={i18n.language}
173
- onSelect={handleSelectLocale}
174
- />
175
- {uiLocales.length >= 2 && <DropdownMenuSeparator />}
176
- {store && (
177
- <DropdownMenuItem asChild>
178
- <Link
179
- to="/$storeId/settings/profile"
180
- params={{ storeId: store.id }}
181
- className="no-underline"
182
- >
183
- <UserIcon className="size-4" />
184
- {t('admin.account.edit_profile')}
185
- </Link>
186
- </DropdownMenuItem>
187
- )}
188
- <DropdownMenuSeparator />
189
- <DropdownMenuItem>
190
- <BookOpenIcon className="size-4" />
191
- {t('admin.account.documentation')}
192
- </DropdownMenuItem>
193
- <DropdownMenuItem>
194
- <MessageCircleIcon className="size-4" />
195
- {t('admin.account.community')}
196
- </DropdownMenuItem>
197
- <DropdownMenuItem>
198
- <MailIcon className="size-4" />
199
- {t('admin.account.contact_support')}
200
- </DropdownMenuItem>
201
- <DropdownMenuSeparator />
202
- <DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
203
- <LogOutIcon className="size-4" />
204
- {t('admin.account.log_out')}
205
- </DropdownMenuItem>
206
- </DropdownMenuContent>
207
- </DropdownMenu>
208
- )
209
- }