spree_admin 5.5.3 → 5.6.0.rc1

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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/vercel-deploy-button.svg +14 -0
  3. data/app/assets/tailwind/spree/admin/components/_alerts.css +5 -1
  4. data/app/assets/tailwind/spree/admin/components/_badges.css +1 -1
  5. data/app/assets/tailwind/spree/admin/components/_breadcrumbs.css +1 -1
  6. data/app/assets/tailwind/spree/admin/components/_buttons.css +1 -1
  7. data/app/assets/tailwind/spree/admin/components/_cards.css +1 -1
  8. data/app/assets/tailwind/spree/admin/components/_dialogs.css +3 -1
  9. data/app/assets/tailwind/spree/admin/components/_dropdowns.css +2 -2
  10. data/app/assets/tailwind/spree/admin/components/_filters.css +1 -1
  11. data/app/assets/tailwind/spree/admin/components/_forms.css +10 -6
  12. data/app/assets/tailwind/spree/admin/components/_layout.css +30 -23
  13. data/app/assets/tailwind/spree/admin/components/_rtl.css +53 -0
  14. data/app/assets/tailwind/spree/admin/components/_search-picker.css +2 -2
  15. data/app/assets/tailwind/spree/admin/components/_tables.css +1 -1
  16. data/app/assets/tailwind/spree/admin/index.css +1 -0
  17. data/app/assets/tailwind/spree/admin/plugins/_tom-select.css +6 -6
  18. data/app/assets/tailwind/spree/admin/plugins/_trix.css +1 -1
  19. data/app/assets/tailwind/spree/admin/plugins/_uppy.css +1 -1
  20. data/app/assets/tailwind/spree/admin/views/_page-builder.css +8 -5
  21. data/app/controllers/concerns/spree/admin/locale_concern.rb +76 -0
  22. data/app/controllers/spree/admin/base_controller.rb +38 -2
  23. data/app/controllers/spree/admin/countries_controller.rb +18 -4
  24. data/app/controllers/spree/admin/exports_controller.rb +7 -0
  25. data/app/controllers/spree/admin/imports_controller.rb +8 -0
  26. data/app/controllers/spree/admin/products_controller.rb +1 -0
  27. data/app/controllers/spree/admin/storefront_controller.rb +100 -0
  28. data/app/controllers/spree/admin/user_sessions_controller.rb +10 -0
  29. data/app/controllers/spree/admin/variants_controller.rb +3 -1
  30. data/app/helpers/spree/admin/base_helper.rb +10 -0
  31. data/app/helpers/spree/admin/channels_helper.rb +17 -0
  32. data/app/helpers/spree/admin/dropdown_helper.rb +14 -0
  33. data/app/helpers/spree/admin/navigation_helper.rb +1 -1
  34. data/app/helpers/spree/admin/onboarding_helper.rb +1 -1
  35. data/app/helpers/spree/admin/orders_helper.rb +9 -34
  36. data/app/helpers/spree/admin/products_helper.rb +1 -1
  37. data/app/helpers/spree/admin/rtl_helper.rb +15 -0
  38. data/app/helpers/spree/admin/shipment_helper.rb +1 -1
  39. data/app/helpers/spree/admin/storefront_helper.rb +46 -0
  40. data/app/helpers/spree/admin/stores_helper.rb +2 -1
  41. data/app/javascript/spree/admin/application.js +2 -0
  42. data/app/javascript/spree/admin/controllers/autocomplete_select_controller.js +4 -0
  43. data/app/javascript/spree/admin/controllers/calendar_range_controller.js +7 -3
  44. data/app/javascript/spree/admin/controllers/display_name_controller.js +18 -0
  45. data/app/javascript/spree/admin/controllers/query_builder_controller.js +39 -28
  46. data/app/javascript/spree/admin/controllers/variants_form_controller.js +49 -1
  47. data/app/javascript/spree/admin/helpers/display_names.js +54 -0
  48. data/app/models/spree/admin/table/column.rb +15 -1
  49. data/app/models/spree/admin/table/filter.rb +23 -16
  50. data/app/models/spree/admin/table/query_builder.rb +11 -2
  51. data/app/views/active_storage/_upload_form.html.erb +1 -1
  52. data/app/views/layouts/spree/admin.html.erb +1 -1
  53. data/app/views/layouts/spree/admin_settings.html.erb +1 -1
  54. data/app/views/layouts/spree/admin_wizard.html.erb +2 -2
  55. data/app/views/layouts/spree/minimal.html.erb +1 -1
  56. data/app/views/spree/admin/admin_users/_audit_log.html.erb +2 -2
  57. data/app/views/spree/admin/admin_users/new.html.erb +1 -1
  58. data/app/views/spree/admin/assets/edit.html.erb +1 -1
  59. data/app/views/spree/admin/channels/_form.html.erb +10 -0
  60. data/app/views/spree/admin/dashboard/_setup_progress.html.erb +4 -4
  61. data/app/views/spree/admin/dashboard/_setup_tasks.html.erb +5 -4
  62. data/app/views/spree/admin/dashboard/_updater.html.erb +4 -4
  63. data/app/views/spree/admin/dashboard/_visits.html.erb +12 -12
  64. data/app/views/spree/admin/dashboard/analytics.html.erb +5 -5
  65. data/app/views/spree/admin/dashboard/setup_tasks/_add_products.html.erb +4 -4
  66. data/app/views/spree/admin/dashboard/setup_tasks/_set_customer_support_email.html.erb +3 -4
  67. data/app/views/spree/admin/dashboard/setup_tasks/_setup_storefront.html.erb +18 -0
  68. data/app/views/spree/admin/gift_card_batches/_form.html.erb +1 -1
  69. data/app/views/spree/admin/import_rows/show.html.erb +2 -2
  70. data/app/views/spree/admin/imports/_footer.html.erb +1 -1
  71. data/app/views/spree/admin/imports/_mapping_form.html.erb +1 -1
  72. data/app/views/spree/admin/imports/show.html.erb +3 -3
  73. data/app/views/spree/admin/integrations/_integration.html.erb +1 -1
  74. data/app/views/spree/admin/integrations/index.html.erb +2 -4
  75. data/app/views/spree/admin/invitations/_invitation.html.erb +1 -1
  76. data/app/views/spree/admin/invitations/new.html.erb +1 -1
  77. data/app/views/spree/admin/invitations/show.html.erb +1 -1
  78. data/app/views/spree/admin/line_items/new.html.erb +1 -1
  79. data/app/views/spree/admin/markets/_form.html.erb +8 -6
  80. data/app/views/spree/admin/metafields/edit.html.erb +1 -1
  81. data/app/views/spree/admin/option_types/_form.html.erb +9 -6
  82. data/app/views/spree/admin/option_types/index.html.erb +2 -3
  83. data/app/views/spree/admin/orders/_customer.html.erb +1 -1
  84. data/app/views/spree/admin/orders/_customer_summary.html.erb +1 -1
  85. data/app/views/spree/admin/orders/_payments.html.erb +1 -1
  86. data/app/views/spree/admin/orders/_shipment.html.erb +7 -2
  87. data/app/views/spree/admin/orders/_shipments.html.erb +1 -1
  88. data/app/views/spree/admin/orders/_user_overview.html.erb +9 -6
  89. data/app/views/spree/admin/orders/return_authorizations/edit.html.erb +1 -1
  90. data/app/views/spree/admin/payment_methods/_form.html.erb +1 -1
  91. data/app/views/spree/admin/payment_methods/index.html.erb +2 -2
  92. data/app/views/spree/admin/payments/_payment.html.erb +1 -1
  93. data/app/views/spree/admin/price_lists/index.html.erb +2 -2
  94. data/app/views/spree/admin/products/form/_inventory.html.erb +19 -0
  95. data/app/views/spree/admin/products/form/_publishing.html.erb +2 -3
  96. data/app/views/spree/admin/products/index.html.erb +1 -2
  97. data/app/views/spree/admin/profile/edit.html.erb +3 -0
  98. data/app/views/spree/admin/promotion_rules/forms/_channel.html.erb +7 -0
  99. data/app/views/spree/admin/promotion_rules/forms/_country.html.erb +1 -1
  100. data/app/views/spree/admin/promotion_rules/forms/_item_total.html.erb +1 -1
  101. data/app/views/spree/admin/promotion_rules/forms/_market.html.erb +7 -0
  102. data/app/views/spree/admin/promotions/_form.html.erb +2 -2
  103. data/app/views/spree/admin/promotions/_sidebar.html.erb +1 -1
  104. data/app/views/spree/admin/promotions/edit.html.erb +2 -2
  105. data/app/views/spree/admin/promotions/form/_kind.html.erb +11 -11
  106. data/app/views/spree/admin/promotions/form/_settings.html.erb +4 -5
  107. data/app/views/spree/admin/reports/new.html.erb +2 -2
  108. data/app/views/spree/admin/shared/_address.html.erb +1 -1
  109. data/app/views/spree/admin/shared/_analytics_loader_skeleton.html.erb +5 -5
  110. data/app/views/spree/admin/shared/_calendar_range_picker.html.erb +11 -1
  111. data/app/views/spree/admin/shared/_content_header.html.erb +2 -2
  112. data/app/views/spree/admin/shared/_edit_resource_links.html.erb +1 -1
  113. data/app/views/spree/admin/shared/_filters_search_bar.html.erb +1 -1
  114. data/app/views/spree/admin/shared/_growth_rate_badge.html.erb +1 -1
  115. data/app/views/spree/admin/shared/_head.html.erb +4 -1
  116. data/app/views/spree/admin/shared/_index_table_options.html.erb +2 -2
  117. data/app/views/spree/admin/shared/_media_asset.html.erb +1 -1
  118. data/app/views/spree/admin/shared/_media_form.html.erb +1 -1
  119. data/app/views/spree/admin/shared/_seo.html.erb +1 -1
  120. data/app/views/spree/admin/shared/_sidebar.html.erb +1 -1
  121. data/app/views/spree/admin/shared/sidebar/_enterprise_edition_notice.html.erb +2 -2
  122. data/app/views/spree/admin/shared/sortable_tree/_taxonomy.html.erb +1 -1
  123. data/app/views/spree/admin/shipping_methods/form/_display.html.erb +2 -2
  124. data/app/views/spree/admin/shipping_methods/form/_estimated_transit_business_days.html.erb +1 -2
  125. data/app/views/spree/admin/shipping_methods/form/_tracking_url.html.erb +1 -1
  126. data/app/views/spree/admin/shipping_methods/form/_zones.html.erb +1 -1
  127. data/app/views/spree/admin/shipping_methods/index.html.erb +1 -1
  128. data/app/views/spree/admin/storefront/show.html.erb +150 -0
  129. data/app/views/spree/admin/stores/form/_basic.html.erb +2 -2
  130. data/app/views/spree/admin/stores/form/_checkout.html.erb +4 -0
  131. data/app/views/spree/admin/stores/form/_emails.html.erb +4 -6
  132. data/app/views/spree/admin/tables/_table.html.erb +3 -2
  133. data/app/views/spree/admin/taxons/_form.html.erb +2 -2
  134. data/app/views/spree/admin/user_sessions/new.html.erb +11 -2
  135. data/app/views/spree/admin/users/_tabs.html.erb +1 -1
  136. data/app/views/spree/admin/variants/_search_result.html.erb +1 -1
  137. data/app/views/spree/admin/variants/_variant.html.erb +1 -1
  138. data/app/views/spree/admin/variants/form/_inventory.html.erb +17 -0
  139. data/app/views/spree/admin/webhook_endpoints/_form.html.erb +2 -2
  140. data/app/views/spree/admin/zones/index.html.erb +1 -1
  141. data/config/i18n-tasks.yml +3 -0
  142. data/config/initializers/spree_admin_navigation.rb +9 -0
  143. data/config/initializers/spree_admin_tables.rb +20 -18
  144. data/config/locales/en.yml +647 -0
  145. data/config/routes.rb +5 -0
  146. metadata +18 -6
