@12-apps/discounts 1.2.0 → 1.4.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.
Files changed (36) hide show
  1. package/README.md +231 -16
  2. package/package.json +55 -6
  3. package/prisma/discounts.prisma +181 -0
  4. package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
  5. package/scripts/sync-discounts-schema.mjs +53 -0
  6. package/src/engine/kinds.ts +62 -0
  7. package/src/index.ts +5 -0
  8. package/src/manifest/index.ts +45 -17
  9. package/src/manifest/web.ts +55 -0
  10. package/src/react/api.ts +209 -0
  11. package/src/react/combo-slot-builder.tsx +213 -0
  12. package/src/react/copy.ts +296 -0
  13. package/src/react/create-web-discounts.tsx +162 -0
  14. package/src/react/discount-actions-menu.tsx +127 -0
  15. package/src/react/discount-card.tsx +92 -0
  16. package/src/react/discount-form-fields.tsx +245 -0
  17. package/src/react/discount-form.tsx +393 -0
  18. package/src/react/discount-list-card.tsx +199 -0
  19. package/src/react/discount-target-picker.tsx +114 -0
  20. package/src/react/discounts-screen.tsx +314 -0
  21. package/src/react/format.ts +126 -0
  22. package/src/react/index.ts +72 -0
  23. package/src/react/pt-BR.ts +157 -0
  24. package/src/react/row.ts +66 -0
  25. package/src/react/table-config.ts +238 -0
  26. package/src/react/target-picker-field.tsx +138 -0
  27. package/src/react/transport.ts +115 -0
  28. package/src/react/use-discounts-data.ts +103 -0
  29. package/src/server/collections.ts +290 -0
  30. package/src/server/copy.ts +12 -0
  31. package/src/server/index.ts +23 -0
  32. package/src/server/logging.ts +20 -3
  33. package/src/server/mcp.ts +33 -0
  34. package/src/server/pt-BR.ts +1 -0
  35. package/src/server/routes.ts +74 -9
  36. package/src/server/targets.ts +102 -0
