@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599

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 (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  31. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  34. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
  35. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  37. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  38. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  39. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  40. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  41. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  42. package/dist/modules/communication_channels/extension-points.js +1 -1
  43. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  44. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  45. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  46. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  47. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  48. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  49. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  50. package/dist/modules/messages/api/route.js +5 -1
  51. package/dist/modules/messages/api/route.js.map +2 -2
  52. package/dist/modules/messages/commands/messages.js +3 -2
  53. package/dist/modules/messages/commands/messages.js.map +2 -2
  54. package/dist/modules/messages/data/validators.js +12 -2
  55. package/dist/modules/messages/data/validators.js.map +2 -2
  56. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  57. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +19 -1
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/catalog/AGENTS.md +6 -2
  62. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  63. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  64. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  65. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  66. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  67. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  68. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  69. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  70. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  71. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  72. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  73. package/src/modules/catalog/data/validators.ts +43 -2
  74. package/src/modules/catalog/extension-points.ts +1 -0
  75. package/src/modules/catalog/i18n/de.json +72 -0
  76. package/src/modules/catalog/i18n/en.json +72 -0
  77. package/src/modules/catalog/i18n/es.json +72 -0
  78. package/src/modules/catalog/i18n/ko.json +72 -0
  79. package/src/modules/catalog/i18n/pl.json +72 -0
  80. package/src/modules/catalog/lib/pricing.ts +103 -9
  81. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  82. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  83. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
  84. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  85. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  86. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  87. package/src/modules/communication_channels/extension-points.ts +1 -1
  88. package/src/modules/communication_channels/i18n/de.json +4 -3
  89. package/src/modules/communication_channels/i18n/en.json +4 -3
  90. package/src/modules/communication_channels/i18n/es.json +4 -3
  91. package/src/modules/communication_channels/i18n/ko.json +4 -3
  92. package/src/modules/communication_channels/i18n/pl.json +4 -3
  93. package/src/modules/communication_channels/lib/adapter.ts +16 -2
  94. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  95. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  96. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  97. package/src/modules/messages/api/route.ts +8 -2
  98. package/src/modules/messages/commands/messages.ts +17 -2
  99. package/src/modules/messages/data/validators.ts +26 -11
  100. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  101. package/src/modules/query_index/lib/engine.ts +23 -1
  102. package/src/modules/sales/AGENTS.md +2 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.8.1-develop.7221.1.ece7ca874e",
3
+ "version": "0.8.1-develop.7227.1.9248c14599",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,16 +253,16 @@
253
253
  "zod": "^4.4.3"
254
254
  },
255
255
  "peerDependencies": {
256
- "@open-mercato/ai-assistant": "0.8.1-develop.7221.1.ece7ca874e",
257
- "@open-mercato/shared": "0.8.1-develop.7221.1.ece7ca874e",
258
- "@open-mercato/ui": "0.8.1-develop.7221.1.ece7ca874e",
256
+ "@open-mercato/ai-assistant": "0.8.1-develop.7227.1.9248c14599",
257
+ "@open-mercato/shared": "0.8.1-develop.7227.1.9248c14599",
258
+ "@open-mercato/ui": "0.8.1-develop.7227.1.9248c14599",
259
259
  "react": "^19.0.0",
260
260
  "react-dom": "^19.0.0"
261
261
  },
