@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
- package/dist/modules/catalog/data/validators.js +21 -2
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/extension-points.js +2 -1
- package/dist/modules/catalog/extension-points.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +37 -8
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
- package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
- package/dist/modules/communication_channels/extension-points.js +1 -1
- package/dist/modules/communication_channels/extension-points.js.map +1 -1
- package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
- package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/messages/api/route.js +5 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +12 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +4 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +19 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/AGENTS.md +6 -2
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
- package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
- package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
- package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
- package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
- package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
- package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
- package/src/modules/catalog/data/validators.ts +43 -2
- package/src/modules/catalog/extension-points.ts +1 -0
- package/src/modules/catalog/i18n/de.json +72 -0
- package/src/modules/catalog/i18n/en.json +72 -0
- package/src/modules/catalog/i18n/es.json +72 -0
- package/src/modules/catalog/i18n/ko.json +72 -0
- package/src/modules/catalog/i18n/pl.json +72 -0
- package/src/modules/catalog/lib/pricing.ts +103 -9
- package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
- package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +4 -3
- package/src/modules/communication_channels/i18n/en.json +4 -3
- package/src/modules/communication_channels/i18n/es.json +4 -3
- package/src/modules/communication_channels/i18n/ko.json +4 -3
- package/src/modules/communication_channels/i18n/pl.json +4 -3
- package/src/modules/communication_channels/lib/adapter.ts +16 -2
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
- package/src/modules/messages/api/route.ts +8 -2
- package/src/modules/messages/commands/messages.ts +17 -2
- package/src/modules/messages/data/validators.ts +26 -11
- package/src/modules/messages/subscribers/message-notification.ts +17 -5
- package/src/modules/query_index/lib/engine.ts +23 -1
- 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.
|
|
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.
|
|
257
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
258
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
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.
|
|
264
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
265
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
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
|
+
}
|