@12-apps/discounts 1.8.0 → 1.10.0
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/package.json +9 -4
- package/src/engine/en-US.ts +37 -0
- package/src/locales.ts +41 -0
- package/src/react/en-US.ts +199 -0
- package/src/react/index.ts +1 -0
- package/src/server/copy.ts +28 -0
- package/src/server/en-US.ts +39 -0
- package/src/server/routes.ts +38 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"./manifest": "./src/manifest/index.ts",
|
|
14
14
|
"./manifest/server": "./src/manifest/server.ts",
|
|
15
15
|
"./manifest/web": "./src/manifest/web.ts",
|
|
16
|
+
"./en-US": "./src/engine/en-US.ts",
|
|
17
|
+
"./server/en-US": "./src/server/en-US.ts",
|
|
18
|
+
"./react/en-US": "./src/react/en-US.ts",
|
|
19
|
+
"./locales": "./src/locales.ts",
|
|
16
20
|
"./package.json": "./package.json"
|
|
17
21
|
},
|
|
18
22
|
"scripts": {
|
|
@@ -28,10 +32,10 @@
|
|
|
28
32
|
"typecheck": "tsc --noEmit"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
31
|
-
"@12-apps/app-shell": "^5.
|
|
35
|
+
"@12-apps/app-shell": "^5.3.0",
|
|
32
36
|
"@12-apps/forms-core": "^1.20.0",
|
|
33
37
|
"@12-apps/shared-helpers": "^2.0.2",
|
|
34
|
-
"@12-apps/ui": "^6.
|
|
38
|
+
"@12-apps/ui": "^6.9.0",
|
|
35
39
|
"@mui/icons-material": "^6.5.0",
|
|
36
40
|
"zod": "^4.3.5"
|
|
37
41
|
},
|
|
@@ -57,8 +61,9 @@
|
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|
|
59
63
|
"@12-apps/eslint-config": "^1.21.1",
|
|
64
|
+
"@12-apps/i18n": "^1.1.0",
|
|
60
65
|
"@12-apps/typescript-config": "^1.20.1",
|
|
61
|
-
"@12-apps/wiring": "^1.
|
|
66
|
+
"@12-apps/wiring": "^1.11.0",
|
|
62
67
|
"@electric-sql/pglite": "^0.2.17",
|
|
63
68
|
"@emotion/react": "^11.14.0",
|
|
64
69
|
"@emotion/styled": "^11.14.0",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MIN_SUBTOTAL_TOKEN, type DiscountRejectionCopy } from "./rejection-copy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The en-US pack — a NAMED export a host passes by hand
|
|
5
|
+
* (`rejectionCopy: EN_US_DISCOUNT_REJECTION_COPY`), never a default.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately COARSER than the reason set, and the translation keeps it that
|
|
8
|
+
* way: switched off, not started yet and expired all read the same, because the
|
|
9
|
+
* difference only leaks how the merchant schedules promotions and the buyer
|
|
10
|
+
* could not act on it either way. Four distinct English sentences here would be
|
|
11
|
+
* a disclosure the Portuguese does not make.
|
|
12
|
+
*
|
|
13
|
+
* `COMBO_NOT_MATCHED` is the one place that coarsening stops, and for the same
|
|
14
|
+
* test read the other way: a buyer one soda short of the bundle can finish it,
|
|
15
|
+
* so telling them WHICH kind of failure this was is the difference between a
|
|
16
|
+
* dead end and a sale. It does not name the missing item — the evaluator does
|
|
17
|
+
* not report one, because "which item is missing" has no single answer when a
|
|
18
|
+
* slot accepts a whole category.
|
|
19
|
+
*
|
|
20
|
+
* `MIN_SUBTOTAL_TOKEN` is substituted with the formatted amount, so the
|
|
21
|
+
* sentence must carry it rather than a number.
|
|
22
|
+
*/
|
|
23
|
+
export const EN_US_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
|
|
24
|
+
UNKNOWN_CODE: "That coupon is invalid or expired.",
|
|
25
|
+
INACTIVE: "That coupon is invalid or expired.",
|
|
26
|
+
NOT_STARTED: "That coupon is invalid or expired.",
|
|
27
|
+
EXPIRED: "That coupon is invalid or expired.",
|
|
28
|
+
MIN_SUBTOTAL_NOT_MET: `This coupon needs a minimum order of ${MIN_SUBTOTAL_TOKEN}.`,
|
|
29
|
+
USAGE_LIMIT_REACHED: "This coupon has reached its usage limit.",
|
|
30
|
+
BUYER_LIMIT_REACHED: "You have already used this coupon.",
|
|
31
|
+
NO_ELIGIBLE_ITEMS: "This coupon does not apply to the items in your cart.",
|
|
32
|
+
COMBO_NOT_MATCHED: "Your cart does not have every item in this bundle yet.",
|
|
33
|
+
ZERO_VALUE: "This coupon does not apply to the items in your cart.",
|
|
34
|
+
NOT_STACKABLE: "Another promotion already applied is better than this coupon.",
|
|
35
|
+
EMPTY_CART: "Your cart is empty.",
|
|
36
|
+
minSubtotalUnknown: "This coupon needs a minimum order value.",
|
|
37
|
+
};
|
package/src/locales.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EN_US_DISCOUNT_REJECTION_COPY } from "./engine/en-US";
|
|
2
|
+
import { PT_BR_DISCOUNT_REJECTION_COPY } from "./engine/pt-BR";
|
|
3
|
+
import type { DiscountRejectionCopy } from "./engine/rejection-copy";
|
|
4
|
+
import { EN_US_DISCOUNTS_WEB_COPY } from "./react/en-US";
|
|
5
|
+
import type { DiscountsWebCopy } from "./react/copy";
|
|
6
|
+
import { PT_BR_DISCOUNTS_WEB_COPY } from "./react/pt-BR";
|
|
7
|
+
import type { DiscountsServerCopy } from "./server/copy";
|
|
8
|
+
import { EN_US_DISCOUNTS_SERVER_COPY } from "./server/en-US";
|
|
9
|
+
import { PT_BR_DISCOUNTS_SERVER_COPY } from "./server/pt-BR";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* All three surfaces, in both languages, keyed by tag — what a host hands to
|
|
13
|
+
* `@12-apps/i18n` when the reader's language is a property of the request
|
|
14
|
+
* rather than of the deployment.
|
|
15
|
+
*
|
|
16
|
+
* Three packs rather than one, because the three have different AUDIENCES and
|
|
17
|
+
* different lifetimes: the rejection copy is read by a shopper at a cart, the
|
|
18
|
+
* server copy by an operator filling a form, and the web copy by that same
|
|
19
|
+
* operator on the screen around it. A host may well resolve them from different
|
|
20
|
+
* places.
|
|
21
|
+
*
|
|
22
|
+
* `LocalePack` is mirrored here rather than imported so the package stays
|
|
23
|
+
* liftable into a repo that has never heard of `@12-apps/i18n`. The named
|
|
24
|
+
* single-language packs stay exported and unchanged.
|
|
25
|
+
*/
|
|
26
|
+
type LocalePack<T> = { readonly "pt-BR": T; readonly "en-US": T };
|
|
27
|
+
|
|
28
|
+
export const DISCOUNT_REJECTION_COPY = {
|
|
29
|
+
"pt-BR": PT_BR_DISCOUNT_REJECTION_COPY,
|
|
30
|
+
"en-US": EN_US_DISCOUNT_REJECTION_COPY,
|
|
31
|
+
} as const satisfies LocalePack<DiscountRejectionCopy>;
|
|
32
|
+
|
|
33
|
+
export const DISCOUNTS_SERVER_COPY = {
|
|
34
|
+
"pt-BR": PT_BR_DISCOUNTS_SERVER_COPY,
|
|
35
|
+
"en-US": EN_US_DISCOUNTS_SERVER_COPY,
|
|
36
|
+
} as const satisfies LocalePack<DiscountsServerCopy>;
|
|
37
|
+
|
|
38
|
+
export const DISCOUNTS_WEB_COPY = {
|
|
39
|
+
"pt-BR": PT_BR_DISCOUNTS_WEB_COPY,
|
|
40
|
+
"en-US": EN_US_DISCOUNTS_WEB_COPY,
|
|
41
|
+
} as const satisfies LocalePack<DiscountsWebCopy>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EN_US_AUTOCOMPLETE_COPY,
|
|
3
|
+
EN_US_CATEGORY_SELECT_COPY,
|
|
4
|
+
EN_US_CONFIRM_ACTION_COPY,
|
|
5
|
+
EN_US_DATA_VIEWS_COPY,
|
|
6
|
+
} from "@12-apps/ui/en-US";
|
|
7
|
+
|
|
8
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The en-US pack for the discounts admin screens.
|
|
12
|
+
*
|
|
13
|
+
* A NAMED PACK a host passes by hand, never a default. The four composed packs
|
|
14
|
+
* come from `@12-apps/ui`'s own English side rather than being restated here,
|
|
15
|
+
* exactly as the pt-BR pack composes the Portuguese ones.
|
|
16
|
+
*
|
|
17
|
+
* The `{token}` substitutions are the package's own and are NOT translated —
|
|
18
|
+
* `fill()` replaces them, so a renamed token is a sentence that renders its own
|
|
19
|
+
* placeholder. Their ORDER is free, which is what naming them buys.
|
|
20
|
+
*
|
|
21
|
+
* The KEYS of `labels.*` are the engine's wire values (`PERCENTAGE`, `ORDER`,
|
|
22
|
+
* `RUNNING`, …). Only the words beside them are translated.
|
|
23
|
+
*/
|
|
24
|
+
export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
25
|
+
screen: {
|
|
26
|
+
title: "Discounts",
|
|
27
|
+
aboutTitle: "About discounts",
|
|
28
|
+
aboutBody:
|
|
29
|
+
"Create automatic promotions or coupon codes — for the whole order, one category, named products, or a bundle.",
|
|
30
|
+
create: "New discount",
|
|
31
|
+
empty: "No discounts yet.",
|
|
32
|
+
// A FILENAME, not a sentence: it is what the export lands on disk as.
|
|
33
|
+
exportFileName: "discounts",
|
|
34
|
+
loading: "Loading discounts…",
|
|
35
|
+
loadFailed: "Could not load the discounts",
|
|
36
|
+
retry: "Try again",
|
|
37
|
+
columns: {
|
|
38
|
+
name: "Name",
|
|
39
|
+
value: "Discount",
|
|
40
|
+
type: "Type",
|
|
41
|
+
scope: "Applies to",
|
|
42
|
+
trigger: "Trigger",
|
|
43
|
+
window: "Validity",
|
|
44
|
+
code: "Code",
|
|
45
|
+
usageCount: "Uses",
|
|
46
|
+
active: "Active",
|
|
47
|
+
},
|
|
48
|
+
yes: "Yes",
|
|
49
|
+
no: "No",
|
|
50
|
+
},
|
|
51
|
+
labels: {
|
|
52
|
+
kind: {
|
|
53
|
+
PERCENTAGE: "Percentage",
|
|
54
|
+
FIXED_AMOUNT: "Fixed amount",
|
|
55
|
+
COMBO: "Bundle",
|
|
56
|
+
FREE_UNITS: "Free items",
|
|
57
|
+
BUNDLE_PRICE: "Bundle price",
|
|
58
|
+
},
|
|
59
|
+
type: {
|
|
60
|
+
PERCENTAGE: "Percentage",
|
|
61
|
+
FIXED_AMOUNT: "Fixed amount",
|
|
62
|
+
BUNDLE_PRICE: "Bundle price",
|
|
63
|
+
FREE_UNITS: "Free items",
|
|
64
|
+
},
|
|
65
|
+
scope: {
|
|
66
|
+
ORDER: "Order",
|
|
67
|
+
CATEGORY: "Category",
|
|
68
|
+
ITEM: "Item",
|
|
69
|
+
COMBO: "Bundle",
|
|
70
|
+
},
|
|
71
|
+
trigger: {
|
|
72
|
+
AUTOMATIC: "Automatic",
|
|
73
|
+
CODE: "Code",
|
|
74
|
+
},
|
|
75
|
+
window: {
|
|
76
|
+
RUNNING: "Running",
|
|
77
|
+
SCHEDULED: "Scheduled",
|
|
78
|
+
ENDED: "Ended",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
// All four shapes get their own phrasing, for the reason the window helper
|
|
82
|
+
// records: a dash on one side would leave the operator guessing whether the
|
|
83
|
+
// promotion has no start or no end, and those mean very different things
|
|
84
|
+
// when it is not running yet.
|
|
85
|
+
window: {
|
|
86
|
+
always: "No end date",
|
|
87
|
+
from: "From {date}",
|
|
88
|
+
until: "Until {date}",
|
|
89
|
+
between: "{from} to {to}",
|
|
90
|
+
},
|
|
91
|
+
form: {
|
|
92
|
+
createTitle: "New discount",
|
|
93
|
+
editTitle: "Edit discount",
|
|
94
|
+
submitCreate: "Create discount",
|
|
95
|
+
submitEdit: "Save changes",
|
|
96
|
+
name: "Promotion name",
|
|
97
|
+
type: "Type",
|
|
98
|
+
kind: "Promotion type",
|
|
99
|
+
comboReward: "Bundle discount",
|
|
100
|
+
comboRewardHint: "The bundle sells for the price of its items minus this discount.",
|
|
101
|
+
percentOff: "Discount (%)",
|
|
102
|
+
percentPlaceholder: "10",
|
|
103
|
+
amountOff: "Discount amount",
|
|
104
|
+
bundlePrice: "Bundle price",
|
|
105
|
+
freeUnits: "Free items",
|
|
106
|
+
freeUnitsHint:
|
|
107
|
+
'How many units of the bundle are free. In a group of 3, making 1 free is the classic "buy 3, pay for 2".',
|
|
108
|
+
trigger: "Trigger",
|
|
109
|
+
code: "Coupon code",
|
|
110
|
+
// A sample CODE, not a word: it shows the shape of one a shopper types.
|
|
111
|
+
codePlaceholder: "WELCOME10",
|
|
112
|
+
scope: "Applies to",
|
|
113
|
+
startsAt: "Starts",
|
|
114
|
+
endsAt: "Ends",
|
|
115
|
+
minSubtotal: "Minimum order",
|
|
116
|
+
usageLimit: "Usage limit",
|
|
117
|
+
perBuyerLimit: "Limit per customer",
|
|
118
|
+
maxComboApplications: "Bundles per order",
|
|
119
|
+
maxComboApplicationsHint: "Leave blank to apply as many times as the cart allows.",
|
|
120
|
+
maxFreeUnitsApplications: "Times per order",
|
|
121
|
+
active: "Active",
|
|
122
|
+
activeHint: "Switch off to pause the promotion without deleting it.",
|
|
123
|
+
stackable: "Stackable",
|
|
124
|
+
stackableHint:
|
|
125
|
+
"When off, this promotion is exclusive: if it wins, no other one is applied.",
|
|
126
|
+
reviewFields: "Check the highlighted fields.",
|
|
127
|
+
saveFailed: "Could not save the discount",
|
|
128
|
+
nameRequired: "Give the promotion a name.",
|
|
129
|
+
invalidPercent: "Enter a percentage greater than 0 and at most 100.",
|
|
130
|
+
invalidAmount: "Enter a discount amount greater than zero.",
|
|
131
|
+
invalidBundlePrice: "Enter the bundle price, greater than zero.",
|
|
132
|
+
invalidFreeUnits: "Enter how many items are free, from 1 upwards.",
|
|
133
|
+
freeUnitsExceedCombo:
|
|
134
|
+
"The bundle has {units} items; make at most {max} free for it to still be a promotion.",
|
|
135
|
+
freeUnitsTargetRequired: "Pick at least one product for the promotion.",
|
|
136
|
+
codeRequired: "Enter the coupon code the customer will type.",
|
|
137
|
+
categoryTargetRequired: "Select at least one category for this discount.",
|
|
138
|
+
itemTargetRequired: "Select at least one product for this discount.",
|
|
139
|
+
comboSlotsRequired: "Build the bundle: add at least one group of items.",
|
|
140
|
+
comboSlotTargetRequired: "Choose what can go in each group of the bundle.",
|
|
141
|
+
invalidComboQuantity: "Enter how many units each group takes, from 1 upwards.",
|
|
142
|
+
invalidMaxComboApplications: "Enter how many bundles fit in one order, from 1 upwards.",
|
|
143
|
+
endsBeforeStarts: "The end date must be after the start date.",
|
|
144
|
+
},
|
|
145
|
+
combo: {
|
|
146
|
+
title: "Build the bundle",
|
|
147
|
+
hint: "Each group is one part of the bundle: how many units it takes, and what can go in it.",
|
|
148
|
+
addSlot: "Add group",
|
|
149
|
+
removeSlot: "Remove group {position}",
|
|
150
|
+
slot: "Group {position}",
|
|
151
|
+
quantity: "Quantity",
|
|
152
|
+
// `{collection}` is the HOST's word for what is being picked, so the
|
|
153
|
+
// sentence is built around it rather than naming a noun of its own.
|
|
154
|
+
pick: "{collection} in this group",
|
|
155
|
+
empty: "No groups yet. Add the first one to build the bundle.",
|
|
156
|
+
summary: "The bundle takes {units} items across {groups} groups.",
|
|
157
|
+
freeUnitsTitle: "Buy more, pay less",
|
|
158
|
+
freeUnitsHint:
|
|
159
|
+
'Choose the products, how many the customer takes and how many are free — the classic "buy 3, pay for 2".',
|
|
160
|
+
buyQuantity: "The customer takes",
|
|
161
|
+
freeUnitsPick: "{collection} in the promotion",
|
|
162
|
+
freeUnitsSummary: "Take {units}, pay for {paid}.",
|
|
163
|
+
},
|
|
164
|
+
targets: {
|
|
165
|
+
pick: "Discounted {collection}",
|
|
166
|
+
search: "Search {collection}…",
|
|
167
|
+
required: "Select at least one option.",
|
|
168
|
+
},
|
|
169
|
+
actions: {
|
|
170
|
+
menu: "Actions",
|
|
171
|
+
edit: "Edit",
|
|
172
|
+
delete: "Delete",
|
|
173
|
+
deleteTitle: "Delete this discount?",
|
|
174
|
+
deleteDescription: "It stops applying in the storefront and cannot be restored.",
|
|
175
|
+
deleteManyTitle: "Delete {count} discounts?",
|
|
176
|
+
deleteManyDescription: "They stop applying in the storefront and cannot be restored.",
|
|
177
|
+
deleteFailed: "Could not delete the discount.",
|
|
178
|
+
actionFailed: "Could not complete the action",
|
|
179
|
+
actionFailedDismiss: "Dismiss",
|
|
180
|
+
},
|
|
181
|
+
card: {
|
|
182
|
+
paused: "Paused",
|
|
183
|
+
ruleHeading: "Rule",
|
|
184
|
+
targetsHeading: "Targets",
|
|
185
|
+
wholeOrder: "the whole order",
|
|
186
|
+
oneTarget: "1 target",
|
|
187
|
+
manyTargets: "{count} targets",
|
|
188
|
+
withCode: "code {code}",
|
|
189
|
+
noTargets: "No targets",
|
|
190
|
+
usage: "Uses",
|
|
191
|
+
minSubtotal: "Minimum order",
|
|
192
|
+
perBuyerLimit: "Limit per customer",
|
|
193
|
+
unlimited: "No limit",
|
|
194
|
+
},
|
|
195
|
+
confirmAction: EN_US_CONFIRM_ACTION_COPY,
|
|
196
|
+
dataViews: EN_US_DATA_VIEWS_COPY,
|
|
197
|
+
categorySelect: EN_US_CATEGORY_SELECT_COPY,
|
|
198
|
+
autocomplete: EN_US_AUTOCOMPLETE_COPY,
|
|
199
|
+
};
|
package/src/react/index.ts
CHANGED
package/src/server/copy.ts
CHANGED
|
@@ -98,3 +98,31 @@ export function missingServerCopy(copy: DiscountsServerCopy | undefined): string
|
|
|
98
98
|
if (copy === undefined) return [...COPY_KEYS];
|
|
99
99
|
return COPY_KEYS.filter((key) => typeof copy[key] !== "string" || copy[key].trim() === "");
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the config field takes once its copy can follow a reader.
|
|
104
|
+
*
|
|
105
|
+
* Declared here rather than imported from `@12-apps/i18n`: this package must
|
|
106
|
+
* stay liftable into a repo that has never heard of it, so the two agree
|
|
107
|
+
* STRUCTURALLY and nothing forces the dependency. The context is deliberately
|
|
108
|
+
* loose — a raw tag off `WireRequest.locale`, unnarrowed — because matching it
|
|
109
|
+
* is the host resolver's job.
|
|
110
|
+
*/
|
|
111
|
+
export type DiscountsCopyResolver<T> = (context: { readonly locale?: string | null }) => T;
|
|
112
|
+
export type DiscountsCopySource<T> = T | DiscountsCopyResolver<T>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The copy a field is offering, at the moment it is needed.
|
|
116
|
+
*
|
|
117
|
+
* Call this where the sentence is USED — a factory that resolves once and keeps
|
|
118
|
+
* the result has re-frozen the language into its mount, and a single-locale
|
|
119
|
+
* host cannot tell the difference.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveDiscountsCopy<T>(
|
|
122
|
+
source: DiscountsCopySource<T>,
|
|
123
|
+
locale: string | undefined,
|
|
124
|
+
): T {
|
|
125
|
+
return typeof source === "function"
|
|
126
|
+
? (source as DiscountsCopyResolver<T>)({ locale })
|
|
127
|
+
: source;
|
|
128
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DiscountsServerCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The en-US pack — a NAMED export a host passes by hand
|
|
5
|
+
* (`copy: EN_US_DISCOUNTS_SERVER_COPY`), never a default.
|
|
6
|
+
*
|
|
7
|
+
* Every sentence names what the operator must change, because these are form
|
|
8
|
+
* errors: the route reports them with the offending FIELD alongside, so an
|
|
9
|
+
* admin form can paint that input rather than only flashing a banner. A
|
|
10
|
+
* translation that generalised them ("invalid data") would keep the field and
|
|
11
|
+
* lose the instruction.
|
|
12
|
+
*/
|
|
13
|
+
export const EN_US_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
|
|
14
|
+
invalidQuery: "Invalid query parameters.",
|
|
15
|
+
notFound: "Discount not found.",
|
|
16
|
+
invalidPercent: "Enter a percentage greater than 0 and at most 100.",
|
|
17
|
+
invalidAmount: "Enter a discount amount greater than zero.",
|
|
18
|
+
codeRequired: "Enter the coupon code the customer will type.",
|
|
19
|
+
categoryTargetRequired: "Select at least one category for this discount.",
|
|
20
|
+
itemTargetRequired: "Select at least one product for this discount.",
|
|
21
|
+
// The format is the WIRE's, not a locale's: the route parses YYYY-MM-DD
|
|
22
|
+
// whichever language reports the failure, so naming any other order here
|
|
23
|
+
// would send an operator to type something the endpoint rejects.
|
|
24
|
+
invalidDate: "Invalid date. Use the format YYYY-MM-DD.",
|
|
25
|
+
endsBeforeStarts: "The end date must be after the start date.",
|
|
26
|
+
invalidMinSubtotal: "The minimum order must be greater than zero (or left blank).",
|
|
27
|
+
invalidUsageLimit: "The usage limit must be greater than zero (or left blank).",
|
|
28
|
+
invalidPerBuyerLimit: "The per-customer limit must be greater than zero (or left blank).",
|
|
29
|
+
comboScopeRequired: "Bundle price and free items only apply to bundle discounts.",
|
|
30
|
+
invalidComboSlots: "A bundle needs at least one group of items.",
|
|
31
|
+
comboTargetRequired: "Select at least one product or category for each group of the bundle.",
|
|
32
|
+
invalidComboQuantity: "Enter a quantity greater than zero for each group of the bundle.",
|
|
33
|
+
invalidBundlePrice: "Enter the bundle price, greater than zero.",
|
|
34
|
+
invalidFreeUnits: "Enter how many items are free, greater than zero.",
|
|
35
|
+
freeUnitsExceedCombo: "The bundle has to charge for at least one item.",
|
|
36
|
+
invalidMaxComboApplications:
|
|
37
|
+
"The bundles-per-cart limit must be greater than zero (or left blank).",
|
|
38
|
+
foreignTarget: "Select categories and products belonging to this store.",
|
|
39
|
+
};
|
package/src/server/routes.ts
CHANGED
|
@@ -9,7 +9,12 @@ import {
|
|
|
9
9
|
loadTargetGroups,
|
|
10
10
|
type DiscountableCollection,
|
|
11
11
|
} from "./collections";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
missingServerCopy,
|
|
14
|
+
resolveDiscountsCopy,
|
|
15
|
+
type DiscountsCopySource,
|
|
16
|
+
type DiscountsServerCopy,
|
|
17
|
+
} from "./copy";
|
|
13
18
|
import { logWrite, observed, type DiscountsLogger } from "./logging";
|
|
14
19
|
import { listDiscountsQuery } from "./mcp";
|
|
15
20
|
import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
|
|
@@ -51,8 +56,14 @@ export type DiscountRoute = WireRoute<DiscountsActor>;
|
|
|
51
56
|
export interface DiscountsApiConfig {
|
|
52
57
|
/** Where the rows are — see {@link DiscountStore}. */
|
|
53
58
|
store: DiscountStore;
|
|
54
|
-
/**
|
|
55
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Every sentence this surface answers a human with. Required, no defaults.
|
|
61
|
+
*
|
|
62
|
+
* A bilingual host passes a RESOLVER instead — `localeCopy(DISCOUNTS_SERVER_COPY)`
|
|
63
|
+
* — and each request is answered in its own caller's language. The plain
|
|
64
|
+
* value stays legal, so a host with one audience changes nothing.
|
|
65
|
+
*/
|
|
66
|
+
copy: DiscountsCopySource<DiscountsServerCopy>;
|
|
56
67
|
/**
|
|
57
68
|
* Where this surface says what it did. Required, and for the same reason
|
|
58
69
|
* `copy` is: a default would be a no-op, and a no-op default is exactly the
|
|
@@ -114,11 +125,16 @@ function acknowledged(id: string): WireResponse {
|
|
|
114
125
|
* fold is what narrows the targets to the scope — checking the raw body would
|
|
115
126
|
* reject ids a COMBO-scoped write was about to drop anyway.
|
|
116
127
|
*/
|
|
117
|
-
async function prepareWrite(
|
|
128
|
+
async function prepareWrite(
|
|
129
|
+
config: DiscountsApiConfig,
|
|
130
|
+
copy: DiscountsServerCopy,
|
|
131
|
+
clientId: string,
|
|
132
|
+
body: unknown,
|
|
133
|
+
) {
|
|
118
134
|
const input = toDiscountWriteInput(body as DiscountWriteBody);
|
|
119
|
-
const write = { scalars: toDiscountScalars(input,
|
|
135
|
+
const write = { scalars: toDiscountScalars(input, copy), targets: targetsForScope(input) };
|
|
120
136
|
if (config.collections) {
|
|
121
|
-
await assertTargetsOwned(config.collections, clientId, write.targets,
|
|
137
|
+
await assertTargetsOwned(config.collections, clientId, write.targets, copy.foreignTarget);
|
|
122
138
|
}
|
|
123
139
|
return write;
|
|
124
140
|
}
|
|
@@ -160,10 +176,12 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
160
176
|
method: "GET",
|
|
161
177
|
path: "/discounts",
|
|
162
178
|
permission: DISCOUNTS_READ,
|
|
163
|
-
handle: endpoint(config, "GET /discounts", async ({ actor, query }) => {
|
|
179
|
+
handle: endpoint(config, "GET /discounts", async ({ actor, query, locale }) => {
|
|
164
180
|
const parsed = schema.safeParse(query);
|
|
165
181
|
if (!parsed.success) {
|
|
166
|
-
|
|
182
|
+
// Worded per request, from the tag the adapter put on it — never at the
|
|
183
|
+
// mount, which ran once at boot.
|
|
184
|
+
return { status: 400, body: { error: resolveDiscountsCopy(config.copy, locale).invalidQuery } };
|
|
167
185
|
}
|
|
168
186
|
// Returned as-is rather than wrapped: the page IS the `{ data,
|
|
169
187
|
// pagination }` envelope the advertised response describes, and wrapping
|
|
@@ -207,9 +225,11 @@ function readRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
207
225
|
method: "GET",
|
|
208
226
|
path: "/discounts/:id",
|
|
209
227
|
permission: DISCOUNTS_READ,
|
|
210
|
-
handle: endpoint(config, "GET /discounts/:id", async ({ actor, params }) => {
|
|
228
|
+
handle: endpoint(config, "GET /discounts/:id", async ({ actor, params, locale }) => {
|
|
211
229
|
const record = await config.store.get(actor.clientId, String(params.id));
|
|
212
|
-
if (record === null)
|
|
230
|
+
if (record === null) {
|
|
231
|
+
return { status: 404, body: { error: resolveDiscountsCopy(config.copy, locale).notFound } };
|
|
232
|
+
}
|
|
213
233
|
return ok({ data: record as DiscountRecord });
|
|
214
234
|
}),
|
|
215
235
|
};
|
|
@@ -221,8 +241,8 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
221
241
|
path: "/discounts",
|
|
222
242
|
permission: DISCOUNTS_WRITE,
|
|
223
243
|
handle: endpoint(config, "POST /discounts", async (request) => {
|
|
224
|
-
const { actor, body } = request;
|
|
225
|
-
const write = await prepareWrite(config, actor.clientId, body);
|
|
244
|
+
const { actor, body, locale } = request;
|
|
245
|
+
const write = await prepareWrite(config, resolveDiscountsCopy(config.copy, locale), actor.clientId, body);
|
|
226
246
|
const created = await config.store.create(actor.clientId, write);
|
|
227
247
|
logWrite(config.logger, "created", request, created.id);
|
|
228
248
|
return ok({ data: created });
|
|
@@ -236,9 +256,9 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
236
256
|
path: "/discounts/:id",
|
|
237
257
|
permission: DISCOUNTS_WRITE,
|
|
238
258
|
handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
|
|
239
|
-
const { actor, params, body } = request;
|
|
259
|
+
const { actor, params, body, locale } = request;
|
|
240
260
|
const id = String(params.id);
|
|
241
|
-
const write = await prepareWrite(config, actor.clientId, body);
|
|
261
|
+
const write = await prepareWrite(config, resolveDiscountsCopy(config.copy, locale), actor.clientId, body);
|
|
242
262
|
await config.store.update(actor.clientId, id, write);
|
|
243
263
|
logWrite(config.logger, "re-stated", request, id);
|
|
244
264
|
return acknowledged(id);
|
|
@@ -288,7 +308,10 @@ function assertLogger(logger: DiscountsLogger | undefined): void {
|
|
|
288
308
|
export function createApiDiscounts(config: DiscountsApiConfig): {
|
|
289
309
|
routes: readonly DiscountRoute[];
|
|
290
310
|
} {
|
|
291
|
-
|
|
311
|
+
// Validated against the DEFAULT rendering — a resolver asked for no locale.
|
|
312
|
+
// A host that forgot a sentence should fail at the mount, as it always did,
|
|
313
|
+
// rather than at whichever request first needs the missing one.
|
|
314
|
+
const missing = missingServerCopy(resolveDiscountsCopy(config.copy, undefined));
|
|
292
315
|
if (missing.length > 0) {
|
|
293
316
|
throw new Error(
|
|
294
317
|
`@12-apps/discounts: createApiDiscounts is missing copy for ${missing.join(", ")} — ` +
|