262
262
  "devDependencies": {
263
- "@open-mercato/ai-assistant": "0.8.1-develop.7221.1.ece7ca874e",
264
- "@open-mercato/shared": "0.8.1-develop.7221.1.ece7ca874e",
265
- "@open-mercato/ui": "0.8.1-develop.7221.1.ece7ca874e",
263
+ "@open-mercato/ai-assistant": "0.8.1-develop.7227.1.9248c14599",
264
+ "@open-mercato/shared": "0.8.1-develop.7227.1.9248c14599",
265
+ "@open-mercato/ui": "0.8.1-develop.7227.1.9248c14599",
266
266
  "@testing-library/dom": "^10.4.1",
267
267
  "@testing-library/jest-dom": "^7.0.1",
268
268
  "@testing-library/react": "^16.3.3",
@@ -40,10 +40,10 @@ Price layers compose in order: base price → channel override → customer-spec
40
40
 
41
41
  ```typescript
42
42
  import { registerCatalogPricingResolver } from '@open-mercato/core/modules/catalog/lib/pricing'
43
- registerCatalogPricingResolver(myResolver, { priority: 10 })
43
+ registerCatalogPricingResolver(myResolver, { priority: 10, id: 'my-module.my-resolver' })
44
44
  ```
45
45
 
46
- The default pipeline emits `catalog.pricing.resolve.before|after` events.
46
+ The default pipeline emits `catalog.pricing.resolve.before|after` events. `pricingResolvers` lives on `globalThis` (survives duplicated module instances, e.g. a standalone app built from this monorepo); pass a stable `id` so re-registering the same resolver on a dev-mode reload is a no-op instead of appending a duplicate.
47
47
 
48
48
  ### Price selection order
49
49
 
@@ -55,6 +55,10 @@ When multiple price rows match the same context, `selectBestPrice` resolves ties
55
55
  - **Same kind** (e.g. tier vs tier): **descending** — the row with the larger `minQuantity` wins. This implements the standard volume-discount semantic: for `qty=50` with tiers `minQty=10` ($9) and `minQty=50` ($8), the `minQty=50` row applies (issue #1706).
56
56
  - **Different kinds** (e.g. promotion vs tier): **ascending** — the row with the smaller `minQuantity` wins. The `+1 for minQuantity > 1` bonus in `scorePrice` can pull a lower-base kind up to the same total as a higher-base kind (e.g. tier `minQty=3` = 3+1 vs promotion `minQty=1` = 4). Ascending across kinds preserves the kind precedence in those collisions.
57
57
 
58
+ ### Resolver-chain tie-break
59
+
60
+ `registerCatalogPricingResolver`'s chain is sorted by `priority` (descending) using a stable sort, so two resolvers registered at the **same priority** run in **registration order** — the first-registered resolver is asked first. This is deterministic and tested (`lib/__tests__/pricing.test.ts`), not an accident of array order; a resolver depending on running before/after a same-priority peer should still set an explicit, distinct `priority` rather than relying on load order.
61
+
58
62
  ## Data Model Constraints
59
63
 
60
64
  - **Products** — core entities with media and descriptions. MUST have at least a name
@@ -0,0 +1,12 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['catalog.pricing.manage'],
4
+ pageTitle: 'Edit price rule',
5
+ pageTitleKey: 'catalog.prices.form.editTitle',
6
+ pageGroup: 'Catalog',
7
+ navHidden: true,
8
+ breadcrumb: [
9
+ { label: 'Price rules', labelKey: 'catalog.prices.page.title', href: '/backend/catalog/prices' },
10
+ { label: 'Edit', labelKey: 'common.edit' },
11
+ ],
12
+ }
@@ -0,0 +1,198 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
5
+ import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
6
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
8
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
9
+ import { ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
10
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { E } from '#generated/entities.ids.generated'
12
+ import { usePriceFormFields, type PriceFormValues } from '../../../../../components/prices/priceFormFields'
13
+ import { normalizePriceRecord } from '../../../../../components/prices/normalizePriceRecord'
14
+
15
+ type PriceListResponse = {
16
+ items?: Array<Record<string, unknown>>
17
+ }
18
+
19
+ function toDateInputValue(value: string | null | undefined): string {
20
+ if (!value) return ''
21
+ const date = new Date(value)
22
+ if (Number.isNaN(date.getTime())) return ''
23
+ return date.toISOString().slice(0, 10)
24
+ }
25
+
26
+ function buildUpdatePayload(
27
+ id: string,
28
+ values: PriceFormValues,
29
+ t: (key: string, fallback?: string) => string,
30
+ ): Record<string, unknown> {
31
+ const productId = values.productId?.trim() || undefined
32
+ const variantId = values.variantId?.trim() || undefined
33
+ if (!productId && !variantId) {
34
+ const message = t('catalog.prices.form.errors.productOrVariantRequired', 'Choose a product or a variant.')
35
+ throw createCrudFormError(message, { productId: message })
36
+ }
37
+ return {
38
+ id,
39
+ productId,
40
+ variantId,
41
+ priceKindId: values.priceKindId?.trim() || undefined,
42
+ currencyCode: values.currencyCode?.trim() || undefined,
43
+ unitPriceNet: values.unitPriceNet?.trim() || undefined,
44
+ unitPriceGross: values.unitPriceGross?.trim() || undefined,
45
+ taxRate: values.taxRate?.trim() || undefined,
46
+ minQuantity: values.minQuantity ?? undefined,
47
+ maxQuantity: values.maxQuantity ?? undefined,
48
+ startsAt: values.startsAt?.trim() || undefined,
49
+ endsAt: values.endsAt?.trim() || undefined,
50
+ customerId: values.customerId?.trim() || undefined,
51
+ customerGroupId: values.customerGroupId?.trim() || undefined,
52
+ channelId: values.channelId?.trim() || undefined,
53
+ offerId: values.offerId?.trim() || undefined,
54
+ userId: values.userId?.trim() || undefined,
55
+ userGroupId: values.userGroupId?.trim() || undefined,
56
+ }
57
+ }
58
+
59
+ export default function EditCatalogPricePage({ params }: { params?: { id?: string } }) {
60
+ const priceId = params?.id ?? ''
61
+ const t = useT()
62
+ const { fields, groups } = usePriceFormFields()
63
+ const [initialValues, setInitialValues] = React.useState<PriceFormValues | null>(null)
64
+ const [loading, setLoading] = React.useState(true)
65
+ const [error, setError] = React.useState<string | null>(null)
66
+ const [isNotFound, setIsNotFound] = React.useState(false)
67
+
68
+ React.useEffect(() => {
69
+ if (!priceId) return
70
+ let cancelled = false
71
+ async function load() {
72
+ setLoading(true)
73
+ setError(null)
74
+ setIsNotFound(false)
75
+ try {
76
+ const { ok, status, result } = await apiCall<PriceListResponse>(
77
+ `/api/catalog/prices?ids=${encodeURIComponent(priceId)}&pageSize=1`,
78
+ )
79
+ if (!ok) {
80
+ if (status === 404) {
81
+ if (!cancelled) setIsNotFound(true)
82
+ return
83
+ }
84
+ throw new Error(t('catalog.prices.form.errors.load', 'Failed to load price rule'))
85
+ }
86
+ const raw = Array.isArray(result?.items) ? result.items[0] : null
87
+ if (!raw) {
88
+ if (!cancelled) setIsNotFound(true)
89
+ return
90
+ }
91
+ if (cancelled) return
92
+ const record = normalizePriceRecord(raw)
93
+ setInitialValues({
94
+ id: record.id,
95
+ productId: record.productId ?? '',
96
+ variantId: record.variantId ?? '',
97
+ priceKindId: record.priceKindId ?? '',
98
+ currencyCode: record.currencyCode ?? '',
99
+ unitPriceNet: record.unitPriceNet ?? '',
100
+ unitPriceGross: record.unitPriceGross ?? '',
101
+ taxRate: record.taxRate ?? '',
102
+ minQuantity: record.minQuantity ?? 1,
103
+ maxQuantity: record.maxQuantity ?? undefined,
104
+ startsAt: toDateInputValue(record.startsAt),
105
+ endsAt: toDateInputValue(record.endsAt),
106
+ customerId: record.customerId ?? '',
107
+ customerGroupId: record.customerGroupId ?? '',
108
+ channelId: record.channelId ?? '',
109
+ offerId: record.offerId ?? '',
110
+ userId: record.userId ?? '',
111
+ userGroupId: record.userGroupId ?? '',
112
+ updatedAt: record.updatedAt ?? null,
113
+ })
114
+ } catch (err) {
115
+ if (!cancelled) {
116
+ const fallback = t('catalog.prices.form.errors.load', 'Failed to load price rule')
117
+ const message = err instanceof Error ? err.message : fallback
118
+ setError(message)
119
+ }
120
+ } finally {
121
+ if (!cancelled) setLoading(false)
122
+ }
123
+ }
124
+ load()
125
+ return () => {
126
+ cancelled = true
127
+ }
128
+ }, [priceId, t])
129
+
130
+ if (!priceId) {
131
+ return (
132
+ <Page>
133
+ <PageBody>
134
+ <p className="text-sm text-destructive">
135
+ {t('catalog.prices.form.errors.idRequired', 'Price rule identifier is required.')}
136
+ </p>
137
+ </PageBody>
138
+ </Page>
139
+ )
140
+ }
141
+
142
+ if (isNotFound) {
143
+ return (
144
+ <Page>
145
+ <PageBody>
146
+ <RecordNotFoundState
147
+ label={t('catalog.prices.form.errors.notFound', 'Price rule not found')}
148
+ backHref="/backend/catalog/prices"
149
+ backLabel={t('catalog.prices.form.actions.backToList', 'Back to price rules')}
150
+ />
151
+ </PageBody>
152
+ </Page>
153
+ )
154
+ }
155
+
156
+ if (error && !loading) {
157
+ return (
158
+ <Page>
159
+ <PageBody>
160
+ <ErrorMessage label={error} />
161
+ </PageBody>
162
+ </Page>
163
+ )
164
+ }
165
+
166
+ return (
167
+ <Page>
168
+ <PageBody>
169
+ <CrudForm<PriceFormValues>
170
+ title={t('catalog.prices.form.editTitle', 'Edit price rule')}
171
+ titleHeadingLevel={1}
172
+ backHref="/backend/catalog/prices"
173
+ fields={fields}
174
+ groups={groups}
175
+ collapsibleGroups
176
+ entityId={E.catalog.catalog_product_price}
177
+ initialValues={initialValues ?? { id: priceId }}
178
+ optimisticLockUpdatedAt={initialValues?.updatedAt}
179
+ isLoading={loading}
180
+ loadingMessage={t('catalog.prices.form.loading', 'Loading price rule...')}
181
+ submitLabel={t('catalog.prices.form.action.save', 'Save')}
182
+ cancelHref="/backend/catalog/prices"
183
+ successRedirect={`/backend/catalog/prices?flash=${encodeURIComponent(t('catalog.prices.flash.updated', 'Price rule updated'))}&type=success`}
184
+ onSubmit={async (values) => {
185
+ const payload = buildUpdatePayload(priceId, values, t)
186
+ await updateCrud('catalog/prices', payload)
187
+ }}
188
+ onDelete={async () => {
189
+ await deleteCrud('catalog/prices', priceId, {
190
+ errorMessage: t('catalog.prices.form.errors.delete', 'Failed to delete price rule'),
191
+ })
192
+ }}
193
+ deleteRedirect={`/backend/catalog/prices?flash=${encodeURIComponent(t('catalog.prices.flash.deleted', 'Price rule deleted'))}&type=success`}
194
+ />
195
+ </PageBody>
196
+ </Page>
197
+ )
198
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+
3
+ const createPageIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
6
+ React.createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
7
+ React.createElement('polyline', { points: '14 2 14 8 20 8' }),
8
+ React.createElement('path', { d: 'M12 11v6M9 14h6' }),
9
+ )
10
+
11
+ export const metadata = {
12
+ requireAuth: true,
13
+ requireFeatures: ['catalog.pricing.manage'],
14
+ pageTitle: 'Create price rule',
15
+ pageTitleKey: 'catalog.prices.form.createTitle',
16
+ pageGroup: 'Catalog',
17
+ pageGroupKey: 'catalog.nav.group',
18
+ icon: createPageIcon,
19
+ breadcrumb: [
20
+ { label: 'Price rules', labelKey: 'catalog.prices.page.title', href: '/backend/catalog/prices' },
21
+ { label: 'Create', labelKey: 'common.create' },
22
+ ],
23
+ }
@@ -0,0 +1,70 @@
1
+ "use client"
2
+
3
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
4
+ import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
5
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
6
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { E } from '#generated/entities.ids.generated'
9
+ import { PRICE_FORM_INITIAL_VALUES, usePriceFormFields, type PriceFormValues } from '../../../../components/prices/priceFormFields'
10
+
11
+ function buildPricePayload(values: PriceFormValues, t: (key: string, fallback?: string) => string): Record<string, unknown> {
12
+ const productId = values.productId?.trim() || undefined
13
+ const variantId = values.variantId?.trim() || undefined
14
+ if (!productId && !variantId) {
15
+ const message = t('catalog.prices.form.errors.productOrVariantRequired', 'Choose a product or a variant.')
16
+ throw createCrudFormError(message, { productId: message })
17
+ }
18
+ const priceKindId = values.priceKindId?.trim() || undefined
19
+ const currencyCode = values.currencyCode?.trim() || undefined
20
+ const payload: Record<string, unknown> = {
21
+ productId,
22
+ variantId,
23
+ priceKindId,
24
+ currencyCode,
25
+ unitPriceNet: values.unitPriceNet?.trim() || undefined,
26
+ unitPriceGross: values.unitPriceGross?.trim() || undefined,
27
+ taxRate: values.taxRate?.trim() || undefined,
28
+ minQuantity: values.minQuantity ?? undefined,
29
+ maxQuantity: values.maxQuantity ?? undefined,
30
+ startsAt: values.startsAt?.trim() || undefined,
31
+ endsAt: values.endsAt?.trim() || undefined,
32
+ customerId: values.customerId?.trim() || undefined,
33
+ customerGroupId: values.customerGroupId?.trim() || undefined,
34
+ channelId: values.channelId?.trim() || undefined,
35
+ offerId: values.offerId?.trim() || undefined,
36
+ userId: values.userId?.trim() || undefined,
37
+ userGroupId: values.userGroupId?.trim() || undefined,
38
+ }
39
+ return payload
40
+ }
41
+
42
+ export default function CreateCatalogPricePage() {
43
+ const t = useT()
44
+ const { fields, groups } = usePriceFormFields()
45
+ const successMessage = encodeURIComponent(t('catalog.prices.flash.created', 'Price rule created'))
46
+
47
+ return (
48
+ <Page>
49
+ <PageBody>
50
+ <CrudForm<PriceFormValues>
51
+ title={t('catalog.prices.form.createTitle', 'Create price rule')}
52
+ titleHeadingLevel={1}
53
+ backHref="/backend/catalog/prices"
54
+ fields={fields}
55
+ groups={groups}
56
+ collapsibleGroups
57
+ entityId={E.catalog.catalog_product_price}
58
+ initialValues={PRICE_FORM_INITIAL_VALUES}
59
+ submitLabel={t('catalog.prices.form.action.save', 'Save')}
60
+ cancelHref="/backend/catalog/prices"
61
+ successRedirect={`/backend/catalog/prices?flash=${successMessage}&type=success`}
62
+ onSubmit={async (values) => {
63
+ const payload = buildPricePayload(values, t)
64
+ await createCrud('catalog/prices', payload)
65
+ }}
66
+ />
67
+ </PageBody>
68
+ </Page>
69
+ )
70
+ }
@@ -0,0 +1,12 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['catalog.products.view'],
4
+ pageTitle: 'Price rules',
5
+ pageTitleKey: 'catalog.prices.page.title',
6
+ pageGroup: 'Catalog',
7
+ pageGroupKey: 'catalog.nav.group',
8
+ pagePriority: 30,
9
+ pageOrder: 110,
10
+ icon: 'tag',
11
+ breadcrumb: [{ label: 'Price rules', labelKey: 'catalog.prices.page.title' }],
12
+ }
@@ -0,0 +1,14 @@
1
+ "use client"
2
+
3
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
4
+ import PricesDataTable from '../../../components/prices/PricesDataTable'
5
+
6
+ export default function CatalogPricesPage() {
7
+ return (
8
+ <Page>
9
+ <PageBody>
10
+ <PricesDataTable />
11
+ </PageBody>
12
+ </Page>
13
+ )
14
+ }
@@ -0,0 +1,243 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs/ComboboxInput'
5
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { createLogger } from '@open-mercato/shared/lib/logger'
8
+
9
+ const logger = createLogger('catalog').child({ component: 'PriceScopeSelectors' })
10
+
11
+ type RemoteItem = Record<string, unknown>
12
+
13
+ function pickString(item: RemoteItem, ...keys: string[]): string {
14
+ for (const key of keys) {
15
+ const value = item[key]
16
+ if (typeof value === 'string' && value.length) return value
17
+ }
18
+ return ''
19
+ }
20
+
21
+ async function loadOptions(
22
+ path: string,
23
+ params: Record<string, string>,
24
+ mapItem: (item: RemoteItem) => ComboboxOption | null,
25
+ ): Promise<ComboboxOption[]> {
26
+ try {
27
+ const search = new URLSearchParams(params)
28
+ const payload = await readApiResultOrThrow<{ items?: RemoteItem[] }>(
29
+ `${path}?${search.toString()}`,
30
+ undefined,
31
+ { fallback: { items: [] } },
32
+ )
33
+ const items = Array.isArray(payload.items) ? payload.items : []
34
+ return items.map(mapItem).filter((option): option is ComboboxOption => option !== null)
35
+ } catch (err) {
36
+ logger.error('catalog.prices.scopeSelect.load', { err, path })
37
+ return []
38
+ }
39
+ }
40
+
41
+ async function resolveOne(
42
+ path: string,
43
+ idParam: string,
44
+ id: string,
45
+ mapItem: (item: RemoteItem) => ComboboxOption | null,
46
+ ): Promise<string> {
47
+ const options = await loadOptions(path, { [idParam]: id, pageSize: '1' }, mapItem)
48
+ return options[0]?.label ?? id
49
+ }
50
+
51
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
52
+
53
+ /**
54
+ * A typed query that is itself a record id (a pasted UUID) is searched via
55
+ * the generic `ids=` narrowing instead of the entity's free-text `search=`
56
+ * filter — free-text search matches title/name/sku columns, never the id
57
+ * column, so pasting an exact id would otherwise return zero suggestions.
58
+ */
59
+ function buildLookupParams(query: string | undefined, pageSize: string): Record<string, string> {
60
+ const trimmed = (query ?? '').trim()
61
+ if (UUID_PATTERN.test(trimmed)) return { ids: trimmed, pageSize: '1' }
62
+ return { search: trimmed, pageSize }
63
+ }
64
+
65
+ export function PriceProductSelect({
66
+ value,
67
+ onChange,
68
+ disabled,
69
+ }: {
70
+ value: string
71
+ onChange: (next: string) => void
72
+ disabled?: boolean
73
+ }) {
74
+ const t = useT()
75
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
76
+ const id = pickString(item, 'id')
77
+ if (!id) return null
78
+ const title = pickString(item, 'title', 'name') || id
79
+ const sku = pickString(item, 'sku')
80
+ return { value: id, label: sku ? `${title} (${sku})` : title }
81
+ }, [])
82
+ return (
83
+ <ComboboxInput
84
+ value={value}
85
+ onChange={onChange}
86
+ disabled={disabled}
87
+ allowCustomValues={false}
88
+ clearable
89
+ placeholder={t('catalog.prices.select.product.placeholder', 'Search products…')}
90
+ loadSuggestions={(query) => loadOptions('/api/catalog/products', buildLookupParams(query, '10'), mapItem)}
91
+ resolveLabel={(id) => resolveOne('/api/catalog/products', 'id', id, mapItem)}
92
+ />
93
+ )
94
+ }
95
+
96
+ export function PriceVariantSelect({
97
+ productId,
98
+ value,
99
+ onChange,
100
+ }: {
101
+ productId: string
102
+ value: string
103
+ onChange: (next: string) => void
104
+ }) {
105
+ const t = useT()
106
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
107
+ const id = pickString(item, 'id')
108
+ if (!id) return null
109
+ const name = pickString(item, 'name') || id
110
+ const sku = pickString(item, 'sku')
111
+ return { value: id, label: sku ? `${name} (${sku})` : name }
112
+ }, [])
113
+ const disabled = !productId
114
+ return (
115
+ <ComboboxInput
116
+ value={value}
117
+ onChange={onChange}
118
+ disabled={disabled}
119
+ allowCustomValues={false}
120
+ clearable
121
+ placeholder={
122
+ disabled
123
+ ? t('catalog.prices.form.field.product', 'Product')
124
+ : t('catalog.prices.select.product.placeholder', 'Search products…')
125
+ }
126
+ loadSuggestions={(query) =>
127
+ disabled
128
+ ? Promise.resolve([])
129
+ : loadOptions('/api/catalog/variants', { ...buildLookupParams(query, '10'), productId }, mapItem)
130
+ }
131
+ resolveLabel={(id) => resolveOne('/api/catalog/variants', 'id', id, mapItem)}
132
+ />
133
+ )
134
+ }
135
+
136
+ export function PriceCustomerSelect({
137
+ value,
138
+ onChange,
139
+ }: {
140
+ value: string
141
+ onChange: (next: string) => void
142
+ }) {
143
+ const t = useT()
144
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
145
+ const id = pickString(item, 'id')
146
+ if (!id) return null
147
+ const name = pickString(item, 'displayName', 'display_name', 'name') || id
148
+ const email = pickString(item, 'primaryEmail', 'primary_email')
149
+ return { value: id, label: name, description: email || null }
150
+ }, [])
151
+ return (
152
+ <ComboboxInput
153
+ value={value}
154
+ onChange={onChange}
155
+ allowCustomValues={false}
156
+ clearable
157
+ placeholder={t('catalog.prices.select.customer.placeholder', 'Search customers…')}
158
+ loadSuggestions={(query) => loadOptions('/api/customers/people', buildLookupParams(query, '10'), mapItem)}
159
+ resolveLabel={(id) => resolveOne('/api/customers/people', 'id', id, mapItem)}
160
+ />
161
+ )
162
+ }
163
+
164
+ export function PriceChannelSelect({
165
+ value,
166
+ onChange,
167
+ }: {
168
+ value: string
169
+ onChange: (next: string) => void
170
+ }) {
171
+ const t = useT()
172
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
173
+ const id = pickString(item, 'id')
174
+ if (!id) return null
175
+ const name = pickString(item, 'name') || id
176
+ const code = pickString(item, 'code')
177
+ return { value: id, label: code ? `${name} (${code})` : name }
178
+ }, [])
179
+ return (
180
+ <ComboboxInput
181
+ value={value}
182
+ onChange={onChange}
183
+ allowCustomValues={false}
184
+ clearable
185
+ placeholder={t('catalog.prices.select.channel.placeholder', 'Select a channel…')}
186
+ loadSuggestions={(query) => loadOptions('/api/sales/channels', buildLookupParams(query, '50'), mapItem)}
187
+ resolveLabel={(id) => resolveOne('/api/sales/channels', 'id', id, mapItem)}
188
+ />
189
+ )
190
+ }
191
+
192
+ export function PricePriceKindSelect({
193
+ value,
194
+ onChange,
195
+ }: {
196
+ value: string
197
+ onChange: (next: string) => void
198
+ }) {
199
+ const t = useT()
200
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
201
+ const id = pickString(item, 'id')
202
+ if (!id) return null
203
+ const title = pickString(item, 'title') || id
204
+ const code = pickString(item, 'code')
205
+ return { value: id, label: code ? `${title} (${code})` : title }
206
+ }, [])
207
+ return (
208
+ <ComboboxInput
209
+ value={value}
210
+ onChange={onChange}
211
+ allowCustomValues={false}
212
+ placeholder={t('catalog.prices.select.priceKind.placeholder', 'Select a price kind…')}
213
+ loadSuggestions={(query) => loadOptions('/api/catalog/price-kinds', buildLookupParams(query, '20'), mapItem)}
214
+ resolveLabel={(id) => resolveOne('/api/catalog/price-kinds', 'id', id, mapItem)}
215
+ />
216
+ )
217
+ }
218
+
219
+ export function PriceCurrencySelect({
220
+ value,
221
+ onChange,
222
+ }: {
223
+ value: string
224
+ onChange: (next: string) => void
225
+ }) {
226
+ const t = useT()
227
+ const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
228
+ const code = pickString(item, 'value')
229
+ if (!code) return null
230
+ const label = pickString(item, 'label') || code
231
+ return { value: code, label: label === code ? code : `${code} — ${label}` }
232
+ }, [])
233
+ return (
234
+ <ComboboxInput
235
+ value={value}
236
+ onChange={onChange}
237
+ allowCustomValues={false}
238
+ placeholder={t('catalog.prices.select.currency.placeholder', 'Select a currency…')}
239
+ loadSuggestions={(query) => loadOptions('/api/currencies/currencies/options', { search: query ?? '', limit: '20' }, mapItem)}
240
+ resolveLabel={(id) => resolveOne('/api/currencies/currencies/options', 'search', id, mapItem)}
241
+ />
242
+ )
243
+ }