@12-apps/discounts 1.5.0 → 1.6.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 +35 -6
- package/package.json +3 -3
- package/src/engine/combo-match.ts +21 -0
- package/src/engine/combo-offer.ts +26 -0
- package/src/index.ts +7 -0
- package/src/react/copy.ts +30 -0
- package/src/react/discount-form-fields.tsx +128 -61
- package/src/react/discount-form-validate.ts +172 -0
- package/src/react/discount-form-values.ts +90 -0
- package/src/react/discount-form.tsx +16 -209
- package/src/react/discount-target-picker.tsx +31 -14
- package/src/react/form-kind.ts +130 -0
- package/src/react/free-units-builder.tsx +180 -0
- package/src/react/pt-BR.ts +18 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ComboRequirement } from "../engine/types";
|
|
2
|
+
|
|
3
|
+
import { comboUnits } from "./combo-slot-builder";
|
|
4
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
5
|
+
import type { DiscountFormValues } from "./discount-form-values";
|
|
6
|
+
import { isComboKind, typeAndScopeFor } from "./form-kind";
|
|
7
|
+
import type { DiscountsFormatters } from "./format";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Everything the create/edit form refuses locally, split from the form itself
|
|
11
|
+
* for the 400-line file gate — comments count toward it, and the container plus
|
|
12
|
+
* these would not fit.
|
|
13
|
+
*
|
|
14
|
+
* Every rule here is ALSO the server's (`../server/validate-combo.ts` and the
|
|
15
|
+
* route's own schema). This half exists to attach the refusal to the input the
|
|
16
|
+
* operator is looking at, never to be the authority: the surface re-validates,
|
|
17
|
+
* and a server refusal comes back through `setFieldErrors` and paints the same
|
|
18
|
+
* inputs red.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A whole number above zero, as typed. `null` when it is neither. */
|
|
22
|
+
function positiveCount(typed: string): number | null {
|
|
23
|
+
const trimmed = typed.trim();
|
|
24
|
+
if (trimmed === "") return null;
|
|
25
|
+
const value = Number(trimmed);
|
|
26
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A money field that has to hold something above zero. */
|
|
30
|
+
function checkMoney(
|
|
31
|
+
typed: string,
|
|
32
|
+
field: string,
|
|
33
|
+
message: string,
|
|
34
|
+
formatters: DiscountsFormatters,
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
const amount = formatters.parseDecimal(typed);
|
|
37
|
+
return amount === null || amount <= 0 ? { [field]: message } : {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The free count, which is the one reward bounded by another field.
|
|
42
|
+
*
|
|
43
|
+
* "Take 3, three free" is a giveaway rather than a promotion, so it is compared
|
|
44
|
+
* against what ONE application of the combo takes out of the cart. The ceiling
|
|
45
|
+
* is named in the message: an operator told only "too many" has to guess the
|
|
46
|
+
* number.
|
|
47
|
+
*/
|
|
48
|
+
function checkFreeUnits(
|
|
49
|
+
typed: string,
|
|
50
|
+
slots: readonly ComboRequirement[],
|
|
51
|
+
copy: DiscountsWebCopy,
|
|
52
|
+
): Record<string, string> {
|
|
53
|
+
const free = positiveCount(typed);
|
|
54
|
+
if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
|
|
55
|
+
const units = comboUnits(slots);
|
|
56
|
+
if (free >= units) {
|
|
57
|
+
return {
|
|
58
|
+
freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The rate, in the 0–100 an operator thinks in rather than basis points. */
|
|
65
|
+
function checkPercent(
|
|
66
|
+
typed: string,
|
|
67
|
+
formatters: DiscountsFormatters,
|
|
68
|
+
copy: DiscountsWebCopy,
|
|
69
|
+
): Record<string, string> {
|
|
70
|
+
const percent = formatters.parseDecimal(typed);
|
|
71
|
+
return percent === null || percent <= 0 || percent > 100
|
|
72
|
+
? { percentOff: copy.form.invalidPercent }
|
|
73
|
+
: {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The reward, checked against the input the chosen KIND actually mounted.
|
|
78
|
+
*
|
|
79
|
+
* `type` is derived rather than read: a combo's reward is a percentage or an
|
|
80
|
+
* amount, and which one it is lives in `comboReward` — so the check runs off
|
|
81
|
+
* the same mapping the submit does, and the two cannot disagree about which
|
|
82
|
+
* input holds the number.
|
|
83
|
+
*/
|
|
84
|
+
function checkValue(
|
|
85
|
+
values: DiscountFormValues,
|
|
86
|
+
slots: readonly ComboRequirement[],
|
|
87
|
+
formatters: DiscountsFormatters,
|
|
88
|
+
copy: DiscountsWebCopy,
|
|
89
|
+
): Record<string, string> {
|
|
90
|
+
const { type } = typeAndScopeFor(values);
|
|
91
|
+
if (type === "FIXED_AMOUNT") {
|
|
92
|
+
return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
|
|
93
|
+
}
|
|
94
|
+
if (type === "BUNDLE_PRICE") {
|
|
95
|
+
return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
|
|
96
|
+
}
|
|
97
|
+
if (type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
|
|
98
|
+
return checkPercent(values.percentOff, formatters, copy);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The combo half: the groups themselves, and the per-cart cap.
|
|
103
|
+
*
|
|
104
|
+
* Every rule here is also the server's (`validate-combo.ts`), and deliberately
|
|
105
|
+
* so — this half exists to attach the refusal to the input the operator is
|
|
106
|
+
* looking at, never to be the authority.
|
|
107
|
+
*/
|
|
108
|
+
function checkCombo(
|
|
109
|
+
values: DiscountFormValues,
|
|
110
|
+
slots: readonly ComboRequirement[],
|
|
111
|
+
copy: DiscountsWebCopy,
|
|
112
|
+
): Record<string, string> {
|
|
113
|
+
if (!isComboKind(values.kind)) return {};
|
|
114
|
+
// "Leve 3, pague 2" is one group of products, so it is refused in those words
|
|
115
|
+
// rather than in the multi-group builder's — same stored shape, different
|
|
116
|
+
// sentence, because the operator is looking at a different control.
|
|
117
|
+
const free = values.kind === "FREE_UNITS";
|
|
118
|
+
const empty = free ? copy.form.freeUnitsTargetRequired : copy.form.comboSlotsRequired;
|
|
119
|
+
// The refusal hangs on `kind` rather than on `scope`: a combo kind does not
|
|
120
|
+
// mount a scope toggle, so an error attributed there would paint nothing.
|
|
121
|
+
if (slots.length === 0) return { kind: empty };
|
|
122
|
+
const errors: Record<string, string> = {};
|
|
123
|
+
if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
|
|
124
|
+
errors.kind = copy.form.invalidComboQuantity;
|
|
125
|
+
} else if (
|
|
126
|
+
slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
|
|
127
|
+
) {
|
|
128
|
+
errors.kind = free ? copy.form.freeUnitsTargetRequired : copy.form.comboSlotTargetRequired;
|
|
129
|
+
}
|
|
130
|
+
if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
|
|
131
|
+
errors.maxComboApplications = copy.form.invalidMaxComboApplications;
|
|
132
|
+
}
|
|
133
|
+
return errors;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What the pickers and the builder currently hold, in one argument. */
|
|
137
|
+
export interface FormTargets {
|
|
138
|
+
categoryIds: string[];
|
|
139
|
+
menuItemIds: string[];
|
|
140
|
+
comboRequirements: ComboRequirement[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The cross-field rules a per-input schema cannot express. */
|
|
144
|
+
export function validate(
|
|
145
|
+
values: DiscountFormValues,
|
|
146
|
+
targets: FormTargets,
|
|
147
|
+
formatters: DiscountsFormatters,
|
|
148
|
+
copy: DiscountsWebCopy,
|
|
149
|
+
): Record<string, string> {
|
|
150
|
+
const slots = targets.comboRequirements;
|
|
151
|
+
const errors: Record<string, string> = {
|
|
152
|
+
...checkValue(values, slots, formatters, copy),
|
|
153
|
+
...checkCombo(values, slots, copy),
|
|
154
|
+
};
|
|
155
|
+
if (values.trigger === "CODE" && values.code.trim() === "") {
|
|
156
|
+
errors.code = copy.form.codeRequired;
|
|
157
|
+
}
|
|
158
|
+
const { scope } = typeAndScopeFor(values);
|
|
159
|
+
if (scope === "CATEGORY" && targets.categoryIds.length === 0) {
|
|
160
|
+
errors.scope = copy.form.categoryTargetRequired;
|
|
161
|
+
}
|
|
162
|
+
if (scope === "ITEM" && targets.menuItemIds.length === 0) {
|
|
163
|
+
errors.scope = copy.form.itemTargetRequired;
|
|
164
|
+
}
|
|
165
|
+
// The window is half-open [start, end): equal dates are an EMPTY window, so
|
|
166
|
+
// the rule is "after", not "not before".
|
|
167
|
+
if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
|
|
168
|
+
errors.endsAt = copy.form.endsBeforeStarts;
|
|
169
|
+
}
|
|
170
|
+
return errors;
|
|
171
|
+
}
|
|
172
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { DiscountWireRecord } from "./api";
|
|
2
|
+
import { comboRewardOf, kindOf } from "./form-kind";
|
|
3
|
+
import type { DiscountsFormatters } from "./format";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the create/edit form HOLDS, and what an existing rule opens as.
|
|
7
|
+
*
|
|
8
|
+
* Its own module because the form and its validators both need the shape, and
|
|
9
|
+
* a type imported back out of the component file would make the two a cycle.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is a STRING: `total-form` holds strings, because every value
|
|
12
|
+
* in a text input is one. The two switches (booleans) and the target id lists
|
|
13
|
+
* (arrays) are therefore not form values at all — they sit in local state
|
|
14
|
+
* beside the container and are merged in at submit.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface DiscountFormValues extends Record<string, string> {
|
|
18
|
+
name: string;
|
|
19
|
+
/** What kind of promotion this is. Decides `type` and `scope` — see `./form-kind`. */
|
|
20
|
+
kind: string;
|
|
21
|
+
/** Which of the two rewards a COMBO gives. Ignored by every other kind. */
|
|
22
|
+
comboReward: string;
|
|
23
|
+
percentOff: string;
|
|
24
|
+
amountOff: string;
|
|
25
|
+
bundlePrice: string;
|
|
26
|
+
freeUnits: string;
|
|
27
|
+
maxComboApplications: string;
|
|
28
|
+
scope: string;
|
|
29
|
+
trigger: string;
|
|
30
|
+
code: string;
|
|
31
|
+
startsAt: string;
|
|
32
|
+
endsAt: string;
|
|
33
|
+
minSubtotal: string;
|
|
34
|
+
usageLimit: string;
|
|
35
|
+
perBuyerLimit: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
|
|
39
|
+
function toDateInput(iso: string | null): string {
|
|
40
|
+
return iso === null ? "" : iso.slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A new rule's defaults: the most common promotion an operator creates. */
|
|
44
|
+
const BLANK: DiscountFormValues = {
|
|
45
|
+
name: "",
|
|
46
|
+
kind: "PERCENTAGE",
|
|
47
|
+
comboReward: "PERCENTAGE",
|
|
48
|
+
percentOff: "",
|
|
49
|
+
amountOff: "",
|
|
50
|
+
bundlePrice: "",
|
|
51
|
+
freeUnits: "",
|
|
52
|
+
maxComboApplications: "",
|
|
53
|
+
scope: "ORDER",
|
|
54
|
+
trigger: "AUTOMATIC",
|
|
55
|
+
code: "",
|
|
56
|
+
startsAt: "",
|
|
57
|
+
endsAt: "",
|
|
58
|
+
minSubtotal: "",
|
|
59
|
+
usageLimit: "",
|
|
60
|
+
perBuyerLimit: "",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function initialValues(
|
|
64
|
+
editing: DiscountWireRecord | null,
|
|
65
|
+
formatters: DiscountsFormatters,
|
|
66
|
+
): DiscountFormValues {
|
|
67
|
+
if (editing === null) return { ...BLANK };
|
|
68
|
+
const cents = (value: number | null): string =>
|
|
69
|
+
value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
|
|
70
|
+
return {
|
|
71
|
+
name: editing.name,
|
|
72
|
+
kind: kindOf(editing),
|
|
73
|
+
comboReward: comboRewardOf(editing),
|
|
74
|
+
percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
|
|
75
|
+
amountOff: cents(editing.amountOffCents),
|
|
76
|
+
bundlePrice: cents(editing.bundlePriceCents ?? null),
|
|
77
|
+
freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
|
|
78
|
+
maxComboApplications:
|
|
79
|
+
editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
|
|
80
|
+
scope: editing.scope,
|
|
81
|
+
trigger: editing.trigger,
|
|
82
|
+
code: editing.code ?? "",
|
|
83
|
+
startsAt: toDateInput(editing.startsAt),
|
|
84
|
+
endsAt: toDateInput(editing.endsAt),
|
|
85
|
+
minSubtotal: cents(editing.minSubtotalCents),
|
|
86
|
+
usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
|
|
87
|
+
perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -15,17 +15,22 @@ import { Stack } from "@12-apps/ui/mui/Stack";
|
|
|
15
15
|
import type { ComboRequirement } from "../engine/types";
|
|
16
16
|
|
|
17
17
|
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
18
|
-
import {
|
|
18
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
19
19
|
import {
|
|
20
|
+
ComboCapField,
|
|
20
21
|
DiscountSwitches,
|
|
22
|
+
KindField,
|
|
21
23
|
LimitFields,
|
|
22
|
-
|
|
24
|
+
RewardFields,
|
|
23
25
|
ScopeField,
|
|
26
|
+
TriggerFields,
|
|
24
27
|
WindowFields,
|
|
25
28
|
type CurrencyFieldComponent,
|
|
26
29
|
} from "./discount-form-fields";
|
|
27
|
-
import { comboUnits } from "./combo-slot-builder";
|
|
28
30
|
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
31
|
+
import { validate, type FormTargets } from "./discount-form-validate";
|
|
32
|
+
import { initialValues, type DiscountFormValues } from "./discount-form-values";
|
|
33
|
+
import { kindOptions, typeAndScopeFor } from "./form-kind";
|
|
29
34
|
import type { DiscountsFormatters } from "./format";
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -44,211 +49,6 @@ import type { DiscountsFormatters } from "./format";
|
|
|
44
49
|
* `setFieldErrors` and paints the same inputs red.
|
|
45
50
|
*/
|
|
46
51
|
|
|
47
|
-
interface DiscountFormValues extends Record<string, string> {
|
|
48
|
-
name: string;
|
|
49
|
-
type: string;
|
|
50
|
-
percentOff: string;
|
|
51
|
-
amountOff: string;
|
|
52
|
-
bundlePrice: string;
|
|
53
|
-
freeUnits: string;
|
|
54
|
-
maxComboApplications: string;
|
|
55
|
-
scope: string;
|
|
56
|
-
trigger: string;
|
|
57
|
-
code: string;
|
|
58
|
-
startsAt: string;
|
|
59
|
-
endsAt: string;
|
|
60
|
-
minSubtotal: string;
|
|
61
|
-
usageLimit: string;
|
|
62
|
-
perBuyerLimit: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
|
|
66
|
-
function toDateInput(iso: string | null): string {
|
|
67
|
-
return iso === null ? "" : iso.slice(0, 10);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** A new rule's defaults: the most common promotion an operator creates. */
|
|
71
|
-
const BLANK: DiscountFormValues = {
|
|
72
|
-
name: "",
|
|
73
|
-
type: "PERCENTAGE",
|
|
74
|
-
percentOff: "",
|
|
75
|
-
amountOff: "",
|
|
76
|
-
bundlePrice: "",
|
|
77
|
-
freeUnits: "",
|
|
78
|
-
maxComboApplications: "",
|
|
79
|
-
scope: "ORDER",
|
|
80
|
-
trigger: "AUTOMATIC",
|
|
81
|
-
code: "",
|
|
82
|
-
startsAt: "",
|
|
83
|
-
endsAt: "",
|
|
84
|
-
minSubtotal: "",
|
|
85
|
-
usageLimit: "",
|
|
86
|
-
perBuyerLimit: "",
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
function initialValues(
|
|
90
|
-
editing: DiscountWireRecord | null,
|
|
91
|
-
formatters: DiscountsFormatters,
|
|
92
|
-
): DiscountFormValues {
|
|
93
|
-
if (editing === null) return { ...BLANK };
|
|
94
|
-
const cents = (value: number | null): string =>
|
|
95
|
-
value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
|
|
96
|
-
return {
|
|
97
|
-
name: editing.name,
|
|
98
|
-
type: editing.type,
|
|
99
|
-
percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
|
|
100
|
-
amountOff: cents(editing.amountOffCents),
|
|
101
|
-
bundlePrice: cents(editing.bundlePriceCents ?? null),
|
|
102
|
-
freeUnits: editing.freeUnits == null ? "" : String(editing.freeUnits),
|
|
103
|
-
maxComboApplications:
|
|
104
|
-
editing.maxComboApplications == null ? "" : String(editing.maxComboApplications),
|
|
105
|
-
scope: editing.scope,
|
|
106
|
-
trigger: editing.trigger,
|
|
107
|
-
code: editing.code ?? "",
|
|
108
|
-
startsAt: toDateInput(editing.startsAt),
|
|
109
|
-
endsAt: toDateInput(editing.endsAt),
|
|
110
|
-
minSubtotal: cents(editing.minSubtotalCents),
|
|
111
|
-
usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
|
|
112
|
-
perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** A whole number above zero, as typed. `null` when it is neither. */
|
|
117
|
-
function positiveCount(typed: string): number | null {
|
|
118
|
-
const trimmed = typed.trim();
|
|
119
|
-
if (trimmed === "") return null;
|
|
120
|
-
const value = Number(trimmed);
|
|
121
|
-
return Number.isInteger(value) && value > 0 ? value : null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** A money field that has to hold something above zero. */
|
|
125
|
-
function checkMoney(
|
|
126
|
-
typed: string,
|
|
127
|
-
field: string,
|
|
128
|
-
message: string,
|
|
129
|
-
formatters: DiscountsFormatters,
|
|
130
|
-
): Record<string, string> {
|
|
131
|
-
const amount = formatters.parseDecimal(typed);
|
|
132
|
-
return amount === null || amount <= 0 ? { [field]: message } : {};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The free count, which is the one reward bounded by another field.
|
|
137
|
-
*
|
|
138
|
-
* "Take 3, three free" is a giveaway rather than a promotion, so it is compared
|
|
139
|
-
* against what ONE application of the combo takes out of the cart. The ceiling
|
|
140
|
-
* is named in the message: an operator told only "too many" has to guess the
|
|
141
|
-
* number.
|
|
142
|
-
*/
|
|
143
|
-
function checkFreeUnits(
|
|
144
|
-
typed: string,
|
|
145
|
-
slots: readonly ComboRequirement[],
|
|
146
|
-
copy: DiscountsWebCopy,
|
|
147
|
-
): Record<string, string> {
|
|
148
|
-
const free = positiveCount(typed);
|
|
149
|
-
if (free === null) return { freeUnits: copy.form.invalidFreeUnits };
|
|
150
|
-
const units = comboUnits(slots);
|
|
151
|
-
if (free >= units) {
|
|
152
|
-
return {
|
|
153
|
-
freeUnits: fill(copy.form.freeUnitsExceedCombo, { units, max: Math.max(units - 1, 0) }),
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
return {};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** The rate, in the 0–100 an operator thinks in rather than basis points. */
|
|
160
|
-
function checkPercent(
|
|
161
|
-
typed: string,
|
|
162
|
-
formatters: DiscountsFormatters,
|
|
163
|
-
copy: DiscountsWebCopy,
|
|
164
|
-
): Record<string, string> {
|
|
165
|
-
const percent = formatters.parseDecimal(typed);
|
|
166
|
-
return percent === null || percent <= 0 || percent > 100
|
|
167
|
-
? { percentOff: copy.form.invalidPercent }
|
|
168
|
-
: {};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** The reward, checked against the input the chosen type actually mounted. */
|
|
172
|
-
function checkValue(
|
|
173
|
-
values: DiscountFormValues,
|
|
174
|
-
slots: readonly ComboRequirement[],
|
|
175
|
-
formatters: DiscountsFormatters,
|
|
176
|
-
copy: DiscountsWebCopy,
|
|
177
|
-
): Record<string, string> {
|
|
178
|
-
if (values.type === "FIXED_AMOUNT") {
|
|
179
|
-
return checkMoney(values.amountOff, "amountOff", copy.form.invalidAmount, formatters);
|
|
180
|
-
}
|
|
181
|
-
if (values.type === "BUNDLE_PRICE") {
|
|
182
|
-
return checkMoney(values.bundlePrice, "bundlePrice", copy.form.invalidBundlePrice, formatters);
|
|
183
|
-
}
|
|
184
|
-
if (values.type === "FREE_UNITS") return checkFreeUnits(values.freeUnits, slots, copy);
|
|
185
|
-
return checkPercent(values.percentOff, formatters, copy);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The combo half: the groups themselves, and the per-cart cap.
|
|
190
|
-
*
|
|
191
|
-
* Every rule here is also the server's (`validate-combo.ts`), and deliberately
|
|
192
|
-
* so — this half exists to attach the refusal to the input the operator is
|
|
193
|
-
* looking at, never to be the authority.
|
|
194
|
-
*/
|
|
195
|
-
function checkCombo(
|
|
196
|
-
values: DiscountFormValues,
|
|
197
|
-
slots: readonly ComboRequirement[],
|
|
198
|
-
copy: DiscountsWebCopy,
|
|
199
|
-
): Record<string, string> {
|
|
200
|
-
if (values.scope !== "COMBO") return {};
|
|
201
|
-
if (slots.length === 0) return { scope: copy.form.comboSlotsRequired };
|
|
202
|
-
const errors: Record<string, string> = {};
|
|
203
|
-
if (slots.some((slot) => !Number.isInteger(slot.quantity) || slot.quantity <= 0)) {
|
|
204
|
-
errors.scope = copy.form.invalidComboQuantity;
|
|
205
|
-
} else if (
|
|
206
|
-
slots.some((slot) => slot.categoryIds.length === 0 && slot.menuItemIds.length === 0)
|
|
207
|
-
) {
|
|
208
|
-
errors.scope = copy.form.comboSlotTargetRequired;
|
|
209
|
-
}
|
|
210
|
-
if (values.maxComboApplications.trim() !== "" && positiveCount(values.maxComboApplications) === null) {
|
|
211
|
-
errors.maxComboApplications = copy.form.invalidMaxComboApplications;
|
|
212
|
-
}
|
|
213
|
-
return errors;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** What the pickers and the builder currently hold, in one argument. */
|
|
217
|
-
interface FormTargets {
|
|
218
|
-
categoryIds: string[];
|
|
219
|
-
menuItemIds: string[];
|
|
220
|
-
comboRequirements: ComboRequirement[];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** The cross-field rules a per-input schema cannot express. */
|
|
224
|
-
function validate(
|
|
225
|
-
values: DiscountFormValues,
|
|
226
|
-
targets: FormTargets,
|
|
227
|
-
formatters: DiscountsFormatters,
|
|
228
|
-
copy: DiscountsWebCopy,
|
|
229
|
-
): Record<string, string> {
|
|
230
|
-
const slots = targets.comboRequirements;
|
|
231
|
-
const errors: Record<string, string> = {
|
|
232
|
-
...checkValue(values, slots, formatters, copy),
|
|
233
|
-
...checkCombo(values, slots, copy),
|
|
234
|
-
};
|
|
235
|
-
if (values.trigger === "CODE" && values.code.trim() === "") {
|
|
236
|
-
errors.code = copy.form.codeRequired;
|
|
237
|
-
}
|
|
238
|
-
if (values.scope === "CATEGORY" && targets.categoryIds.length === 0) {
|
|
239
|
-
errors.scope = copy.form.categoryTargetRequired;
|
|
240
|
-
}
|
|
241
|
-
if (values.scope === "ITEM" && targets.menuItemIds.length === 0) {
|
|
242
|
-
errors.scope = copy.form.itemTargetRequired;
|
|
243
|
-
}
|
|
244
|
-
// The window is half-open [start, end): equal dates are an EMPTY window, so
|
|
245
|
-
// the rule is "after", not "not before".
|
|
246
|
-
if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
|
|
247
|
-
errors.endsAt = copy.form.endsBeforeStarts;
|
|
248
|
-
}
|
|
249
|
-
return errors;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
52
|
/** The values `total-form` cannot hold, kept beside it and merged at submit. */
|
|
253
53
|
function useNonStringState(editing: DiscountWireRecord | null) {
|
|
254
54
|
const [active, setActive] = useState<boolean>(editing?.active ?? true);
|
|
@@ -318,6 +118,10 @@ function makeSubmit(
|
|
|
318
118
|
}
|
|
319
119
|
const payload: DiscountFormPayload = {
|
|
320
120
|
...values,
|
|
121
|
+
// The one place the form's question becomes the API's two columns. Every
|
|
122
|
+
// other consumer — the write path, the evaluator, the CHECK constraints —
|
|
123
|
+
// still sees the `type`/`scope` pair it always did.
|
|
124
|
+
...typeAndScopeFor(values),
|
|
321
125
|
...targets,
|
|
322
126
|
stackable: extra.stackable,
|
|
323
127
|
active: extra.active,
|
|
@@ -352,7 +156,8 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
352
156
|
>
|
|
353
157
|
<Stack spacing={2}>
|
|
354
158
|
<Fields.TextField name="name" label={copy.form.name} />
|
|
355
|
-
<
|
|
159
|
+
<KindField copy={copy} kinds={kindOptions(editing)} />
|
|
160
|
+
<RewardFields copy={copy} currencyField={currencyField} />
|
|
356
161
|
<ScopeField copy={copy} />
|
|
357
162
|
<DiscountTargetPicker
|
|
358
163
|
groups={groups}
|
|
@@ -366,6 +171,8 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
|
366
171
|
onComboRequirementsChange: extra.setComboRequirements,
|
|
367
172
|
}}
|
|
368
173
|
/>
|
|
174
|
+
<ComboCapField copy={copy} />
|
|
175
|
+
<TriggerFields copy={copy} />
|
|
369
176
|
<WindowFields copy={copy} />
|
|
370
177
|
<LimitFields copy={copy} currencyField={currencyField} />
|
|
371
178
|
<DiscountSwitches
|
|
@@ -9,16 +9,18 @@ import type { ComboRequirement } from "../engine/types";
|
|
|
9
9
|
import type { WireTargetGroup } from "./api";
|
|
10
10
|
import { ComboSlotBuilder } from "./combo-slot-builder";
|
|
11
11
|
import { fill, type DiscountsWebCopy } from "./copy";
|
|
12
|
+
import { FreeUnitsBuilder } from "./free-units-builder";
|
|
12
13
|
import { TargetPickerField } from "./target-picker-field";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* What a scoped discount points at, picked from the host's own collections.
|
|
16
17
|
*
|
|
17
|
-
* This file is the ROUTER: it reads the
|
|
18
|
+
* This file is the ROUTER: it reads the KIND and decides which shape the
|
|
18
19
|
* targets take. The controls themselves live next door — one collection's
|
|
19
20
|
* picker in `target-picker-field`, the quantified group list in
|
|
20
|
-
* `combo-slot-builder
|
|
21
|
-
*
|
|
21
|
+
* `combo-slot-builder`, the one-group "leve 3, pague 2" in
|
|
22
|
+
* `free-units-builder` — because the builders need the picker per group and a
|
|
23
|
+
* file cannot import its own importer.
|
|
22
24
|
*
|
|
23
25
|
* Which collections exist is not decided here. The surface reads them from
|
|
24
26
|
* `GET /discounts/targets`, which answers whatever the host registered — so
|
|
@@ -61,17 +63,17 @@ function scopeFor(targetType: string): string {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/**
|
|
64
|
-
* The
|
|
66
|
+
* The kind-driven target block.
|
|
65
67
|
*
|
|
66
|
-
* Reads the LIVE `scope` from the form context, so flipping
|
|
67
|
-
* the
|
|
68
|
-
* into its own state.
|
|
68
|
+
* Reads the LIVE `kind` and `scope` from the form context, so flipping either
|
|
69
|
+
* toggle swaps the control immediately without the parent form having to mirror
|
|
70
|
+
* the value into its own state.
|
|
69
71
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* The two combo kinds are a different SHAPE, not another picker (FUT-268): a
|
|
73
|
+
* combo is a list of quantified groups, so it gets a builder and every
|
|
74
|
+
* collection at once, while CATEGORY and ITEM each get exactly one collection's
|
|
75
|
+
* picker. ORDER renders nothing — an order-wide discount covers everything, so
|
|
76
|
+
* a target list there is not empty, it is meaningless.
|
|
75
77
|
*/
|
|
76
78
|
export function DiscountTargetPicker({
|
|
77
79
|
groups,
|
|
@@ -82,10 +84,25 @@ export function DiscountTargetPicker({
|
|
|
82
84
|
copy: DiscountsWebCopy;
|
|
83
85
|
selection: DiscountTargetSelection;
|
|
84
86
|
}): JSX.Element | null {
|
|
85
|
-
const { values } = useFormContext();
|
|
87
|
+
const { values, errors, setFieldValue } = useFormContext();
|
|
88
|
+
const kind = values.kind ?? "PERCENTAGE";
|
|
86
89
|
const scope = values.scope ?? "ORDER";
|
|
87
90
|
|
|
88
|
-
if (
|
|
91
|
+
if (kind === "FREE_UNITS") {
|
|
92
|
+
return (
|
|
93
|
+
<FreeUnitsBuilder
|
|
94
|
+
slots={selection.comboRequirements}
|
|
95
|
+
groups={groups}
|
|
96
|
+
copy={copy}
|
|
97
|
+
freeUnits={values.freeUnits ?? ""}
|
|
98
|
+
error={errors.freeUnits}
|
|
99
|
+
onChange={selection.onComboRequirementsChange}
|
|
100
|
+
onFreeUnitsChange={(next) => setFieldValue("freeUnits", next)}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (kind === "COMBO" || kind === "BUNDLE_PRICE") {
|
|
89
106
|
return (
|
|
90
107
|
<ComboSlotBuilder
|
|
91
108
|
slots={selection.comboRequirements}
|