@@ -0,0 +1,209 @@
1
+ import type { ComboRequirement } from "../engine/types";
2
+
3
+ import type { DiscountsFormatters } from "./format";
4
+ import type { DiscountsResult, DiscountsTransport } from "./transport";
5
+
6
+ /**
7
+ * The wire, and the one place the FORM's shape becomes the API's.
8
+ *
9
+ * A form holds strings — every value in a text input is one — while the API
10
+ * takes integers, basis points and nulls. That coercion is a real rule set, not
11
+ * plumbing, and it has exactly one rule worth stating: a BLANK field means "no
12
+ * value" (`null`), never zero, because `0` is a real and different answer for a
13
+ * limit or a minimum. A host repeating this per call site is how one screen
14
+ * starts saving `0` where another saves `null`.
15
+ */
16
+
17
+ /** A discount as the CRUD endpoints echo it — JSON, so dates are ISO strings. */
18
+ export interface DiscountWireRecord {
19
+ id: string;
20
+ name: string;
21
+ type: string;
22
+ percentOffBp: number | null;
23
+ amountOffCents: number | null;
24
+ bundlePriceCents?: number | null;
25
+ freeUnits?: number | null;
26
+ maxComboApplications?: number | null;
27
+ comboRequirements?: ComboRequirement[];
28
+ scope: string;
29
+ trigger: string;
30
+ code: string | null;
31
+ startsAt: string | null;
32
+ endsAt: string | null;
33
+ minSubtotalCents: number | null;
34
+ usageLimit: number | null;
35
+ perBuyerLimit: number | null;
36
+ usageCount: number;
37
+ stackable: boolean;
38
+ active: boolean;
39
+ categoryIds: string[];
40
+ menuItemIds: string[];
41
+ createdAt: string;
42
+ }
43
+
44
+ /** The pagination envelope the list endpoint answers with. */
45
+ export interface DiscountsPagination {
46
+ page: number;
47
+ pageSize: number;
48
+ total: number;
49
+ pageCount: number;
50
+ hasNextPage: boolean;
51
+ hasPreviousPage: boolean;
52
+ }
53
+
54
+ export interface DiscountsPage {
55
+ data: DiscountWireRecord[];
56
+ pagination: DiscountsPagination;
57
+ }
58
+
59
+ /** One selectable target, as `GET /discounts/targets` answers it. */
60
+ export interface WireTarget {
61
+ id: string;
62
+ name: string;
63
+ parentId?: string | null;
64
+ }
65
+
66
+ /** One registered collection and its rows. */
67
+ export interface WireTargetGroup {
68
+ targetType: string;
69
+ slug: string;
70
+ label: string;
71
+ nests: boolean;
72
+ targets: WireTarget[];
73
+ }
74
+
75
+ /** The form's payload, still in FORM shape. */
76
+ export interface DiscountFormPayload {
77
+ name: string;
78
+ type: string;
79
+ /** 0–100 with up to two decimals, in the operator's own notation. */
80
+ percentOff: string;
81
+ /** A money amount, as the currency field stores it. */
82
+ amountOff: string;
83
+ /** What the matched combo group costs, as the currency field stores it. */
84
+ bundlePrice: string;
85
+ /** How many of the matched units are free, as typed. */
86
+ freeUnits: string;
87
+ /** How many combos one cart may claim. Blank = as often as it fits. */
88
+ maxComboApplications: string;
89
+ scope: string;
90
+ trigger: string;
91
+ code: string;
92
+ startsAt: string;
93
+ endsAt: string;
94
+ minSubtotal: string;
95
+ usageLimit: string;
96
+ perBuyerLimit: string;
97
+ stackable: boolean;
98
+ active: boolean;
99
+ categoryIds: string[];
100
+ menuItemIds: string[];
101
+ /** The combo's groups, in the operator's own order. Empty at every other scope. */
102
+ comboRequirements: readonly ComboRequirement[];
103
+ }
104
+
105
+ /** A money amount as integer cents, or null when blank. */
106
+ function toCents(typed: string, formatters: DiscountsFormatters): number | null {
107
+ const amount = formatters.parseDecimal(typed);
108
+ return amount === null ? null : Math.round(amount * 100);
109
+ }
110
+
111
+ /** A whole-number cap, or null when blank ("no cap"). */
112
+ function toCount(typed: string, formatters: DiscountsFormatters): number | null {
113
+ const parsed = formatters.parseDecimal(typed);
114
+ return parsed === null ? null : Math.trunc(parsed);
115
+ }
116
+
117
+ /**
118
+ * A percentage as BASIS POINTS.
119
+ *
120
+ * The form takes 0–100 with two decimals because that is how an operator thinks
121
+ * about a promotion; the column stores basis points because that is how the
122
+ * engine computes without a float. The `Math.round` after the ×100 is what
123
+ * keeps "12,5" exactly 1250 rather than 1249.9999999999998.
124
+ */
125
+ function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
126
+ const percent = formatters.parseDecimal(typed);
127
+ return percent === null ? null : Math.round(percent * 100);
128
+ }
129
+
130
+ /** Blank → null, so an unused branch's leftover text never reaches a column. */
131
+ function toNullable(value: string): string | null {
132
+ const trimmed = value.trim();
133
+ return trimmed === "" ? null : trimmed;
134
+ }
135
+
136
+ /** The wire body both writes send — a discount is always saved WHOLE. */
137
+ export function toWriteBody(
138
+ input: DiscountFormPayload,
139
+ formatters: DiscountsFormatters,
140
+ ): Record<string, unknown> {
141
+ const isCombo = input.scope === "COMBO";
142
+ return {
143
+ name: input.name.trim(),
144
+ type: input.type,
145
+ // Exactly one value column, chosen by the TYPE, with the other three
146
+ // forced to null. The four are mutually exclusive at the database
147
+ // (`discounts_value_check`), so carrying a leftover from a branch the
148
+ // operator flipped away from is a 500 on a form they filled in correctly —
149
+ // the same reason the server's own folder nulls them rather than passing
150
+ // them through.
151
+ percentOffBp:
152
+ input.type === "PERCENTAGE" ? toBasisPoints(input.percentOff, formatters) : null,
153
+ amountOffCents: input.type === "FIXED_AMOUNT" ? toCents(input.amountOff, formatters) : null,
154
+ bundlePriceCents:
155
+ input.type === "BUNDLE_PRICE" ? toCents(input.bundlePrice, formatters) : null,
156
+ freeUnits: input.type === "FREE_UNITS" ? toCount(input.freeUnits, formatters) : null,
157
+ // The combo SPEC, narrowed to the scope for the reason the id lists below
158
+ // are: a rule whose scope moved off COMBO keeps neither its groups nor its
159
+ // cap, or it would go on being matched as a combo by a screen that no
160
+ // longer shows one.
161
+ comboRequirements: isCombo ? [...input.comboRequirements] : [],
162
+ maxComboApplications: isCombo ? toCount(input.maxComboApplications, formatters) : null,
163
+ scope: input.scope,
164
+ trigger: input.trigger,
165
+ code: input.trigger === "CODE" ? toNullable(input.code) : null,
166
+ startsAt: toNullable(input.startsAt),
167
+ endsAt: toNullable(input.endsAt),
168
+ minSubtotalCents: toCents(input.minSubtotal, formatters),
169
+ usageLimit: toCount(input.usageLimit, formatters),
170
+ perBuyerLimit: toCount(input.perBuyerLimit, formatters),
171
+ stackable: input.stackable,
172
+ active: input.active,
173
+ // Narrowed to the scope on the way OUT as well as on the way in: leaving a
174
+ // stale id list on a rule whose scope changed is how a promotion goes on
175
+ // covering something its screen no longer shows.
176
+ categoryIds: input.scope === "CATEGORY" ? input.categoryIds : [],
177
+ menuItemIds: input.scope === "ITEM" ? input.menuItemIds : [],
178
+ };
179
+ }
180
+
181
+ /** The bound wire client the screens use. Nothing else performs I/O. */
182
+ export interface DiscountsApiClient {
183
+ list(search: string): Promise<DiscountsPage>;
184
+ targets(): Promise<readonly WireTargetGroup[]>;
185
+ create(input: DiscountFormPayload): Promise<DiscountsResult<DiscountWireRecord>>;
186
+ update(id: string, input: DiscountFormPayload): Promise<DiscountsResult<{ id: string }>>;
187
+ remove(id: string): Promise<DiscountsResult<{ id: string }>>;
188
+ }
189
+
190
+ export function createDiscountsApiClient(
191
+ apiBase: string,
192
+ transport: DiscountsTransport,
193
+ formatters: DiscountsFormatters,
194
+ ): DiscountsApiClient {
195
+ const collection = `${apiBase}/discounts`;
196
+ const item = (id: string): string => `${collection}/${encodeURIComponent(id)}`;
197
+ return {
198
+ list: (search) => transport.get<DiscountsPage>(search ? `${collection}?${search}` : collection),
199
+ targets: () =>
200
+ transport
201
+ .get<{ data: WireTargetGroup[] }>(`${collection}/targets`)
202
+ .then((payload) => payload.data),
203
+ create: (input) =>
204
+ transport.send<DiscountWireRecord>(collection, "POST", toWriteBody(input, formatters)),
205
+ update: (id, input) =>
206
+ transport.send<{ id: string }>(item(id), "PATCH", toWriteBody(input, formatters)),
207
+ remove: (id) => transport.send<{ id: string }>(item(id), "DELETE"),
208
+ };
209
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { Button } from "@12-apps/ui/form/Button";
6
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
7
+ import { Input } from "@12-apps/ui/form/Input";
8
+ import { Box } from "@12-apps/ui/mui/Box";
9
+ import { Stack } from "@12-apps/ui/mui/Stack";
10
+ import { Text } from "@12-apps/ui/typography/Text";
11
+
12
+ import type { ComboRequirement } from "../engine/types";
13
+
14
+ import type { WireTargetGroup } from "./api";
15
+ import { fill, type DiscountsWebCopy } from "./copy";
16
+ import { TargetPickerField } from "./target-picker-field";
17
+
18
+ /**
19
+ * The combo builder — how an operator says "2 refrigerantes, 2 hambúrgueres e
20
+ * 2 batatas" (FUT-268).
21
+ *
22
+ * A combo is a list of GROUPS. Each group is a quantity and the rows that can
23
+ * fill it, and a cart matches the combo when every group is satisfiable at
24
+ * once. That shape is already the engine's (`ComboRequirement`), the
25
+ * validator's and the schema's — `discount_combo_slots` with its own targets —
26
+ * so this file adds no concept. It adds the only thing that was missing: a way
27
+ * to author one.
28
+ *
29
+ * Two decisions worth stating, because both are visible in the markup:
30
+ *
31
+ * **A group can name categories AND items at once.** "2 refrigerantes" is a
32
+ * category; "2 batatas médias" is an item; and a real menu mixes them inside
33
+ * one group ("any drink, or specifically the large fries"). So each group gets
34
+ * every registered collection's picker rather than a scope toggle — the union
35
+ * is what `ComboRequirement` already stores, and forcing a choice would make
36
+ * the common offer unexpressible.
37
+ *
38
+ * **Position is the array index, and it is meaningful.** The list an operator
39
+ * builds is the list a card reads back; two groups of the same size are
40
+ * otherwise indistinguishable once stored. Removing a group therefore
41
+ * renumbers the ones after it, which is what an operator expects from a list
42
+ * they are editing — and `toTargetRows` stamps the index as `position` on the
43
+ * way to the database.
44
+ */
45
+
46
+ /** How many units one application of the combo takes out of the cart. */
47
+ export function comboUnits(slots: readonly ComboRequirement[]): number {
48
+ return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
49
+ }
50
+
51
+ /** An empty group: one unit, nothing chosen yet. */
52
+ export function blankSlot(): ComboRequirement {
53
+ return { quantity: 1, categoryIds: [], menuItemIds: [] };
54
+ }
55
+
56
+ /** Replace one group, leaving the rest (and their order) alone. */
57
+ function replaceAt(
58
+ slots: readonly ComboRequirement[],
59
+ index: number,
60
+ next: ComboRequirement,
61
+ ): ComboRequirement[] {
62
+ return slots.map((slot, position) => (position === index ? next : slot));
63
+ }
64
+
65
+ /** Which id list of a group a collection reads and writes. */
66
+ function idsFor(slot: ComboRequirement, targetType: string): readonly string[] {
67
+ return targetType === "CATEGORY" ? slot.categoryIds : slot.menuItemIds;
68
+ }
69
+
70
+ function withIds(slot: ComboRequirement, targetType: string, ids: string[]): ComboRequirement {
71
+ return targetType === "CATEGORY" ? { ...slot, categoryIds: ids } : { ...slot, menuItemIds: ids };
72
+ }
73
+
74
+ /**
75
+ * One group: its quantity, one picker per registered collection, and the
76
+ * control that removes it.
77
+ *
78
+ * The quantity is a `number` input rather than a masked one — it counts units,
79
+ * not money, so there is no locale in it. It is held as a NUMBER in state
80
+ * rather than as the form's string, because the summary line above adds them up
81
+ * and the validator compares them against `freeUnits`; parsing on every read
82
+ * would put the same `Number()` in three places.
83
+ */
84
+ function ComboSlotRow({
85
+ slot,
86
+ index,
87
+ groups,
88
+ copy,
89
+ onChange,
90
+ onRemove,
91
+ }: {
92
+ slot: ComboRequirement;
93
+ index: number;
94
+ groups: readonly WireTargetGroup[];
95
+ copy: DiscountsWebCopy;
96
+ onChange: (next: ComboRequirement) => void;
97
+ onRemove: () => void;
98
+ }): JSX.Element {
99
+ const position = index + 1;
100
+ const empty = slot.categoryIds.length === 0 && slot.menuItemIds.length === 0;
101
+ return (
102
+ <Box
103
+ data-testid={`combo-slot-${index}`}
104
+ sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}
105
+ >
106
+ <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
107
+ <Text weight="medium">{fill(copy.combo.slot, { position })}</Text>
108
+ <Button
109
+ variant="text"
110
+ onClick={onRemove}
111
+ // The label carries the POSITION, so a screen reader announces which
112
+ // group this removes — "remove" alone is three identical buttons.
113
+ aria-label={fill(copy.combo.removeSlot, { position })}
114
+ data-testid={`combo-slot-${index}-remove`}
115
+ >
116
+ ✕
117
+ </Button>
118
+ </Stack>
119
+ <Stack spacing={2} sx={{ mt: 1 }}>
120
+ <Input
121
+ type="number"
122
+ label={copy.combo.quantity}
123
+ value={String(slot.quantity)}
124
+ onChange={(event) => onChange({ ...slot, quantity: Number(event.target.value) })}
125
+ data-testid={`combo-slot-${index}-quantity`}
126
+ />
127
+ {groups.map((group) => (
128
+ <TargetPickerField
129
+ key={group.targetType}
130
+ group={group}
131
+ label={fill(copy.combo.pick, { collection: group.label })}
132
+ placeholder={fill(copy.targets.search, { collection: group.label })}
133
+ // The refusal belongs to the GROUP, not to either picker: a group
134
+ // naming a category is complete even with no items in it. So the
135
+ // message hangs below both, once, and only when both are empty.
136
+ requiredMessage={undefined}
137
+ ids={idsFor(slot, group.targetType)}
138
+ onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
139
+ dataTestId={`combo-slot-${index}-${group.slug}`}
140
+ />
141
+ ))}
142
+ {empty && (
143
+ <FormMessage error dataTestId={`combo-slot-${index}-message`}>
144
+ {copy.form.comboSlotTargetRequired}
145
+ </FormMessage>
146
+ )}
147
+ </Stack>
148
+ </Box>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * The whole builder: the heading, the groups, the add button and the read-back.
154
+ *
155
+ * The read-back line is not decoration. A combo is the one promotion whose
156
+ * meaning is spread across several controls, and "the combo takes 6 items in 3
157
+ * groups" is the sentence that catches a mistyped quantity before a save — the
158
+ * same number `freeUnits` is validated against, computed once and shown.
159
+ */
160
+ export function ComboSlotBuilder({
161
+ slots,
162
+ groups,
163
+ copy,
164
+ onChange,
165
+ }: {
166
+ slots: readonly ComboRequirement[];
167
+ groups: readonly WireTargetGroup[];
168
+ copy: DiscountsWebCopy;
169
+ onChange: (next: ComboRequirement[]) => void;
170
+ }): JSX.Element {
171
+ return (
172
+ <FormControl fullWidth>
173
+ <FormLabel>{copy.combo.title}</FormLabel>
174
+ <Text variant="caption">{copy.combo.hint}</Text>
175
+ <Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
176
+ {slots.map((slot, index) => (
177
+ <ComboSlotRow
178
+ // The index IS the identity here: `position` is the array index,
179
+ // and a group carries no id of its own until the database gives it
180
+ // one. Removing a group renumbers the rest, which is exactly what
181
+ // the operator sees happen.
182
+ key={index}
183
+ slot={slot}
184
+ index={index}
185
+ groups={groups}
186
+ copy={copy}
187
+ onChange={(next) => onChange(replaceAt(slots, index, next))}
188
+ onRemove={() => onChange(slots.filter((_, position) => position !== index))}
189
+ />
190
+ ))}
191
+ {slots.length === 0 && (
192
+ <Text variant="caption" data-testid="combo-slots-empty">
193
+ {copy.combo.empty}
194
+ </Text>
195
+ )}
196
+ <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
197
+ <Button
198
+ variant="outline"
199
+ onClick={() => onChange([...slots, blankSlot()])}
200
+ data-testid="combo-add-slot"
201
+ >
202
+ {copy.combo.addSlot}
203
+ </Button>
204
+ {slots.length > 0 && (
205
+ <Text variant="caption" data-testid="combo-summary">
206
+ {fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
207
+ </Text>
208
+ )}
209
+ </Stack>
210
+ </Stack>
211
+ </FormControl>
212
+ );
213
+ }