@@ -226,22 +226,10 @@ export default class extends Controller {
226
226
  const operatorSelect = filterRow.querySelector("[data-operator-select]")
227
227
  operatorSelect.innerHTML = ""
228
228
 
229
- const operatorLabels = {
230
- eq: "equals",
231
- not_eq: "does not equal",
232
- cont: "contains",
233
- not_cont: "does not contain",
234
- start: "starts with",
235
- end: "ends with",
236
- gt: "greater than",
237
- gteq: "greater than or equal",
238
- lt: "less than",
239
- lteq: "less than or equal",
240
- in: "is any of",
241
- not_in: "is none of",
242
- null: "is empty",
243
- not_null: "is not empty"
244
- }
229
+ // Labels come from the server already translated (see Filter.operators_for_select).
230
+ const operatorLabels = Object.fromEntries(
231
+ this.operatorsValue.map(op => [op.value, op.label])
232
+ )
245
233
 
246
234
  operators.forEach(op => {
247
235
  const option = document.createElement("option")
@@ -355,8 +343,21 @@ export default class extends Controller {
355
343
 
356
344
  if (fieldConfig.search_url) {
357
345
  wrapper.dataset.selectUrlValue = fieldConfig.search_url
358
- // Use remote search mode so it doesn't pre-fetch and overwrite our preloaded options
359
- wrapper.dataset.selectRemoteSearchValue = "true"
346
+
347
+ if (fieldConfig.preload_options) {
348
+ // Small, fixed option set: load it once and filter client-side on
349
+ // the localized `label` while submitting the underlying value.
350
+ wrapper.dataset.selectLabelFieldValue = "label"
351
+ wrapper.dataset.selectSearchFieldValue = "label"
352
+ // Preserve the server's localized-name ordering. The label is
353
+ // flag-prefixed, so sorting by it would order by flag codepoints
354
+ // (ISO) instead of name; "$order" keeps the received order.
355
+ wrapper.dataset.selectSortFieldValue = "$order"
356
+ } else {
357
+ // Remote search mode so it doesn't pre-fetch and overwrite our
358
+ // preloaded options (datasets here are large / server-limited).
359
+ wrapper.dataset.selectRemoteSearchValue = "true"
360
+ }
360
361
  }
361
362
 
362
363
  // Use name as value field for tags (tags_name ransack attribute expects tag names)
@@ -371,17 +372,27 @@ export default class extends Controller {
371
372
  input.dataset.selectTarget = "input"
372
373
  input.dataset.valueInput = "" // Mark as value input for serialization
373
374
 
374
- // Restore existing values - we store as array of {id, name} objects
375
- // Use remoteSearchActiveOption which properly preloads options and selects them
375
+ // Restore existing values - we store as array of {id, name} objects.
376
376
  if (existingValue !== null && existingValue !== undefined && Array.isArray(existingValue) && existingValue.length > 0) {
377
- const preloadedOptions = existingValue.map(item => {
378
- if (typeof item === 'object') {
379
- return { id: useNameAsValue ? item.name : item.id, name: item.name }
380
- } else {
381
- return { id: item, name: item }
382
- }
383
- })
384
- wrapper.dataset.selectRemoteSearchActiveOptionValue = JSON.stringify(preloadedOptions)
377
+ if (fieldConfig.preload_options) {
378
+ // The full option list is preloaded, so we only need the selected
379
+ // values; Tom Select resolves their labels from the loaded options.
380
+ const selectedValues = existingValue.map(item =>
381
+ String(typeof item === 'object' ? item.id : item)
382
+ )
383
+ wrapper.dataset.selectActiveOptionValue = JSON.stringify(selectedValues)
384
+ } else {
385
+ // Remote search: preload the selected options so they render with
386
+ // their labels without first issuing a search.
387
+ const preloadedOptions = existingValue.map(item => {
388
+ if (typeof item === 'object') {
389
+ return { id: useNameAsValue ? item.name : item.id, name: item.name }
390
+ } else {
391
+ return { id: item, name: item }
392
+ }
393
+ })
394
+ wrapper.dataset.selectRemoteSearchActiveOptionValue = JSON.stringify(preloadedOptions)
395
+ }
385
396
  }
386
397
 
387
398
  // Set up change listener on select element directly
@@ -72,6 +72,10 @@ export default class extends CheckboxSelectAll {
72
72
  }
73
73
 
74
74
  this.inventoryFormTarget = document.querySelector('.inventory-form');
75
+
76
+ // Drop stale removal inputs (e.g. restored from the Turbo cache) so a fresh
77
+ // session never re-submits removals the user didn't make in it.
78
+ this.syncRemovedVariantIdInputs()
75
79
  }
76
80
 
77
81
  toggleQuantityTracked() {
@@ -119,6 +123,37 @@ export default class extends CheckboxSelectAll {
119
123
  }
120
124
  }
121
125
 
126
+ // The set of persisted variant ids the user removed from the matrix. Submitted as
127
+ // `product[removed_variant_ids][]` — the server only ever destroys ids listed there,
128
+ // never variants that are merely missing from the re-submitted form.
129
+ get removedVariantIds() {
130
+ if (!this._removedVariantIds) this._removedVariantIds = new Set()
131
+
132
+ return this._removedVariantIds
133
+ }
134
+
135
+ markVariantRemoved(internalName) {
136
+ const variantId = this.variantIdsValue[internalName]
137
+ if (variantId) this.removedVariantIds.add(String(variantId))
138
+ }
139
+
140
+ markVariantKept(internalName) {
141
+ const variantId = this.variantIdsValue[internalName]
142
+ if (variantId) this.removedVariantIds.delete(String(variantId))
143
+ }
144
+
145
+ syncRemovedVariantIdInputs() {
146
+ this.element.querySelectorAll('input[name="product[removed_variant_ids][]"]').forEach((input) => input.remove())
147
+
148
+ this.removedVariantIds.forEach((variantId) => {
149
+ const input = document.createElement('input')
150
+ input.type = 'hidden'
151
+ input.name = 'product[removed_variant_ids][]'
152
+ input.value = variantId
153
+ this.element.appendChild(input)
154
+ })
155
+ }
156
+
122
157
  deleteSelected() {
123
158
  const newStockValue = this.stockValue
124
159
  const newPricesValue = this.pricesValue
@@ -126,6 +161,7 @@ export default class extends CheckboxSelectAll {
126
161
  const internalName = checkbox.value
127
162
 
128
163
  this.ignoredVariants.add(internalName)
164
+ this.markVariantRemoved(internalName)
129
165
  const variant = this.variantsContainerTarget.querySelector(`[data-variant-name="${internalName}"]`)
130
166
 
131
167
  const nestingLevel = internalName.split('/').length
@@ -157,7 +193,8 @@ export default class extends CheckboxSelectAll {
157
193
 
158
194
  delete newStockValue[internalName]
159
195
  delete newPricesValue[internalName]
160
- variant.remove()
196
+ // the row may already be gone when deleting an option value re-rendered the matrix
197
+ variant?.remove()
161
198
  })
162
199
 
163
200
  this.stockValue = newStockValue
@@ -166,6 +203,7 @@ export default class extends CheckboxSelectAll {
166
203
  this.checkboxAllTarget.checked = false
167
204
  this.refresh()
168
205
  this.refreshParentInputs()
206
+ this.syncRemovedVariantIdInputs()
169
207
  }
170
208
 
171
209
  reorderOptions(event) {
@@ -498,6 +536,14 @@ export default class extends CheckboxSelectAll {
498
536
  }
499
537
  currentVariants.add(internalName)
500
538
 
539
+ // Reconcile the removal ledger with what this render keeps: leaf rows
540
+ // re-submit their variant id, parent (grouping) rows never do.
541
+ if (i === nestingLevel - 1) {
542
+ this.markVariantKept(internalName)
543
+ } else if (i === 0 && nestingLevel > 1) {
544
+ this.markVariantRemoved(internalName)
545
+ }
546
+
501
547
  const existingVariant = this.variantsContainerTarget.querySelector(`[data-variant-name="${internalName}"]`)
502
548
  if (existingVariant) {
503
549
  if (i === 0) {
@@ -599,6 +645,7 @@ export default class extends CheckboxSelectAll {
599
645
  this.variantsContainerTarget.querySelectorAll('[data-variants-form-target="variant"]').forEach((variant) => {
600
646
  const variantName = variant.dataset.variantName
601
647
  if (!currentVariants.has(variantName)) {
648
+ this.markVariantRemoved(variantName)
602
649
  variant.remove()
603
650
  }
604
651
  })
@@ -609,6 +656,7 @@ export default class extends CheckboxSelectAll {
609
656
  }
610
657
 
611
658
  this.refreshParentInputs()
659
+ this.syncRemovedVariantIdInputs()
612
660
  }
613
661
 
614
662
  refreshParentInputs() {
@@ -0,0 +1,54 @@
1
+ /** Mirrors the React dashboard's `CODE — Localized Name` format. */
2
+ export function formatCodeName(code, name) {
3
+ const upper = code.toUpperCase()
4
+ if (!name || name.toUpperCase() === upper) return upper
5
+ return `${upper} — ${name}`
6
+ }
7
+
8
+ // Intl.DisplayNames construction is the expensive part (locale resolution +
9
+ // table setup); .of() is cheap. Cache one formatter per (type, locale) so
10
+ // localizing a large select doesn't rebuild it for every option.
11
+ const formatterCache = new Map()
12
+
13
+ function displayNameFormatter(type, locale) {
14
+ const key = `${type}:${locale}`
15
+ if (formatterCache.has(key)) return formatterCache.get(key)
16
+
17
+ let formatter
18
+ try {
19
+ formatter = new Intl.DisplayNames([locale, 'en'], { type })
20
+ } catch {
21
+ formatter = null
22
+ }
23
+ formatterCache.set(key, formatter)
24
+ return formatter
25
+ }
26
+
27
+ /** Resolve a code via Intl.DisplayNames in the admin UI language. */
28
+ export function intlDisplayName(type, code, locale) {
29
+ if (!code) return undefined
30
+
31
+ const formatter = displayNameFormatter(type, locale)
32
+ if (!formatter) return undefined
33
+
34
+ try {
35
+ return formatter.of(code)
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ /** Returns the admin UI locale from `<html lang>`, defaulting to `en` when unset. */
42
+ export function adminUiLocale() {
43
+ return document.documentElement.lang || 'en'
44
+ }
45
+
46
+ /** Rewrite <option> labels using Intl.DisplayNames before Tom Select init. */
47
+ export function localizeSelectOptions(select, type, locale = adminUiLocale()) {
48
+ for (const option of select.options) {
49
+ if (!option.value) continue
50
+
51
+ const name = intlDisplayName(type, option.value, locale)
52
+ if (name) option.textContent = formatCodeName(option.value, name)
53
+ }
54
+ }
@@ -44,6 +44,11 @@ module Spree
44
44
  attribute :operators, default: -> { [] }
45
45
  attribute :value_options
46
46
  attribute :search_url
47
+ # When true, the autocomplete filter loads the full option list from
48
+ # `search_url` once and filters it client-side on each option's `label`
49
+ # field (instead of querying the server per keystroke). Only suitable for
50
+ # small, fixed option sets whose endpoint returns a `label` attribute.
51
+ attribute :preload_options, :boolean, default: false
47
52
  attribute :sort_scope_asc
48
53
  attribute :sort_scope_desc
49
54
 
@@ -69,7 +74,16 @@ module Spree
69
74
 
70
75
  # Resolve label (handles i18n keys)
71
76
  def resolve_label
72
- return I18n.t(label, default: label.split('.').last.humanize) if label.is_a?(String) && label.include?('.')
77
+ if label.is_a?(String) && label.include?('.')
78
+ # Dotted keys may live under the `spree` namespace (e.g. `admin.num_orders`,
79
+ # `price_list_statuses.active`) or at the I18n root (e.g. `activerecord.attributes.*`).
80
+ # Prefer the `spree`-scoped lookup, then fall back to the root lookup.
81
+ scoped = Spree.t(label, default: '')
82
+ return scoped if scoped.present?
83
+
84
+ return I18n.t(label, default: label.split('.').last.humanize)
85
+ end
86
+
73
87
  return label if label.is_a?(String) && label.present?
74
88
 
75
89
  key_to_translate = label || key
@@ -3,20 +3,20 @@ module Spree
3
3
  class Table
4
4
  class Filter
5
5
  OPERATORS = {
6
- eq: { label: 'equals', predicate: '_eq' },
7
- not_eq: { label: 'does not equal', predicate: '_not_eq' },
8
- cont: { label: 'contains', predicate: '_cont' },
9
- not_cont: { label: 'does not contain', predicate: '_not_cont' },
10
- start: { label: 'starts with', predicate: '_start' },
11
- end: { label: 'ends with', predicate: '_end' },
12
- gt: { label: 'greater than', predicate: '_gt' },
13
- gteq: { label: 'greater than or equal to', predicate: '_gteq' },
14
- lt: { label: 'less than', predicate: '_lt' },
15
- lteq: { label: 'less than or equal to', predicate: '_lteq' },
16
- in: { label: 'is any of', predicate: '_in' },
17
- not_in: { label: 'is none of', predicate: '_not_in' },
18
- null: { label: 'is empty', predicate: '_null', no_value: true },
19
- not_null: { label: 'is not empty', predicate: '_not_null', no_value: true }
6
+ eq: { predicate: '_eq' },
7
+ not_eq: { predicate: '_not_eq' },
8
+ cont: { predicate: '_cont' },
9
+ not_cont: { predicate: '_not_cont' },
10
+ start: { predicate: '_start' },
11
+ end: { predicate: '_end' },
12
+ gt: { predicate: '_gt' },
13
+ gteq: { predicate: '_gteq' },
14
+ lt: { predicate: '_lt' },
15
+ lteq: { predicate: '_lteq' },
16
+ in: { predicate: '_in' },
17
+ not_in: { predicate: '_not_in' },
18
+ null: { predicate: '_null', no_value: true },
19
+ not_null: { predicate: '_not_null', no_value: true }
20
20
  }.freeze
21
21
 
22
22
  attr_accessor :field, :operator, :value, :id
@@ -73,7 +73,7 @@ module Spree
73
73
  # Get human-readable operator label
74
74
  # @return [String]
75
75
  def operator_label
76
- OPERATORS.dig(@operator, :label) || @operator.to_s.humanize
76
+ self.class.translate_operator(@operator)
77
77
  end
78
78
 
79
79
  # Check if this operator requires a value
@@ -107,9 +107,16 @@ module Spree
107
107
  # @return [Array<Hash>]
108
108
  def self.operators_for_select
109
109
  OPERATORS.map do |key, config|
110
- { value: key.to_s, label: config[:label], no_value: config[:no_value] || false }
110
+ { value: key.to_s, label: translate_operator(key), no_value: config[:no_value] || false }
111
111
  end
112
112
  end
113
+
114
+ # Translate an operator key to its human-readable label
115
+ # @param operator [Symbol]
116
+ # @return [String]
117
+ def self.translate_operator(operator)
118
+ Spree.t("admin.table.operators.#{operator}", default: operator.to_s.humanize)
119
+ end
113
120
  end
114
121
  end
115
122
  end
@@ -80,7 +80,8 @@ module Spree
80
80
  type: column.filter_type.to_s,
81
81
  operators: column.operators.map(&:to_s),
82
82
  value_options: format_value_options(column.value_options),
83
- search_url: resolve_search_url(column.search_url, view_context)
83
+ search_url: resolve_search_url(column.search_url, view_context),
84
+ preload_options: column.preload_options
84
85
  }
85
86
  end
86
87
  end
@@ -107,12 +108,20 @@ module Spree
107
108
 
108
109
  resolved_options.map do |opt|
109
110
  if opt.is_a?(Hash)
110
- { value: opt[:value] || opt['value'], label: opt[:label] || opt['label'] }
111
+ { value: opt[:value] || opt['value'], label: resolve_option_label(opt[:label] || opt['label']) }
111
112
  else
112
113
  { value: opt.to_s, label: opt.to_s.humanize }
113
114
  end
114
115
  end
115
116
  end
117
+
118
+ # Translate a value-option label when it is given as an i18n key (a dotted
119
+ # string like 'admin.products.draft'); plain strings are returned as-is.
120
+ def resolve_option_label(label)
121
+ return label unless label.is_a?(String) && label.include?('.')
122
+
123
+ I18n.t(label, default: label.split('.').last.humanize)
124
+ end
116
125
  end
117
126
  end
118
127
  end
@@ -52,7 +52,7 @@
52
52
  </button>
53
53
 
54
54
  <% if show_delete_button %>
55
- <button type="button" class="btn btn-danger ml-auto" data-action="active-storage-upload#remove" data-turbo-confirm="<%= Spree.t(:are_you_sure) %>">
55
+ <button type="button" class="btn btn-danger ms-auto" data-action="active-storage-upload#remove" data-turbo-confirm="<%= Spree.t(:are_you_sure) %>">
56
56
  <%= icon('trash') %>
57
57
 
58
58
  <%= Spree.t('actions.destroy') %>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= I18n.locale %>">
2
+ <html lang="<%= I18n.locale %>" dir="<%= html_dir %>">
3
3
  <head>
4
4
  <%= render "spree/admin/shared/head" %>
5
5
  <%= render "spree/admin/shared/custom_head" %>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= I18n.locale %>">
2
+ <html lang="<%= I18n.locale %>" dir="<%= html_dir %>">
3
3
  <head>
4
4
  <%= render "spree/admin/shared/head" %>
5
5
  </head>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= I18n.locale %>">
2
+ <html lang="<%= I18n.locale %>" dir="<%= html_dir %>">
3
3
  <head>
4
4
  <%= render "spree/admin/shared/head" %>
5
5
  </head>
@@ -14,7 +14,7 @@
14
14
  <%= link_to_with_icon 'arrow-back', Spree.t('admin.back_to_dashboard'), spree.admin_path, class: 'hover:bg-gray-100 flex justify-start gap-2 px-8 no-underline h-16 items-center' %>
15
15
 
16
16
  <% if content_for?(:steps) %>
17
- <ol class="flex items-center list-none p-0 grow justify-between h-16 border-l border-gray-200" id="steps">
17
+ <ol class="flex items-center list-none p-0 grow justify-between h-16 border-s border-gray-200" id="steps">
18
18
  <%= yield :steps %>
19
19
  </ol>
20
20
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= I18n.locale %>">
2
+ <html lang="<%= I18n.locale %>" dir="<%= html_dir %>">
3
3
  <head>
4
4
  <%= render "spree/admin/shared/head" %>
5
5
  </head>
@@ -1,5 +1,5 @@
1
1
  <div class="text-gray-600 py-12 gap-4 flex flex-col items-center justify-center">
2
- <p>Audit log is part of the Spree Enterprise offering.</p>
2
+ <p><%= Spree.t('admin.admin_users.audit_log_enterprise_notice') %></p>
3
3
 
4
- <%= external_link_to 'Upgrade to Spree Enterprise', 'https://spreecommerce.org/pricing', class: 'btn btn-primary' %>
4
+ <%= external_link_to Spree.t('admin.admin_users.upgrade_to_enterprise'), 'https://spreecommerce.org/pricing', class: 'btn btn-primary' %>
5
5
  </div>
@@ -3,7 +3,7 @@
3
3
 
4
4
  <div class="rounded-md border p-6 mb-6 flex items-center gap-2">
5
5
  <%= render_avatar(@invitation.inviter, height: 32, width: 32) %>
6
- <%= @invitation.inviter.name %> has invited you to join <strong><%= @invitation.resource.name %></strong>
6
+ <%= Spree.t('admin.invitations.invited_you_to_join_html', inviter: tag.strong(@invitation.inviter.name), resource: tag.strong(@invitation.resource.name)) %>
7
7
  </div>
8
8
 
9
9
  <%= render 'form', f: f %>
@@ -66,7 +66,7 @@
66
66
  <%= drawer_discard_button %>
67
67
  <%= link_to Spree.t('actions.destroy'), object_url(@asset),
68
68
  data: { turbo_method: :delete, turbo_confirm: Spree.t(:are_you_sure_delete), turbo_frame: '_top' },
69
- class: 'btn btn-danger ml-auto' if can?(:destroy, @asset) %>
69
+ class: 'btn btn-danger ms-auto' if can?(:destroy, @asset) %>
70
70
  </div>
71
71
  <% end %>
72
72
  <% end %>
@@ -15,5 +15,15 @@
15
15
  { label: Spree.t('admin.channels.order_routing_strategy'),
16
16
  help_bubble: Spree.t('admin.channels.order_routing_strategy_hint'),
17
17
  include_blank: Spree.t('admin.channels.order_routing_strategy_inherit') } %>
18
+ <%= f.spree_select :preferred_storefront_access,
19
+ options_for_select(channel_storefront_access_options, f.object.preferred_storefront_access),
20
+ { label: Spree.t('admin.channels.storefront_access'),
21
+ help_bubble: Spree.t('admin.channels.storefront_access_hint'),
22
+ include_blank: Spree.t('admin.channels.storefront_access_inherit') } %>
23
+ <%= f.spree_select :preferred_guest_checkout,
24
+ options_for_select(channel_guest_checkout_options, f.object.preferred_guest_checkout&.to_s || ''),
25
+ { label: Spree.t('admin.channels.guest_checkout'),
26
+ help_bubble: Spree.t('admin.channels.guest_checkout_hint'),
27
+ include_blank: Spree.t('admin.channels.guest_checkout_inherit') } %>
18
28
  </div>
19
29
  </div>
@@ -1,14 +1,14 @@
1
1
  <div class="card mb-6" id="setup_progress">
2
2
  <div class="card-body">
3
3
  <div class="mb-4">
4
- Your overall setup progress
5
- <span class="float-right badge badge-<%= current_store.setup_completed? ? 'success' : 'info' %>">
4
+ <%= Spree.t('admin.dashboard.setup_progress.title') %>
5
+ <span class="badge badge-<%= current_store.setup_completed? ? 'success' : 'info' %> inline-block">
6
6
  <% if current_store.setup_completed? %>
7
7
  <%= icon('check', class: 'text-green-700') %>
8
8
  <% end %>
9
9
 
10
- <span>
11
- <strong><%= current_store.setup_tasks_done %></strong> of <%= current_store.setup_tasks_total %> steps done
10
+ <span dir="auto">
11
+ <%= Spree.t('admin.dashboard.setup_progress.steps_done_html', done: current_store.setup_tasks_done, total: current_store.setup_tasks_total) %>
12
12
  </span>
13
13
  </span>
14
14
  </div>
@@ -1,20 +1,21 @@
1
1
  <div id="setup_tasks" class="flex flex-col gap-4">
2
2
  <% task_in_progress = nil %>
3
- <% current_store.setup_tasks_list.each_with_index do |task, index| %>
4
- <% done = current_store.setup_task_done?(task) %>
3
+ <% current_store.setup_tasks.each do |item| %>
4
+ <% task = item.name %>
5
+ <% done = item.done %>
5
6
  <% unless done %>
6
7
  <% task_in_progress ||= task %>
7
8
  <% end %>
8
9
  <% is_expanded = task_in_progress == task %>
9
10
 
10
11
  <div class="card overflow-hidden" data-controller="reveal" data-reveal-hidden-class="is-collapsed" id="setup_task_<%= task %>">
11
- <div class="card-header card-header--collapsible border-0 h-auto no-underline flex items-center p-3 text-gray-900 font-normal w-full bg-transparent cursor-pointer focus:outline-none focus:ring-0 hover:bg-gray-50"
12
+ <div class="card-header card-header--collapsible border-0 h-auto no-underline flex items-center gap-2 p-3 text-gray-900 font-normal w-full bg-transparent cursor-pointer focus:outline-none focus:ring-0 hover:bg-gray-50"
12
13
  role="button"
13
14
  tabindex="0"
14
15
  data-action="click->reveal#toggle keydown.enter->reveal#toggle keydown.space->reveal#toggle">
15
16
  <%= onboarding_check_circle(done) %>
16
17
  <span class="ml-6 font-medium"><%= Spree.t("admin.store_setup_tasks.#{task}") %></span>
17
- <%= icon('chevron-right', class: 'arrow ml-auto transition-transform duration-200') %>
18
+ <%= icon('chevron-right', class: 'arrow ms-auto transition-transform duration-200') %>
18
19
  </div>
19
20
  <div data-reveal-target="item" class="collapsible-content <%= 'is-collapsed' unless is_expanded %>">
20
21
  <div>
@@ -3,17 +3,17 @@
3
3
  <%= image_tag 'favicon_256x256.png', alt: 'Spree', width: 32, height: 32 %>
4
4
  <div class="flex flex-col">
5
5
  <p class="mb-1">
6
- <strong>Spree <%= spree_updater.latest_release['name'] %></strong> is available.
7
- <br />Your current version is <strong>Spree <%= spree_updater.current_release %></strong>.
6
+ <%= Spree.t('admin.dashboard.updater.available_html', version: tag.strong("Spree #{spree_updater.latest_release['name']}")) %>
7
+ <br /><%= Spree.t('admin.dashboard.updater.current_version_html', version: tag.strong("Spree #{spree_updater.current_release}")) %>
8
8
  </p>
9
9
 
10
10
  <% if spree_updater.latest_release['url'] %>
11
11
  <div>
12
- <%= external_link_to "View release notes", spree_updater.latest_release['url'], target: '_blank', class: 'btn btn-sm btn-secondary' %>
12
+ <%= external_link_to Spree.t('admin.dashboard.updater.view_release_notes'), spree_updater.latest_release['url'], target: '_blank', class: 'btn btn-sm btn-secondary' %>
13
13
  </div>
14
14
  <% end %>
15
15
  </div>
16
16
 
17
- <%= link_to '', spree.admin_dismiss_updater_notice_path, data: { turbo_method: :patch }, class: 'btn-close ml-auto' %>
17
+ <%= link_to '', spree.admin_dismiss_updater_notice_path, data: { turbo_method: :patch }, class: 'btn-close ms-auto' %>
18
18
  </div>
19
19
  <% end %>
@@ -3,7 +3,7 @@
3
3
  <div class="card h-full">
4
4
  <div class="card-header">
5
5
  <h5 class="card-title">
6
- Where are visitors coming from?
6
+ <%= Spree.t('admin.dashboard.visits.referrers_title') %>
7
7
  </h5>
8
8
  </div>
9
9
  <% if @top_referrers.any? %>
@@ -19,9 +19,9 @@
19
19
  <p class="m-12 text-center text-gray-600">
20
20
  <%= icon 'share', height: 50, class: 'mb-4', style: 'opacity: 0.5' %>
21
21
  <br />
22
- <strong>No data</strong>
22
+ <strong><%= Spree.t('admin.dashboard.visits.no_data') %></strong>
23
23
  <br />
24
- No data available for this period.
24
+ <%= Spree.t('admin.dashboard.visits.no_data_for_period') %>
25
25
  </p>
26
26
  <% end %>
27
27
  </div>
@@ -30,7 +30,7 @@
30
30
  <div class="card h-full">
31
31
  <div class="card-header">
32
32
  <h5 class="card-title">
33
- Top landing pages
33
+ <%= Spree.t('admin.dashboard.visits.landing_pages_title') %>
34
34
  </h5>
35
35
  </div>
36
36
  <% if @top_landing_pages.any? %>
@@ -46,9 +46,9 @@
46
46
  <p class="m-12 text-center text-gray-600">
47
47
  <%= icon 'pointer', height: 50, class: 'mb-4', style: 'opacity: 0.5' %>
48
48
  <br />
49
- <strong>No data</strong>
49
+ <strong><%= Spree.t('admin.dashboard.visits.no_data') %></strong>
50
50
  <br />
51
- No data available for this period.
51
+ <%= Spree.t('admin.dashboard.visits.no_data_for_period') %>
52
52
  </p>
53
53
  <% end %>
54
54
  </div>
@@ -57,7 +57,7 @@
57
57
  <div class="card h-full">
58
58
  <div class="card-header">
59
59
  <h5 class="card-title">
60
- Visitor location
60
+ <%= Spree.t('admin.dashboard.visits.locations_title') %>
61
61
  </h5>
62
62
  </div>
63
63
  <% if @top_locations.any? %>
@@ -80,9 +80,9 @@
80
80
  <p class="m-12 text-center text-gray-600">
81
81
  <%= icon 'world', height: 50, class: 'mb-4', style: 'opacity: 0.5' %>
82
82
  <br />
83
- <strong>No data</strong>
83
+ <strong><%= Spree.t('admin.dashboard.visits.no_data') %></strong>
84
84
  <br />
85
- No data available for this period.
85
+ <%= Spree.t('admin.dashboard.visits.no_data_for_period') %>
86
86
  </p>
87
87
  <% end %>
88
88
  </div>
@@ -91,7 +91,7 @@
91
91
  <div class="card">
92
92
  <div class="card-header">
93
93
  <h5 class="card-title">
94
- Visitor devices
94
+ <%= Spree.t('admin.dashboard.visits.devices_title') %>
95
95
  </h5>
96
96
  </div>
97
97
  <% if @top_devices.count > 0 %>
@@ -102,9 +102,9 @@
102
102
  <p class="m-12 text-center text-gray-600">
103
103
  <%= icon 'device-mobile', height: 50, class: 'mb-4', style: 'opacity: 0.5' %>
104
104
  <br />
105
- <strong>No data</strong>
105
+ <strong><%= Spree.t('admin.dashboard.visits.no_data') %></strong>
106
106
  <br />
107
- No data available for this period.
107
+ <%= Spree.t('admin.dashboard.visits.no_data_for_period') %>
108
108
  </p>
109
109
  <% end %>
110
110
  </div>