@12-apps/discounts 1.2.0 → 1.3.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 +196 -16
- package/package.json +55 -6
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +45 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +185 -0
- package/src/react/copy.ts +239 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +174 -0
- package/src/react/discount-form.tsx +272 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +159 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +132 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- package/src/server/targets.ts +102 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { CardKebab, useCardActions, useRemoveConfirm } from "@12-apps/ui/data-display/CardKit";
|
|
6
|
+
import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
|
|
7
|
+
import type { DropdownMenuItem } from "@12-apps/ui/navigation/DropdownMenu";
|
|
8
|
+
|
|
9
|
+
import type { DiscountsApiClient, WireTargetGroup } from "./api";
|
|
10
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
11
|
+
import { DiscountForm } from "./discount-form";
|
|
12
|
+
import type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
13
|
+
import type { DiscountsFormatters } from "./format";
|
|
14
|
+
import type { DiscountListItem } from "./row";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One rule's self-contained "⋮" menu: it owns its edit dialog inline and its
|
|
18
|
+
* delete write, reading the refresh and the error channel from
|
|
19
|
+
* `CardActionsProvider`.
|
|
20
|
+
*
|
|
21
|
+
* Reused by BOTH the grid row and the card, so the two can never offer a
|
|
22
|
+
* different set of actions — which is the whole reason it is one component
|
|
23
|
+
* rather than a menu per surface.
|
|
24
|
+
*
|
|
25
|
+
* There is deliberately no version-history entry. A discount is not versioned
|
|
26
|
+
* catalog content: restoring "version 3" of a coupon whose redemption counter
|
|
27
|
+
* has since moved is meaningless, so the surface does not offer it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface DiscountActionsMenuProps {
|
|
31
|
+
row: DiscountListItem;
|
|
32
|
+
api: DiscountsApiClient;
|
|
33
|
+
copy: DiscountsWebCopy;
|
|
34
|
+
formatters: DiscountsFormatters;
|
|
35
|
+
currencyField: CurrencyFieldComponent;
|
|
36
|
+
/**
|
|
37
|
+
* The registered collections, for the edit form's target pickers.
|
|
38
|
+
*
|
|
39
|
+
* Optional, and the absence is a real state rather than an oversight: while
|
|
40
|
+
* they are still loading, only DELETE is offered — an edit form opened
|
|
41
|
+
* without them would show empty pickers, and saving would silently clear a
|
|
42
|
+
* target list it never had the chance to display.
|
|
43
|
+
*/
|
|
44
|
+
groups?: readonly WireTargetGroup[];
|
|
45
|
+
onError: (error: unknown, context: string) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function DiscountActionsMenu({
|
|
49
|
+
row,
|
|
50
|
+
api,
|
|
51
|
+
copy,
|
|
52
|
+
formatters,
|
|
53
|
+
currencyField,
|
|
54
|
+
groups,
|
|
55
|
+
onError,
|
|
56
|
+
}: DiscountActionsMenuProps): JSX.Element {
|
|
57
|
+
const { onRefresh } = useCardActions();
|
|
58
|
+
const [editOpen, setEditOpen] = useState(false);
|
|
59
|
+
const canEdit = groups !== undefined;
|
|
60
|
+
|
|
61
|
+
const remove = useRemoveConfirm({
|
|
62
|
+
write: async () => {
|
|
63
|
+
const result = await api.remove(row.id);
|
|
64
|
+
if (!result.ok) onError(result, "discounts.delete");
|
|
65
|
+
return result;
|
|
66
|
+
},
|
|
67
|
+
title: copy.actions.deleteTitle,
|
|
68
|
+
entityName: row.name,
|
|
69
|
+
description: copy.actions.deleteDescription,
|
|
70
|
+
confirmText: copy.actions.delete,
|
|
71
|
+
fallbackError: copy.actions.deleteFailed,
|
|
72
|
+
dataTestId: "discount-delete-confirm",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const items: DropdownMenuItem[] = [];
|
|
76
|
+
if (canEdit) {
|
|
77
|
+
items.push({ id: "edit", label: copy.actions.edit, onClick: () => setEditOpen(true) });
|
|
78
|
+
}
|
|
79
|
+
items.push({
|
|
80
|
+
id: "delete",
|
|
81
|
+
label: copy.actions.delete,
|
|
82
|
+
color: "danger",
|
|
83
|
+
onClick: remove.request,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<CardKebab
|
|
89
|
+
items={items}
|
|
90
|
+
menuLabel={copy.actions.menu}
|
|
91
|
+
dataTestId={`discount-actions-${row.id}`}
|
|
92
|
+
/>
|
|
93
|
+
{remove.dialog}
|
|
94
|
+
{canEdit && (
|
|
95
|
+
<Dialog
|
|
96
|
+
open={editOpen}
|
|
97
|
+
onClose={() => setEditOpen(false)}
|
|
98
|
+
title={copy.form.editTitle}
|
|
99
|
+
size="md"
|
|
100
|
+
showCloseButton
|
|
101
|
+
dataTestId="discount-dialog"
|
|
102
|
+
>
|
|
103
|
+
{/* Mounted only while open, so the next open re-seeds from the row
|
|
104
|
+
instead of keeping the last attempt's values. */}
|
|
105
|
+
{editOpen && (
|
|
106
|
+
<DialogContent>
|
|
107
|
+
<DiscountForm
|
|
108
|
+
key={row.id}
|
|
109
|
+
api={api}
|
|
110
|
+
copy={copy}
|
|
111
|
+
formatters={formatters}
|
|
112
|
+
currencyField={currencyField}
|
|
113
|
+
groups={groups}
|
|
114
|
+
editing={row.record}
|
|
115
|
+
onError={onError}
|
|
116
|
+
onSaved={() => {
|
|
117
|
+
setEditOpen(false);
|
|
118
|
+
onRefresh();
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</DialogContent>
|
|
122
|
+
)}
|
|
123
|
+
</Dialog>
|
|
124
|
+
)}
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
|
|
6
|
+
|
|
7
|
+
import type { KindCardProps } from "@12-apps/ui/data-display/CardKit";
|
|
8
|
+
import { BaseCard } from "@12-apps/ui/data-display/DataViews";
|
|
9
|
+
import { Chip } from "@12-apps/ui/data-display/Chip";
|
|
10
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
11
|
+
|
|
12
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
13
|
+
import { DiscountActionsMenu, type DiscountActionsMenuProps } from "./discount-actions-menu";
|
|
14
|
+
import type { DiscountListItem } from "./row";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One rule as a tile for the grid's card layout.
|
|
18
|
+
*
|
|
19
|
+
* The subtitle carries the two numbers an operator scans a promotions board
|
|
20
|
+
* for: what it takes off, and how long it runs. The chips carry the two facts
|
|
21
|
+
* that decide WHERE and WHEN it fires. "Paused" is shown only when true —
|
|
22
|
+
* a card shouting "Active" on every tile would make the one paused promotion
|
|
23
|
+
* harder to spot, not easier.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The semantic colour a scope's chip carries. Not copy: it is a token. */
|
|
27
|
+
const SCOPE_COLOR: Record<string, "neutral" | "warning" | "success" | "info"> = {
|
|
28
|
+
ORDER: "neutral",
|
|
29
|
+
CATEGORY: "warning",
|
|
30
|
+
ITEM: "success",
|
|
31
|
+
COMBO: "info",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const TRIGGER_COLOR: Record<string, "success" | "info"> = {
|
|
35
|
+
AUTOMATIC: "success",
|
|
36
|
+
CODE: "info",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type DiscountCardProps = KindCardProps &
|
|
40
|
+
Omit<DiscountActionsMenuProps, "row"> & { row: DiscountListItem };
|
|
41
|
+
|
|
42
|
+
export function DiscountCard({
|
|
43
|
+
row,
|
|
44
|
+
selection,
|
|
45
|
+
aspectRatio,
|
|
46
|
+
copy,
|
|
47
|
+
...menu
|
|
48
|
+
}: DiscountCardProps & { copy: DiscountsWebCopy }): JSX.Element {
|
|
49
|
+
const scopeLabel = copy.labels.scope[row.scope as keyof typeof copy.labels.scope] ?? row.scope;
|
|
50
|
+
const triggerLabel =
|
|
51
|
+
copy.labels.trigger[row.trigger as keyof typeof copy.labels.trigger] ?? row.trigger;
|
|
52
|
+
return (
|
|
53
|
+
<BaseCard
|
|
54
|
+
aspectRatio={aspectRatio ?? "4:3"}
|
|
55
|
+
scale={selection.scale}
|
|
56
|
+
selected={selection.selected}
|
|
57
|
+
onToggleSelect={selection.onToggleSelect}
|
|
58
|
+
testId={`discount-card-${row.id}`}
|
|
59
|
+
title={row.name}
|
|
60
|
+
subtitle={`${row.valueLabel} · ${row.windowLabel}`}
|
|
61
|
+
imageFallback={<LocalOfferOutlinedIcon sx={{ fontSize: 40, opacity: 0.35 }} />}
|
|
62
|
+
menu={<DiscountActionsMenu row={row} copy={copy} {...menu} />}
|
|
63
|
+
>
|
|
64
|
+
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
|
65
|
+
<Chip
|
|
66
|
+
label={scopeLabel}
|
|
67
|
+
size="sm"
|
|
68
|
+
variant="filled"
|
|
69
|
+
color={SCOPE_COLOR[row.scope] ?? "neutral"}
|
|
70
|
+
dataTestId={`discount-card-scope-${row.id}`}
|
|
71
|
+
/>
|
|
72
|
+
<Chip
|
|
73
|
+
// A coupon identifies itself; the word "code" beside a code says less.
|
|
74
|
+
label={row.trigger === "CODE" ? (row.code ?? triggerLabel) : triggerLabel}
|
|
75
|
+
size="sm"
|
|
76
|
+
variant="outlined"
|
|
77
|
+
color={TRIGGER_COLOR[row.trigger] ?? "neutral"}
|
|
78
|
+
dataTestId={`discount-card-trigger-${row.id}`}
|
|
79
|
+
/>
|
|
80
|
+
{!row.active && (
|
|
81
|
+
<Chip
|
|
82
|
+
label={copy.card.paused}
|
|
83
|
+
size="sm"
|
|
84
|
+
variant="outlined"
|
|
85
|
+
color="neutral"
|
|
86
|
+
dataTestId={`discount-card-paused-${row.id}`}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
</Box>
|
|
90
|
+
</BaseCard>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Switch } from "@12-apps/ui/form/Switch";
|
|
6
|
+
import { Fields, useFormContext } from "@12-apps/ui/form/total-form";
|
|
7
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
8
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
9
|
+
|
|
10
|
+
import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
|
|
11
|
+
|
|
12
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The form's inputs, split from the form itself for the 400-line file gate —
|
|
16
|
+
* comments count toward it, and the container plus these would not fit.
|
|
17
|
+
*
|
|
18
|
+
* Every option list is built FROM the vocabulary arrays rather than spelled out
|
|
19
|
+
* again: those arrays back the database's CHECK constraints, so an option an
|
|
20
|
+
* operator can pick is by construction a value the column will store. A
|
|
21
|
+
* hand-written list is how a form offers a fifth type the database refuses.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One closed set as a toggle's options, labelled by the pack. */
|
|
25
|
+
function optionsFor<TKey extends string>(
|
|
26
|
+
values: readonly TKey[],
|
|
27
|
+
labels: Readonly<Record<TKey, string>>,
|
|
28
|
+
): { value: string; label: string }[] {
|
|
29
|
+
return values.map((value) => ({ value, label: labels[value] }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Percentage or amount — whichever the chosen type actually stores. */
|
|
33
|
+
function ValueField({
|
|
34
|
+
copy,
|
|
35
|
+
currencyField,
|
|
36
|
+
}: {
|
|
37
|
+
copy: DiscountsWebCopy;
|
|
38
|
+
currencyField: CurrencyFieldComponent;
|
|
39
|
+
}): JSX.Element {
|
|
40
|
+
const { values } = useFormContext();
|
|
41
|
+
const Currency = currencyField;
|
|
42
|
+
if (values.type === "FIXED_AMOUNT") {
|
|
43
|
+
return <Currency name="amountOff" label={copy.form.amountOff} />;
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
<Fields.TextField
|
|
47
|
+
name="percentOff"
|
|
48
|
+
label={copy.form.percentOff}
|
|
49
|
+
placeholder={copy.form.percentPlaceholder}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A money input, supplied by the host.
|
|
56
|
+
*
|
|
57
|
+
* Currency entry is a host decision — masking, the symbol's side, whether cents
|
|
58
|
+
* are typed or implied — and `@12-apps/ui` has no opinionated one. So the
|
|
59
|
+
* surface takes the component rather than shipping a guess; a host with nothing
|
|
60
|
+
* special passes a plain text field.
|
|
61
|
+
*/
|
|
62
|
+
export type CurrencyFieldComponent = (props: { name: string; label: string }) => JSX.Element;
|
|
63
|
+
|
|
64
|
+
/** The coupon input, shown only for a CODE-triggered rule. */
|
|
65
|
+
function CodeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element | null {
|
|
66
|
+
const { values } = useFormContext();
|
|
67
|
+
if (values.trigger !== "CODE") return null;
|
|
68
|
+
return (
|
|
69
|
+
<Fields.TextField
|
|
70
|
+
name="code"
|
|
71
|
+
label={copy.form.code}
|
|
72
|
+
placeholder={copy.form.codePlaceholder}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Type + value + trigger + code, the four that decide WHAT and HOW. */
|
|
78
|
+
export function RuleFields({
|
|
79
|
+
copy,
|
|
80
|
+
currencyField,
|
|
81
|
+
}: {
|
|
82
|
+
copy: DiscountsWebCopy;
|
|
83
|
+
currencyField: CurrencyFieldComponent;
|
|
84
|
+
}): JSX.Element {
|
|
85
|
+
return (
|
|
86
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
87
|
+
<Fields.ToggleField
|
|
88
|
+
name="type"
|
|
89
|
+
label={copy.form.type}
|
|
90
|
+
options={optionsFor(DISCOUNT_TYPES, copy.labels.type)}
|
|
91
|
+
/>
|
|
92
|
+
<ValueField copy={copy} currencyField={currencyField} />
|
|
93
|
+
<Fields.ToggleField
|
|
94
|
+
name="trigger"
|
|
95
|
+
label={copy.form.trigger}
|
|
96
|
+
options={optionsFor(DISCOUNT_TRIGGERS, copy.labels.trigger)}
|
|
97
|
+
/>
|
|
98
|
+
<CodeField copy={copy} />
|
|
99
|
+
</Box>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The scope toggle, which decides which target picker is shown below it. */
|
|
104
|
+
export function ScopeField({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
105
|
+
return (
|
|
106
|
+
<Fields.ToggleField
|
|
107
|
+
name="scope"
|
|
108
|
+
label={copy.form.scope}
|
|
109
|
+
options={optionsFor(DISCOUNT_SCOPES, copy.labels.scope)}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The active window. Both sides optional; the end date is EXCLUSIVE. */
|
|
115
|
+
export function WindowFields({ copy }: { copy: DiscountsWebCopy }): JSX.Element {
|
|
116
|
+
return (
|
|
117
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" } }}>
|
|
118
|
+
<Fields.TextField name="startsAt" label={copy.form.startsAt} type="date" />
|
|
119
|
+
<Fields.TextField name="endsAt" label={copy.form.endsAt} type="date" />
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The minimum basket and the two redemption caps. */
|
|
125
|
+
export function LimitFields({
|
|
126
|
+
copy,
|
|
127
|
+
currencyField,
|
|
128
|
+
}: {
|
|
129
|
+
copy: DiscountsWebCopy;
|
|
130
|
+
currencyField: CurrencyFieldComponent;
|
|
131
|
+
}): JSX.Element {
|
|
132
|
+
const Currency = currencyField;
|
|
133
|
+
return (
|
|
134
|
+
<Box sx={{ display: "grid", gap: 2, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" } }}>
|
|
135
|
+
<Currency name="minSubtotal" label={copy.form.minSubtotal} />
|
|
136
|
+
<Fields.TextField name="usageLimit" label={copy.form.usageLimit} type="number" />
|
|
137
|
+
<Fields.TextField name="perBuyerLimit" label={copy.form.perBuyerLimit} type="number" />
|
|
138
|
+
</Box>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The two booleans, owned by the parent so submit can send them. */
|
|
143
|
+
export function DiscountSwitches({
|
|
144
|
+
copy,
|
|
145
|
+
active,
|
|
146
|
+
stackable,
|
|
147
|
+
onActive,
|
|
148
|
+
onStackable,
|
|
149
|
+
}: {
|
|
150
|
+
copy: DiscountsWebCopy;
|
|
151
|
+
active: boolean;
|
|
152
|
+
stackable: boolean;
|
|
153
|
+
onActive: (next: boolean) => void;
|
|
154
|
+
onStackable: (next: boolean) => void;
|
|
155
|
+
}): JSX.Element {
|
|
156
|
+
return (
|
|
157
|
+
<Stack spacing={1}>
|
|
158
|
+
<Switch
|
|
159
|
+
checked={active}
|
|
160
|
+
onChange={(event) => onActive(event.target.checked)}
|
|
161
|
+
label={copy.form.active}
|
|
162
|
+
description={copy.form.activeHint}
|
|
163
|
+
dataTestId="discount-active"
|
|
164
|
+
/>
|
|
165
|
+
<Switch
|
|
166
|
+
checked={stackable}
|
|
167
|
+
onChange={(event) => onStackable(event.target.checked)}
|
|
168
|
+
label={copy.form.stackable}
|
|
169
|
+
description={copy.form.stackableHint}
|
|
170
|
+
dataTestId="discount-stackable"
|
|
171
|
+
/>
|
|
172
|
+
</Stack>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { minLength, required } from "@12-apps/forms-core";
|
|
6
|
+
import { Alert } from "@12-apps/ui/data-display/Alert";
|
|
7
|
+
import {
|
|
8
|
+
Fields,
|
|
9
|
+
FormContainer,
|
|
10
|
+
FormErrorSnackbar,
|
|
11
|
+
SubmitButton,
|
|
12
|
+
} from "@12-apps/ui/form/total-form";
|
|
13
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
14
|
+
|
|
15
|
+
import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
|
|
16
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
17
|
+
import {
|
|
18
|
+
DiscountSwitches,
|
|
19
|
+
LimitFields,
|
|
20
|
+
RuleFields,
|
|
21
|
+
ScopeField,
|
|
22
|
+
WindowFields,
|
|
23
|
+
type CurrencyFieldComponent,
|
|
24
|
+
} from "./discount-form-fields";
|
|
25
|
+
import { DiscountTargetPicker } from "./discount-target-picker";
|
|
26
|
+
import type { DiscountsFormatters } from "./format";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The create/edit form — shared by the header's create dialog and the row
|
|
30
|
+
* menu's edit dialog, so the fields and the submit live in one place.
|
|
31
|
+
* `editing === null` creates; otherwise it re-states the rule whole.
|
|
32
|
+
*
|
|
33
|
+
* Two kinds of value are deliberately NOT `total-form` values, for one reason:
|
|
34
|
+
* `total-form` holds STRINGS. The two switches (booleans) and the target id
|
|
35
|
+
* lists (arrays) sit in local state beside the container and are merged in at
|
|
36
|
+
* submit.
|
|
37
|
+
*
|
|
38
|
+
* Every rule checked here is ALSO checked on the server. This half exists for
|
|
39
|
+
* the operator's sake — instant, field-attached feedback — never as the
|
|
40
|
+
* authority: the surface re-validates, and a server refusal comes back through
|
|
41
|
+
* `setFieldErrors` and paints the same inputs red.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
interface DiscountFormValues extends Record<string, string> {
|
|
45
|
+
name: string;
|
|
46
|
+
type: string;
|
|
47
|
+
percentOff: string;
|
|
48
|
+
amountOff: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
trigger: string;
|
|
51
|
+
code: string;
|
|
52
|
+
startsAt: string;
|
|
53
|
+
endsAt: string;
|
|
54
|
+
minSubtotal: string;
|
|
55
|
+
usageLimit: string;
|
|
56
|
+
perBuyerLimit: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `YYYY-MM-DD` for a native date input, from the wire's ISO instant. */
|
|
60
|
+
function toDateInput(iso: string | null): string {
|
|
61
|
+
return iso === null ? "" : iso.slice(0, 10);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A new rule's defaults: the most common promotion an operator creates. */
|
|
65
|
+
const BLANK: DiscountFormValues = {
|
|
66
|
+
name: "",
|
|
67
|
+
type: "PERCENTAGE",
|
|
68
|
+
percentOff: "",
|
|
69
|
+
amountOff: "",
|
|
70
|
+
scope: "ORDER",
|
|
71
|
+
trigger: "AUTOMATIC",
|
|
72
|
+
code: "",
|
|
73
|
+
startsAt: "",
|
|
74
|
+
endsAt: "",
|
|
75
|
+
minSubtotal: "",
|
|
76
|
+
usageLimit: "",
|
|
77
|
+
perBuyerLimit: "",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function initialValues(
|
|
81
|
+
editing: DiscountWireRecord | null,
|
|
82
|
+
formatters: DiscountsFormatters,
|
|
83
|
+
): DiscountFormValues {
|
|
84
|
+
if (editing === null) return { ...BLANK };
|
|
85
|
+
const cents = (value: number | null): string =>
|
|
86
|
+
value === null ? "" : formatters.toInput(Number((value / 100).toFixed(2)));
|
|
87
|
+
return {
|
|
88
|
+
name: editing.name,
|
|
89
|
+
type: editing.type,
|
|
90
|
+
percentOff: editing.percentOffBp === null ? "" : formatters.toInput(editing.percentOffBp / 100),
|
|
91
|
+
amountOff: cents(editing.amountOffCents),
|
|
92
|
+
scope: editing.scope,
|
|
93
|
+
trigger: editing.trigger,
|
|
94
|
+
code: editing.code ?? "",
|
|
95
|
+
startsAt: toDateInput(editing.startsAt),
|
|
96
|
+
endsAt: toDateInput(editing.endsAt),
|
|
97
|
+
minSubtotal: cents(editing.minSubtotalCents),
|
|
98
|
+
usageLimit: editing.usageLimit === null ? "" : String(editing.usageLimit),
|
|
99
|
+
perBuyerLimit: editing.perBuyerLimit === null ? "" : String(editing.perBuyerLimit),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The value half: a rate in (0, 100], or an amount above zero. */
|
|
104
|
+
function checkValue(
|
|
105
|
+
values: DiscountFormValues,
|
|
106
|
+
formatters: DiscountsFormatters,
|
|
107
|
+
copy: DiscountsWebCopy,
|
|
108
|
+
): Record<string, string> {
|
|
109
|
+
if (values.type === "PERCENTAGE") {
|
|
110
|
+
const percent = formatters.parseDecimal(values.percentOff);
|
|
111
|
+
if (percent === null || percent <= 0 || percent > 100) {
|
|
112
|
+
return { percentOff: copy.form.invalidPercent };
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
const amount = formatters.parseDecimal(values.amountOff);
|
|
117
|
+
return amount === null || amount <= 0 ? { amountOff: copy.form.invalidAmount } : {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The cross-field rules a per-input schema cannot express. */
|
|
121
|
+
function validate(
|
|
122
|
+
values: DiscountFormValues,
|
|
123
|
+
targets: { categoryIds: string[]; menuItemIds: string[] },
|
|
124
|
+
formatters: DiscountsFormatters,
|
|
125
|
+
copy: DiscountsWebCopy,
|
|
126
|
+
): Record<string, string> {
|
|
127
|
+
const errors: Record<string, string> = { ...checkValue(values, formatters, copy) };
|
|
128
|
+
if (values.trigger === "CODE" && values.code.trim() === "") {
|
|
129
|
+
errors.code = copy.form.codeRequired;
|
|
130
|
+
}
|
|
131
|
+
if (values.scope === "CATEGORY" && targets.categoryIds.length === 0) {
|
|
132
|
+
errors.scope = copy.form.categoryTargetRequired;
|
|
133
|
+
}
|
|
134
|
+
if (values.scope === "ITEM" && targets.menuItemIds.length === 0) {
|
|
135
|
+
errors.scope = copy.form.itemTargetRequired;
|
|
136
|
+
}
|
|
137
|
+
// The window is half-open [start, end): equal dates are an EMPTY window, so
|
|
138
|
+
// the rule is "after", not "not before".
|
|
139
|
+
if (values.startsAt !== "" && values.endsAt !== "" && values.endsAt <= values.startsAt) {
|
|
140
|
+
errors.endsAt = copy.form.endsBeforeStarts;
|
|
141
|
+
}
|
|
142
|
+
return errors;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The values `total-form` cannot hold, kept beside it and merged at submit. */
|
|
146
|
+
function useNonStringState(editing: DiscountWireRecord | null) {
|
|
147
|
+
const [active, setActive] = useState<boolean>(editing?.active ?? true);
|
|
148
|
+
const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
|
|
149
|
+
const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
|
|
150
|
+
const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
|
|
151
|
+
return {
|
|
152
|
+
active,
|
|
153
|
+
stackable,
|
|
154
|
+
categoryIds,
|
|
155
|
+
menuItemIds,
|
|
156
|
+
setActive,
|
|
157
|
+
setStackable,
|
|
158
|
+
setCategoryIds,
|
|
159
|
+
setMenuItemIds,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type NonStringState = ReturnType<typeof useNonStringState>;
|
|
164
|
+
type SubmitHelpers = { setFieldErrors: (errors: Record<string, string | undefined>) => void };
|
|
165
|
+
|
|
166
|
+
export interface DiscountFormProps {
|
|
167
|
+
api: DiscountsApiClient;
|
|
168
|
+
copy: DiscountsWebCopy;
|
|
169
|
+
formatters: DiscountsFormatters;
|
|
170
|
+
currencyField: CurrencyFieldComponent;
|
|
171
|
+
groups: readonly WireTargetGroup[];
|
|
172
|
+
editing: DiscountWireRecord | null;
|
|
173
|
+
onSaved: () => void;
|
|
174
|
+
/** Where a refused or failed write is reported, beyond the operator's screen. */
|
|
175
|
+
onError: (error: unknown, context: string) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the submit handler.
|
|
180
|
+
*
|
|
181
|
+
* A factory rather than a closure inside the component, so the component stays
|
|
182
|
+
* inside the size gate and the validate → send → attribute-the-failure sequence
|
|
183
|
+
* reads as one block.
|
|
184
|
+
*/
|
|
185
|
+
function makeSubmit(
|
|
186
|
+
props: DiscountFormProps,
|
|
187
|
+
extra: NonStringState,
|
|
188
|
+
setError: (message: string | null) => void,
|
|
189
|
+
): (values: DiscountFormValues, helpers: SubmitHelpers) => Promise<void> {
|
|
190
|
+
const { api, copy, formatters, editing, onSaved, onError } = props;
|
|
191
|
+
return async function handleSubmit(values, { setFieldErrors }): Promise<void> {
|
|
192
|
+
setError(null);
|
|
193
|
+
const targets = { categoryIds: extra.categoryIds, menuItemIds: extra.menuItemIds };
|
|
194
|
+
const invalid = validate(values, targets, formatters, copy);
|
|
195
|
+
if (Object.keys(invalid).length > 0) {
|
|
196
|
+
setFieldErrors(invalid);
|
|
197
|
+
setError(copy.form.reviewFields);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const payload: DiscountFormPayload = {
|
|
201
|
+
...values,
|
|
202
|
+
...targets,
|
|
203
|
+
stackable: extra.stackable,
|
|
204
|
+
active: extra.active,
|
|
205
|
+
};
|
|
206
|
+
const result = editing
|
|
207
|
+
? await api.update(editing.id, payload)
|
|
208
|
+
: await api.create(payload);
|
|
209
|
+
if (result.ok) {
|
|
210
|
+
onSaved();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setError(result.error);
|
|
214
|
+
// The server attributes what it can — a duplicate code, a rate out of range
|
|
215
|
+
// — so highlight those inputs instead of only showing the banner.
|
|
216
|
+
if (result.fieldErrors) setFieldErrors(result.fieldErrors);
|
|
217
|
+
onError(result, editing ? "discounts.update" : "discounts.create");
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function DiscountForm(props: DiscountFormProps): JSX.Element {
|
|
222
|
+
const { copy, formatters, currencyField, groups, editing } = props;
|
|
223
|
+
const [error, setError] = useState<string | null>(null);
|
|
224
|
+
const extra = useNonStringState(editing);
|
|
225
|
+
const handleSubmit = makeSubmit(props, extra, setError);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<FormContainer<DiscountFormValues>
|
|
229
|
+
initialValues={initialValues(editing, formatters)}
|
|
230
|
+
schema={{ name: [required(copy.form.nameRequired), minLength(2)] }}
|
|
231
|
+
onSubmit={handleSubmit}
|
|
232
|
+
dataTestId="discount-form"
|
|
233
|
+
>
|
|
234
|
+
<Stack spacing={2}>
|
|
235
|
+
<Fields.TextField name="name" label={copy.form.name} />
|
|
236
|
+
<RuleFields copy={copy} currencyField={currencyField} />
|
|
237
|
+
<ScopeField copy={copy} />
|
|
238
|
+
<DiscountTargetPicker
|
|
239
|
+
groups={groups}
|
|
240
|
+
copy={copy}
|
|
241
|
+
selection={{
|
|
242
|
+
categoryIds: extra.categoryIds,
|
|
243
|
+
menuItemIds: extra.menuItemIds,
|
|
244
|
+
onCategoryIdsChange: extra.setCategoryIds,
|
|
245
|
+
onMenuItemIdsChange: extra.setMenuItemIds,
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
<WindowFields copy={copy} />
|
|
249
|
+
<LimitFields copy={copy} currencyField={currencyField} />
|
|
250
|
+
<DiscountSwitches
|
|
251
|
+
copy={copy}
|
|
252
|
+
active={extra.active}
|
|
253
|
+
stackable={extra.stackable}
|
|
254
|
+
onActive={extra.setActive}
|
|
255
|
+
onStackable={extra.setStackable}
|
|
256
|
+
/>
|
|
257
|
+
{error && (
|
|
258
|
+
<Alert
|
|
259
|
+
variant="danger"
|
|
260
|
+
title={copy.form.saveFailed}
|
|
261
|
+
description={error}
|
|
262
|
+
data-testid="discount-form-error"
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
<SubmitButton dataTestId="discount-form-submit">
|
|
266
|
+
{editing ? copy.form.submitEdit : copy.form.submitCreate}
|
|
267
|
+
</SubmitButton>
|
|
268
|
+
</Stack>
|
|
269
|
+
<FormErrorSnackbar />
|
|
270
|
+
</FormContainer>
|
|
271
|
+
);
|
|
272
|
+
}
|