@12-apps/discounts 1.13.0 → 1.15.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/README.md +10 -0
- package/package.json +3 -3
- package/prisma/discounts.prisma +3 -0
- package/prisma/migrations/20260926230000_discount_first_order_only/migration.sql +13 -0
- package/src/engine/eligibility.ts +3 -0
- package/src/engine/en-US.ts +1 -0
- package/src/engine/kinds.ts +1 -0
- package/src/engine/preview.ts +8 -0
- package/src/engine/pt-BR.ts +1 -0
- package/src/engine/teaser.ts +3 -0
- package/src/engine/types.ts +17 -0
- package/src/react/api.ts +5 -0
- package/src/react/copy.ts +5 -0
- package/src/react/discount-form.tsx +15 -0
- package/src/react/discounts-screen.tsx +2 -1
- package/src/react/en-US.ts +4 -0
- package/src/react/first-order-field.tsx +40 -0
- package/src/react/pt-BR.ts +4 -0
- package/src/server/mcp.ts +2 -0
- package/src/server/store.ts +5 -0
- package/src/server/validate.ts +19 -0
package/README.md
CHANGED
|
@@ -474,6 +474,16 @@ language is a line in the host's diff rather than a silence.
|
|
|
474
474
|
resolved (`{ clientId }`); authentication, tenant resolution, RBAC tiers and
|
|
475
475
|
plan gates stay where they belong.
|
|
476
476
|
|
|
477
|
+
**Who is buying for the first time.** A rule saved with `firstOrderOnly`
|
|
478
|
+
(FUT-2825) applies only when the host sets `buyerIsFirstTime: true` on it —
|
|
479
|
+
this package never reads an order, so "has this buyer bought here before" is
|
|
480
|
+
the host's answer, the way `buyerUsageCount` already is. Unknown (`null` or
|
|
481
|
+
omitted: an anonymous cart, a cached menu) is NOT first-time, on purpose: a
|
|
482
|
+
discount the cart shows and checkout then takes back is worse than one that
|
|
483
|
+
appears once the buyer is known. So a first-purchase rule is never badged or
|
|
484
|
+
teased on a catalog card, and two concurrent first orders by one buyer can both
|
|
485
|
+
pass, as with `perBuyerLimit`. Combos keep their own rules and never carry it.
|
|
486
|
+
|
|
477
487
|
**The words on a pill.** The validity filter a promotions grid usually wants —
|
|
478
488
|
running / scheduled / ended — compares two nullable columns against "now",
|
|
479
489
|
which no `filterableField` expresses, and its values are words a host chose. So
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/e2e/**"
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"typecheck": "tsc --noEmit"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@12-apps/app-shell": "^5.
|
|
44
|
+
"@12-apps/app-shell": "^5.11.0",
|
|
45
45
|
"@12-apps/forms-core": "^1.21.0",
|
|
46
46
|
"@12-apps/shared-helpers": "^2.1.0",
|
|
47
|
-
"@12-apps/ui": "^6.
|
|
47
|
+
"@12-apps/ui": "^6.43.0",
|
|
48
48
|
"@mui/icons-material": "^6.5.0",
|
|
49
49
|
"zod": "^4.3.5"
|
|
50
50
|
},
|
package/prisma/discounts.prisma
CHANGED
|
@@ -109,6 +109,9 @@ model Discount {
|
|
|
109
109
|
// Redemptions allowed across every buyer, and per buyer. Null = no cap.
|
|
110
110
|
usageLimit Int? @map("usage_limit")
|
|
111
111
|
perBuyerLimit Int? @map("per_buyer_limit")
|
|
112
|
+
// Only a buyer's FIRST purchase at the store (FUT-2825). Who that is, is the
|
|
113
|
+
// host's answer: this package never reads an order.
|
|
114
|
+
firstOrderOnly Boolean @default(false) @map("first_order_only")
|
|
112
115
|
usageCount Int @default(0) @map("usage_count")
|
|
113
116
|
// When false this rule stacks with nothing: if it wins it is the only one
|
|
114
117
|
// applied to the order ("best single discount wins").
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- @12-apps/discounts: the first-purchase condition (FUT-2825).
|
|
2
|
+
--
|
|
3
|
+
-- "Só na primeira compra do cliente nesta loja" — a rule the engine screens
|
|
4
|
+
-- against the host's answer to "has this buyer bought here before".
|
|
5
|
+
--
|
|
6
|
+
-- ── PURELY ADDITIVE ───────────────────────────────────────────────────────
|
|
7
|
+
-- One NOT NULL column with a constant default, no backfill: FALSE is exactly
|
|
8
|
+
-- what every existing row already means, so this migration cannot change the
|
|
9
|
+
-- price of anything. Postgres adds a column with a constant default without
|
|
10
|
+
-- rewriting the table.
|
|
11
|
+
--
|
|
12
|
+
-- Replay-safe like its predecessors: `IF NOT EXISTS`.
|
|
13
|
+
ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "first_order_only" BOOLEAN NOT NULL DEFAULT false;
|
|
@@ -152,6 +152,9 @@ function screenLimits(rule: DiscountRule, ctx: ScreenContext): DiscountRejection
|
|
|
152
152
|
if (rule.perBuyerLimit !== null && rule.buyerUsageCount >= rule.perBuyerLimit) {
|
|
153
153
|
return "BUYER_LIMIT_REACHED";
|
|
154
154
|
}
|
|
155
|
+
// Advisory in the same way `buyerUsageCount` is: two concurrent first orders
|
|
156
|
+
// by one buyer can both pass (FUT-2825).
|
|
157
|
+
if (rule.firstOrderOnly === true && rule.buyerIsFirstTime !== true) return "NOT_FIRST_ORDER";
|
|
155
158
|
if (rule.minSubtotalCents !== null && ctx.subtotalCents < rule.minSubtotalCents) {
|
|
156
159
|
return "MIN_SUBTOTAL_NOT_MET";
|
|
157
160
|
}
|
package/src/engine/en-US.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const EN_US_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
|
|
|
29
29
|
MIN_SUBTOTAL_NOT_MET: `This coupon needs a minimum order of ${MIN_SUBTOTAL_TOKEN}.`,
|
|
30
30
|
USAGE_LIMIT_REACHED: "This coupon has reached its usage limit.",
|
|
31
31
|
BUYER_LIMIT_REACHED: "You have already used this coupon.",
|
|
32
|
+
NOT_FIRST_ORDER: "Only valid on your first purchase at this store.",
|
|
32
33
|
NO_ELIGIBLE_ITEMS: "This coupon does not apply to the items in your cart.",
|
|
33
34
|
COMBO_NOT_MATCHED: "Your cart does not have every item in this bundle yet.",
|
|
34
35
|
ZERO_VALUE: "This coupon does not apply to the items in your cart.",
|
package/src/engine/kinds.ts
CHANGED
package/src/engine/preview.ts
CHANGED
|
@@ -60,6 +60,14 @@ import type { DiscountCartLine, DiscountRule } from "./types";
|
|
|
60
60
|
* screens the same rule against a real `buyerUsageCount` and reports
|
|
61
61
|
* `BUYER_LIMIT_REACHED`, before a single cent is charged.
|
|
62
62
|
*
|
|
63
|
+
* `firstOrderOnly` (FUT-2825) goes the OTHER way, deliberately. A per-buyer
|
|
64
|
+
* cap is exhausted by a few; "first purchase only" excludes EVERY returning
|
|
65
|
+
* buyer, so an optimistic badge would be a wrong price for most of the people
|
|
66
|
+
* reading the menu. The screen settles it without a special case: a catalog
|
|
67
|
+
* read passes no `buyerIsFirstTime`, an unknown buyer is not first-time, and
|
|
68
|
+
* `screenRule` rejects `NOT_FIRST_ORDER` — so no badge. The host advertises the
|
|
69
|
+
* promotion to the buyers it CAN name as first-time, outside the menu.
|
|
70
|
+
*
|
|
63
71
|
* Pure, like the rest of the engine: `now` is injected and the rules arrive
|
|
64
72
|
* pre-loaded once per catalog request, never per product.
|
|
65
73
|
*/
|
package/src/engine/pt-BR.ts
CHANGED
|
@@ -33,6 +33,7 @@ export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
|
|
|
33
33
|
MIN_SUBTOTAL_NOT_MET: `Este cupom exige um pedido mínimo de ${MIN_SUBTOTAL_TOKEN}.`,
|
|
34
34
|
USAGE_LIMIT_REACHED: "Este cupom já atingiu o limite de uso.",
|
|
35
35
|
BUYER_LIMIT_REACHED: "Você já usou este cupom.",
|
|
36
|
+
NOT_FIRST_ORDER: "Só vale na primeira compra nesta loja.",
|
|
36
37
|
NO_ELIGIBLE_ITEMS: "Este cupom não vale para os itens do seu carrinho.",
|
|
37
38
|
COMBO_NOT_MATCHED: "Seu carrinho ainda não tem todos os itens deste combo.",
|
|
38
39
|
ZERO_VALUE: "Este cupom não vale para os itens do seu carrinho.",
|
package/src/engine/teaser.ts
CHANGED
|
@@ -102,6 +102,9 @@ function isTeasable(rule: DiscountRule, now: Date, localNow: LocalClock | null):
|
|
|
102
102
|
*/
|
|
103
103
|
function isTeasableShape(rule: DiscountRule): boolean {
|
|
104
104
|
if (rule.trigger !== "AUTOMATIC" || !rule.active || rule.scope === "ORDER") return false;
|
|
105
|
+
// A first-purchase rule is never teased: a card cannot know the buyer, and a
|
|
106
|
+
// returning one would be promised a price the cart refuses (FUT-2825).
|
|
107
|
+
if (rule.firstOrderOnly === true) return false;
|
|
105
108
|
return rule.schedule !== null && rule.schedule !== undefined;
|
|
106
109
|
}
|
|
107
110
|
|
package/src/engine/types.ts
CHANGED
|
@@ -121,6 +121,23 @@ export interface DiscountRule {
|
|
|
121
121
|
perBuyerLimit: number | null;
|
|
122
122
|
/** Redemptions this buyer has already PAID for. 0 for an anonymous cart. */
|
|
123
123
|
buyerUsageCount: number;
|
|
124
|
+
/**
|
|
125
|
+
* The rule applies only to a buyer's FIRST purchase at the store
|
|
126
|
+
* (FUT-2825). Omitted = false, which is every rule that predates it.
|
|
127
|
+
*
|
|
128
|
+
* OPTIONAL for the reason `schedule` is: an adopter that does not offer
|
|
129
|
+
* first-purchase promotions keeps compiling, and keeps pricing exactly as
|
|
130
|
+
* before.
|
|
131
|
+
*/
|
|
132
|
+
firstOrderOnly?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Whether THIS buyer has no purchase at the store yet — the host's answer,
|
|
135
|
+
* since only the host knows its orders. `null` or omitted = unknown (an
|
|
136
|
+
* anonymous cart, the cached menu), which is NOT first-time: a discount the
|
|
137
|
+
* cart shows and checkout then takes away is worse than one that appears
|
|
138
|
+
* once the buyer is known.
|
|
139
|
+
*/
|
|
140
|
+
buyerIsFirstTime?: boolean | null;
|
|
124
141
|
/** False ⇒ exclusive: if it wins, nothing else applies. */
|
|
125
142
|
stackable: boolean;
|
|
126
143
|
/** Deterministic tie-break key, ascending. */
|
package/src/react/api.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface DiscountWireRecord {
|
|
|
36
36
|
minSubtotalCents: number | null;
|
|
37
37
|
usageLimit: number | null;
|
|
38
38
|
perBuyerLimit: number | null;
|
|
39
|
+
/** Only a buyer's first purchase at the store (FUT-2825); absent = false. */
|
|
40
|
+
firstOrderOnly?: boolean;
|
|
39
41
|
usageCount: number;
|
|
40
42
|
stackable: boolean;
|
|
41
43
|
active: boolean;
|
|
@@ -98,6 +100,8 @@ export interface DiscountFormPayload {
|
|
|
98
100
|
usageLimit: string;
|
|
99
101
|
perBuyerLimit: string;
|
|
100
102
|
stackable: boolean;
|
|
103
|
+
/** The first-purchase switch (FUT-2825); absent = false. */
|
|
104
|
+
firstOrderOnly?: boolean;
|
|
101
105
|
active: boolean;
|
|
102
106
|
categoryIds: string[];
|
|
103
107
|
menuItemIds: string[];
|
|
@@ -182,6 +186,7 @@ export function toWriteBody(
|
|
|
182
186
|
usageLimit: toCount(input.usageLimit, formatters),
|
|
183
187
|
perBuyerLimit: toCount(input.perBuyerLimit, formatters),
|
|
184
188
|
stackable: input.stackable,
|
|
189
|
+
firstOrderOnly: input.firstOrderOnly === true,
|
|
185
190
|
active: input.active,
|
|
186
191
|
// Narrowed to the scope on the way OUT as well as on the way in: leaving a
|
|
187
192
|
// stale id list on a rule whose scope changed is how a promotion goes on
|
package/src/react/copy.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface DiscountsScreenCopy {
|
|
|
51
51
|
readonly empty: string;
|
|
52
52
|
/** The exported file's base name, without extension. */
|
|
53
53
|
readonly exportFileName: string;
|
|
54
|
+
/** The header's export trigger. */
|
|
55
|
+
readonly exportAction: string;
|
|
54
56
|
/** While the first page is loading. */
|
|
55
57
|
readonly loading: string;
|
|
56
58
|
/** The read failed: a heading, and the button that tries again. */
|
|
@@ -142,6 +144,9 @@ export interface DiscountsFormCopy {
|
|
|
142
144
|
readonly activeHint: string;
|
|
143
145
|
readonly stackable: string;
|
|
144
146
|
readonly stackableHint: string;
|
|
147
|
+
/** The first-purchase condition (FUT-2825), and what it does to the menu. */
|
|
148
|
+
readonly firstOrderOnly: string;
|
|
149
|
+
readonly firstOrderOnlyHint: string;
|
|
145
150
|
/** The banner over a form the operator must fix before it will send. */
|
|
146
151
|
readonly reviewFields: string;
|
|
147
152
|
/** The banner over a write the SERVER refused. */
|
|
@@ -15,6 +15,7 @@ import { Stack } from "@12-apps/ui/mui/Stack";
|
|
|
15
15
|
import type { DiscountSchedule } from "../engine/schedule";
|
|
16
16
|
import type { ComboRequirement } from "../engine/types";
|
|
17
17
|
|
|
18
|
+
import { FirstOrderField } from "./first-order-field";
|
|
18
19
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
19
20
|
import type { DiscountsWebCopy } from "./copy";
|
|
20
21
|
import {
|
|
@@ -51,10 +52,16 @@ import type { DiscountsFormatters } from "./format";
|
|
|
51
52
|
* `setFieldErrors` and paints the same inputs red.
|
|
52
53
|
*/
|
|
53
54
|
|
|
55
|
+
/** A stored rule's first-purchase switch (FUT-2825); off for a new rule and an older record. */
|
|
56
|
+
function savedFirstOrderOnly(editing: DiscountWireRecord | null): boolean {
|
|
57
|
+
return editing !== null && editing.firstOrderOnly === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
/** The values `total-form` cannot hold, kept beside it and merged at submit. */
|
|
55
61
|
function useNonStringState(editing: DiscountWireRecord | null) {
|
|
56
62
|
const [active, setActive] = useState<boolean>(editing?.active ?? true);
|
|
57
63
|
const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
|
|
64
|
+
const [firstOrderOnly, setFirstOrderOnly] = useState<boolean>(() => savedFirstOrderOnly(editing));
|
|
58
65
|
const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
|
|
59
66
|
const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
|
|
60
67
|
// The combo's groups are the third array, and the one whose ORDER matters:
|
|
@@ -81,6 +88,7 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
81
88
|
return {
|
|
82
89
|
active,
|
|
83
90
|
stackable,
|
|
91
|
+
firstOrderOnly,
|
|
84
92
|
categoryIds,
|
|
85
93
|
menuItemIds,
|
|
86
94
|
comboRequirements,
|
|
@@ -88,6 +96,7 @@ function useNonStringState(editing: DiscountWireRecord | null) {
|
|
|
88
96
|
schedule,
|
|
89
97
|
setActive,
|
|
90
98
|
setStackable,
|
|
99
|
+
setFirstOrderOnly,
|
|
91
100
|
setCategoryIds,
|
|
92
101
|
setMenuItemIds,
|
|
93
102
|
setComboRequirements,
|
|
@@ -153,6 +162,7 @@ function makeSubmit(
|
|
|
153
162
|
...typeAndScopeFor(values),
|
|
154
163
|
...targets,
|
|
155
164
|
stackable: extra.stackable,
|
|
165
|
+
firstOrderOnly: extra.firstOrderOnly,
|
|
156
166
|
active: extra.active,
|
|
157
167
|
};
|
|
158
168
|
const result = editing
|
|
@@ -211,6 +221,11 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
211
221
|
onScheduleEnabledChange={extra.setScheduleEnabled}
|
|
212
222
|
/>
|
|
213
223
|
<LimitFields copy={copy} currencyField={currencyField} />
|
|
224
|
+
<FirstOrderField
|
|
225
|
+
copy={copy}
|
|
226
|
+
checked={extra.firstOrderOnly}
|
|
227
|
+
onChange={extra.setFirstOrderOnly}
|
|
228
|
+
/>
|
|
214
229
|
<DiscountSwitches
|
|
215
230
|
copy={copy}
|
|
216
231
|
active={extra.active}
|
|
@@ -115,11 +115,12 @@ function HeaderControls({
|
|
|
115
115
|
}): JSX.Element {
|
|
116
116
|
return (
|
|
117
117
|
<>
|
|
118
|
-
<Dashboard.Info title={copy.screen.aboutTitle}>
|
|
118
|
+
<Dashboard.Info title={copy.screen.aboutTitle} ariaLabel={copy.screen.aboutTitle}>
|
|
119
119
|
{copy.screen.aboutBody}
|
|
120
120
|
</Dashboard.Info>
|
|
121
121
|
<Dashboard.Spacer />
|
|
122
122
|
<Dashboard.Export
|
|
123
|
+
label={copy.screen.exportAction}
|
|
123
124
|
formats={[
|
|
124
125
|
{ id: "csv", label: "CSV (.csv)" },
|
|
125
126
|
{ id: "json", label: "JSON (.json)" },
|
package/src/react/en-US.ts
CHANGED
|
@@ -31,6 +31,7 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
31
31
|
empty: "No discounts yet.",
|
|
32
32
|
// A FILENAME, not a sentence: it is what the export lands on disk as.
|
|
33
33
|
exportFileName: "discounts",
|
|
34
|
+
exportAction: "Export",
|
|
34
35
|
loading: "Loading discounts…",
|
|
35
36
|
loadFailed: "Could not load the discounts",
|
|
36
37
|
retry: "Try again",
|
|
@@ -167,6 +168,9 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
167
168
|
stackable: "Stackable",
|
|
168
169
|
stackableHint:
|
|
169
170
|
"When off, this promotion is exclusive: if it wins, no other one is applied.",
|
|
171
|
+
firstOrderOnly: "Only on the customer's first purchase at this store",
|
|
172
|
+
firstOrderOnlyHint:
|
|
173
|
+
"Only for customers who have never bought here. Menu prices do not change: the discount shows in the cart of whoever can use it.",
|
|
170
174
|
reviewFields: "Check the highlighted fields.",
|
|
171
175
|
saveFailed: "Could not save the discount",
|
|
172
176
|
nameRequired: "Give the promotion a name.",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
|
|
3
|
+
import { Switch } from "@12-apps/ui/form/Switch";
|
|
4
|
+
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
5
|
+
|
|
6
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
7
|
+
import { isComboKind } from "./form-kind";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* "Só na primeira compra do cliente nesta loja" (FUT-2825).
|
|
11
|
+
*
|
|
12
|
+
* Offered at the ORDER, CATEGORY and ITEM scopes only. A combo keeps its own
|
|
13
|
+
* rules — "leve 3, pague 2" is a deal about the basket, not about who is
|
|
14
|
+
* buying — so the switch is not drawn for one, and the server folds a combo's
|
|
15
|
+
* value to false whatever a body sends.
|
|
16
|
+
*
|
|
17
|
+
* Its state is the parent's, like the two switches below it, so submit can
|
|
18
|
+
* send it: `total-form` holds strings.
|
|
19
|
+
*/
|
|
20
|
+
export function FirstOrderField({
|
|
21
|
+
copy,
|
|
22
|
+
checked,
|
|
23
|
+
onChange,
|
|
24
|
+
}: {
|
|
25
|
+
copy: DiscountsWebCopy;
|
|
26
|
+
checked: boolean;
|
|
27
|
+
onChange: (next: boolean) => void;
|
|
28
|
+
}): JSX.Element | null {
|
|
29
|
+
const { values } = useFormContext();
|
|
30
|
+
if (isComboKind(values.kind ?? "PERCENTAGE") || values.scope === "COMBO") return null;
|
|
31
|
+
return (
|
|
32
|
+
<Switch
|
|
33
|
+
checked={checked}
|
|
34
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
35
|
+
label={copy.form.firstOrderOnly}
|
|
36
|
+
description={copy.form.firstOrderOnlyHint}
|
|
37
|
+
dataTestId="discount-first-order-only"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/react/pt-BR.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
26
26
|
create: "Novo desconto",
|
|
27
27
|
empty: "Nenhum desconto cadastrado.",
|
|
28
28
|
exportFileName: "descontos",
|
|
29
|
+
exportAction: "Exportar",
|
|
29
30
|
loading: "Carregando descontos…",
|
|
30
31
|
loadFailed: "Não foi possível carregar os descontos",
|
|
31
32
|
retry: "Tentar novamente",
|
|
@@ -157,6 +158,9 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
|
157
158
|
stackable: "Acumulável",
|
|
158
159
|
stackableHint:
|
|
159
160
|
"Quando desligado, esta promoção é exclusiva: se vencer, nenhuma outra é aplicada.",
|
|
161
|
+
firstOrderOnly: "Só na primeira compra do cliente nesta loja",
|
|
162
|
+
firstOrderOnlyHint:
|
|
163
|
+
"Vale só para quem nunca comprou aqui. O preço do cardápio não muda: o desconto aparece no carrinho de quem pode usar.",
|
|
160
164
|
reviewFields: "Revise os campos destacados.",
|
|
161
165
|
saveFailed: "Não foi possível salvar o desconto",
|
|
162
166
|
nameRequired: "Informe o nome da promoção.",
|
package/src/server/mcp.ts
CHANGED
|
@@ -127,6 +127,7 @@ export const discountSchema = z.object({
|
|
|
127
127
|
minSubtotalCents: z.number().int().nullable(),
|
|
128
128
|
usageLimit: z.number().int().nullable(),
|
|
129
129
|
perBuyerLimit: z.number().int().nullable(),
|
|
130
|
+
firstOrderOnly: z.boolean().optional(),
|
|
130
131
|
usageCount: z.number().int(),
|
|
131
132
|
stackable: z.boolean(),
|
|
132
133
|
active: z.boolean(),
|
|
@@ -185,6 +186,7 @@ const discountWriteShape = {
|
|
|
185
186
|
minSubtotalCents: z.number().int().positive().nullable().optional(),
|
|
186
187
|
usageLimit: z.number().int().positive().nullable().optional(),
|
|
187
188
|
perBuyerLimit: z.number().int().positive().nullable().optional(),
|
|
189
|
+
firstOrderOnly: z.boolean().optional(),
|
|
188
190
|
stackable: z.boolean(),
|
|
189
191
|
active: z.boolean(),
|
|
190
192
|
categoryIds: z.array(z.string().min(1)).optional(),
|
package/src/server/store.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface DiscountRecord {
|
|
|
44
44
|
minSubtotalCents: number | null;
|
|
45
45
|
usageLimit: number | null;
|
|
46
46
|
perBuyerLimit: number | null;
|
|
47
|
+
/**
|
|
48
|
+
* Only a buyer's first purchase at the store (FUT-2825). OPTIONAL for the
|
|
49
|
+
* reason `schedule` is: a host without the column answers the same record.
|
|
50
|
+
*/
|
|
51
|
+
firstOrderOnly?: boolean;
|
|
47
52
|
usageCount: number;
|
|
48
53
|
stackable: boolean;
|
|
49
54
|
active: boolean;
|
package/src/server/validate.ts
CHANGED
|
@@ -63,6 +63,11 @@ export interface DiscountWriteInput {
|
|
|
63
63
|
minSubtotalCents: number | null;
|
|
64
64
|
usageLimit: number | null;
|
|
65
65
|
perBuyerLimit: number | null;
|
|
66
|
+
/**
|
|
67
|
+
* Only a buyer's FIRST purchase at the store (FUT-2825). Optional for the
|
|
68
|
+
* reason `schedule` is; omitted = false.
|
|
69
|
+
*/
|
|
70
|
+
firstOrderOnly?: boolean;
|
|
66
71
|
stackable: boolean;
|
|
67
72
|
active: boolean;
|
|
68
73
|
/** Target category ids — read only when `scope === "CATEGORY"`. */
|
|
@@ -235,6 +240,8 @@ export interface DiscountScalars {
|
|
|
235
240
|
minSubtotalCents: number | null;
|
|
236
241
|
usageLimit: number | null;
|
|
237
242
|
perBuyerLimit: number | null;
|
|
243
|
+
/** Only a buyer's first purchase at the store. Always false on a COMBO. */
|
|
244
|
+
firstOrderOnly: boolean;
|
|
238
245
|
stackable: boolean;
|
|
239
246
|
active: boolean;
|
|
240
247
|
}
|
|
@@ -292,11 +299,21 @@ export function toDiscountScalars(
|
|
|
292
299
|
minSubtotalCents: input.minSubtotalCents,
|
|
293
300
|
usageLimit: input.usageLimit,
|
|
294
301
|
perBuyerLimit: input.perBuyerLimit,
|
|
302
|
+
firstOrderOnly: firstOrderOnlyOf(input),
|
|
295
303
|
stackable: input.stackable,
|
|
296
304
|
active: input.active,
|
|
297
305
|
};
|
|
298
306
|
}
|
|
299
307
|
|
|
308
|
+
/**
|
|
309
|
+
* A combo keeps its own rules (FUT-2825): the form never offers the condition
|
|
310
|
+
* there, and a body that sends it anyway is folded to false, the way a code
|
|
311
|
+
* left behind by flipping the trigger is folded to null.
|
|
312
|
+
*/
|
|
313
|
+
function firstOrderOnlyOf(input: DiscountWriteInput): boolean {
|
|
314
|
+
return input.scope !== "COMBO" && input.firstOrderOnly === true;
|
|
315
|
+
}
|
|
316
|
+
|
|
300
317
|
/**
|
|
301
318
|
* The validated wire body as the routes receive it — every optional field is
|
|
302
319
|
* `T | null | undefined` because the wire lets a client omit what does not
|
|
@@ -322,6 +339,7 @@ export interface DiscountWriteBody {
|
|
|
322
339
|
minSubtotalCents?: number | null;
|
|
323
340
|
usageLimit?: number | null;
|
|
324
341
|
perBuyerLimit?: number | null;
|
|
342
|
+
firstOrderOnly?: boolean;
|
|
325
343
|
stackable: boolean;
|
|
326
344
|
active: boolean;
|
|
327
345
|
categoryIds?: string[];
|
|
@@ -357,6 +375,7 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
|
|
|
357
375
|
minSubtotalCents: orNull(body.minSubtotalCents),
|
|
358
376
|
usageLimit: orNull(body.usageLimit),
|
|
359
377
|
perBuyerLimit: orNull(body.perBuyerLimit),
|
|
378
|
+
firstOrderOnly: body.firstOrderOnly === true,
|
|
360
379
|
stackable: body.stackable,
|
|
361
380
|
active: body.active,
|
|
362
381
|
categoryIds: orEmpty(body.categoryIds),
|