@12-apps/discounts 1.1.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 +240 -19
- package/package.json +56 -7
- 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 +52 -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 +25 -0
- package/src/server/logging.ts +135 -0
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +147 -34
- package/src/server/targets.ts +102 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { discountWindowState, type DiscountWindowState } from "../engine/kinds";
|
|
2
|
+
|
|
3
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Money, percentages and dates — as ONE locale's reader expects them, and as
|
|
7
|
+
* that same reader would type them back.
|
|
8
|
+
*
|
|
9
|
+
* The origin hard-coded `pt-BR` and `BRL` in five places, which is the shape of
|
|
10
|
+
* copy leaking into logic: the number "12,5" is not a translation of "12.5", it
|
|
11
|
+
* is the same number written for a different reader, and a package that assumes
|
|
12
|
+
* one of them cannot be adopted by the other. So the locale and the currency
|
|
13
|
+
* arrive as config, and the FORMATTERS ARE BUILT ONCE per surface — `Intl`
|
|
14
|
+
* constructors are expensive enough that building one per cell is a measurable
|
|
15
|
+
* cost on a page of eight columns.
|
|
16
|
+
*
|
|
17
|
+
* Parsing is the half that is easy to forget. A form takes what the operator
|
|
18
|
+
* typed, in their own notation, and a `Number("12,5")` is `NaN` — so the
|
|
19
|
+
* decimal separator is DERIVED from the locale rather than assumed, which is
|
|
20
|
+
* the only way "1.234,56" and "1,234.56" can both be read correctly.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The dash a surface renders for "nothing here", shared so it stays one glyph. */
|
|
24
|
+
export const EMPTY = "—";
|
|
25
|
+
|
|
26
|
+
export interface DiscountsFormatters {
|
|
27
|
+
/** Integer cents as money, or {@link EMPTY}. */
|
|
28
|
+
money(cents: number | null): string;
|
|
29
|
+
/** Basis points as a percentage ("1250" → "12,5%"), or {@link EMPTY}. */
|
|
30
|
+
percent(basisPoints: number | null): string;
|
|
31
|
+
/** An ISO instant as a calendar date, read in UTC — see below. */
|
|
32
|
+
date(iso: string | null): string;
|
|
33
|
+
/** What the operator typed, as a number. `null` for blank or unreadable. */
|
|
34
|
+
parseDecimal(typed: string): number | null;
|
|
35
|
+
/** A number as the operator would type it back ("12.5" → "12,5"). */
|
|
36
|
+
toInput(value: number): string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The decimal separator this locale writes, read from `Intl` rather than guessed. */
|
|
40
|
+
function decimalSeparator(locale: string): string {
|
|
41
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1.1);
|
|
42
|
+
return parts.find((part) => part.type === "decimal")?.value ?? ".";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The group separator, which must be STRIPPED before a parse, not replaced. */
|
|
46
|
+
function groupSeparator(locale: string): string {
|
|
47
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1_000);
|
|
48
|
+
return parts.find((part) => part.type === "group")?.value ?? ",";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createFormatters(locale: string, currency: string): DiscountsFormatters {
|
|
52
|
+
const money = new Intl.NumberFormat(locale, { style: "currency", currency });
|
|
53
|
+
const percent = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });
|
|
54
|
+
const decimal = decimalSeparator(locale);
|
|
55
|
+
const group = groupSeparator(locale);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
money: (cents) => (cents === null ? EMPTY : money.format(cents / 100)),
|
|
59
|
+
percent: (basisPoints) =>
|
|
60
|
+
basisPoints === null ? EMPTY : `${percent.format(basisPoints / 100)}%`,
|
|
61
|
+
/**
|
|
62
|
+
* Read in UTC on purpose. The column holds a CALENDAR date at UTC midnight,
|
|
63
|
+
* so formatting it in the browser's zone would show a store west of
|
|
64
|
+
* Greenwich the day before the one they typed — a promotion that appears to
|
|
65
|
+
* start yesterday.
|
|
66
|
+
*/
|
|
67
|
+
date: (iso) => {
|
|
68
|
+
if (iso === null) return EMPTY;
|
|
69
|
+
const parsed = new Date(iso);
|
|
70
|
+
if (Number.isNaN(parsed.getTime())) return iso;
|
|
71
|
+
return parsed.toLocaleDateString(locale, { timeZone: "UTC" });
|
|
72
|
+
},
|
|
73
|
+
parseDecimal: (typed) => {
|
|
74
|
+
const normalized = typed
|
|
75
|
+
.trim()
|
|
76
|
+
.split(group)
|
|
77
|
+
.join("")
|
|
78
|
+
.replace(decimal, ".");
|
|
79
|
+
if (normalized === "") return null;
|
|
80
|
+
const parsed = Number(normalized);
|
|
81
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
82
|
+
},
|
|
83
|
+
toInput: (value) => String(value).replace(".", decimal),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a rule takes off, as an operator reads it. */
|
|
88
|
+
export function formatDiscountValue(
|
|
89
|
+
row: { type: string; percentOffBp: number | null; amountOffCents: number | null; bundlePriceCents?: number | null },
|
|
90
|
+
formatters: DiscountsFormatters,
|
|
91
|
+
): string {
|
|
92
|
+
if (row.type === "PERCENTAGE") return formatters.percent(row.percentOffBp);
|
|
93
|
+
if (row.type === "BUNDLE_PRICE") return formatters.money(row.bundlePriceCents ?? null);
|
|
94
|
+
if (row.type === "FREE_UNITS") return EMPTY;
|
|
95
|
+
return formatters.money(row.amountOffCents);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The active window as a SENTENCE, in whichever of the four shapes applies.
|
|
100
|
+
*
|
|
101
|
+
* All four get their own phrasing because a dash on one side would leave the
|
|
102
|
+
* operator guessing whether the promotion has no start or no end — and those
|
|
103
|
+
* mean very different things when it is not running yet.
|
|
104
|
+
*/
|
|
105
|
+
export function formatWindow(
|
|
106
|
+
row: { startsAt: string | null; endsAt: string | null },
|
|
107
|
+
formatters: DiscountsFormatters,
|
|
108
|
+
copy: DiscountsWebCopy,
|
|
109
|
+
): string {
|
|
110
|
+
const { startsAt, endsAt } = row;
|
|
111
|
+
if (startsAt === null && endsAt === null) return copy.window.always;
|
|
112
|
+
if (endsAt === null) return fill(copy.window.from, { date: formatters.date(startsAt) });
|
|
113
|
+
if (startsAt === null) return fill(copy.window.until, { date: formatters.date(endsAt) });
|
|
114
|
+
return fill(copy.window.between, {
|
|
115
|
+
from: formatters.date(startsAt),
|
|
116
|
+
to: formatters.date(endsAt),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Where a rule sits relative to its window — the engine's predicate, re-exported. */
|
|
121
|
+
export function windowStateOf(
|
|
122
|
+
row: { startsAt: string | null; endsAt: string | null },
|
|
123
|
+
now: Date,
|
|
124
|
+
): DiscountWindowState {
|
|
125
|
+
return discountWindowState(row, now);
|
|
126
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/react` — the promotions ADMIN, as a host mounts it.
|
|
3
|
+
*
|
|
4
|
+
* One factory: {@link createWebDiscounts}. Everything else exported here is
|
|
5
|
+
* either a seam a consumer legitimately needs on its own (the transport, to
|
|
6
|
+
* substitute the backend; the formatters, to render a discount outside these
|
|
7
|
+
* screens) or a type it needs to satisfy the config.
|
|
8
|
+
*
|
|
9
|
+
* The contract a consumer is held to, in two lines:
|
|
10
|
+
*
|
|
11
|
+
* - **Build the surface ONCE, at module scope.** The members are component
|
|
12
|
+
* TYPES; rebuilding per render remounts the whole tree below, and a form
|
|
13
|
+
* then cannot be typed into.
|
|
14
|
+
* - **`onError` is yours.** These screens tell the OPERATOR what went wrong.
|
|
15
|
+
* Whether anyone else learns of it is a host decision, and a no-op default
|
|
16
|
+
* would make "nothing is broken" and "nothing is watching" look identical.
|
|
17
|
+
*/
|
|
18
|
+
export {
|
|
19
|
+
createWebDiscounts,
|
|
20
|
+
useWebDiscounts,
|
|
21
|
+
DiscountsSurface,
|
|
22
|
+
type DiscountsScreenProps,
|
|
23
|
+
type DiscountsWebConfig,
|
|
24
|
+
type WebDiscounts,
|
|
25
|
+
} from "./create-web-discounts";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
missingWebCopy,
|
|
29
|
+
fill,
|
|
30
|
+
type DiscountsActionsCopy,
|
|
31
|
+
type DiscountsCardCopy,
|
|
32
|
+
type DiscountsFormCopy,
|
|
33
|
+
type DiscountsScreenCopy,
|
|
34
|
+
type DiscountsTargetCopy,
|
|
35
|
+
type DiscountsVocabularyCopy,
|
|
36
|
+
type DiscountsWebCopy,
|
|
37
|
+
type DiscountsWindowCopy,
|
|
38
|
+
} from "./copy";
|
|
39
|
+
|
|
40
|
+
export { PT_BR_DISCOUNTS_WEB_COPY } from "./pt-BR";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
createFormatters,
|
|
44
|
+
formatDiscountValue,
|
|
45
|
+
formatWindow,
|
|
46
|
+
windowStateOf,
|
|
47
|
+
EMPTY,
|
|
48
|
+
type DiscountsFormatters,
|
|
49
|
+
} from "./format";
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
createDiscountsApiClient,
|
|
53
|
+
toWriteBody,
|
|
54
|
+
type DiscountFormPayload,
|
|
55
|
+
type DiscountsApiClient,
|
|
56
|
+
type DiscountsPage,
|
|
57
|
+
type DiscountsPagination,
|
|
58
|
+
type DiscountWireRecord,
|
|
59
|
+
type WireTarget,
|
|
60
|
+
type WireTargetGroup,
|
|
61
|
+
} from "./api";
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
httpDiscountsTransport,
|
|
65
|
+
DiscountsHttpError,
|
|
66
|
+
type DiscountsResult,
|
|
67
|
+
type DiscountsTransport,
|
|
68
|
+
} from "./transport";
|
|
69
|
+
|
|
70
|
+
export { toListItem, type DiscountListItem } from "./row";
|
|
71
|
+
|
|
72
|
+
export type { CurrencyFieldComponent } from "./discount-form-fields";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack for the discounts admin screens.
|
|
5
|
+
*
|
|
6
|
+
* A NAMED PACK a host passes by hand, never a default: choosing a language is a
|
|
7
|
+
* line in the adopter's diff. See `./copy` for why the port has this many keys.
|
|
8
|
+
*
|
|
9
|
+
* Doubles as the SHAPE `missingWebCopy` walks, which is the only reason this
|
|
10
|
+
* module is imported by `createWebDiscounts` — the key names travel, the words
|
|
11
|
+
* do not.
|
|
12
|
+
*/
|
|
13
|
+
export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
|
|
14
|
+
screen: {
|
|
15
|
+
title: "Descontos",
|
|
16
|
+
aboutTitle: "Sobre os descontos",
|
|
17
|
+
aboutBody:
|
|
18
|
+
"Crie promoções automáticas ou cupons com código, para o pedido inteiro, uma categoria, produtos específicos ou um combo.",
|
|
19
|
+
create: "Novo desconto",
|
|
20
|
+
empty: "Nenhum desconto cadastrado.",
|
|
21
|
+
exportFileName: "descontos",
|
|
22
|
+
loading: "Carregando descontos…",
|
|
23
|
+
loadFailed: "Não foi possível carregar os descontos",
|
|
24
|
+
retry: "Tentar novamente",
|
|
25
|
+
columns: {
|
|
26
|
+
name: "Nome",
|
|
27
|
+
value: "Desconto",
|
|
28
|
+
type: "Tipo",
|
|
29
|
+
scope: "Abrangência",
|
|
30
|
+
trigger: "Ativação",
|
|
31
|
+
window: "Vigência",
|
|
32
|
+
code: "Código",
|
|
33
|
+
usageCount: "Usos",
|
|
34
|
+
active: "Ativo",
|
|
35
|
+
},
|
|
36
|
+
yes: "Sim",
|
|
37
|
+
no: "Não",
|
|
38
|
+
},
|
|
39
|
+
labels: {
|
|
40
|
+
type: {
|
|
41
|
+
PERCENTAGE: "Porcentagem",
|
|
42
|
+
FIXED_AMOUNT: "Valor fixo",
|
|
43
|
+
BUNDLE_PRICE: "Preço de combo",
|
|
44
|
+
FREE_UNITS: "Itens grátis",
|
|
45
|
+
},
|
|
46
|
+
scope: {
|
|
47
|
+
ORDER: "Pedido",
|
|
48
|
+
CATEGORY: "Categoria",
|
|
49
|
+
ITEM: "Item",
|
|
50
|
+
COMBO: "Combo",
|
|
51
|
+
},
|
|
52
|
+
trigger: {
|
|
53
|
+
AUTOMATIC: "Automático",
|
|
54
|
+
CODE: "Código",
|
|
55
|
+
},
|
|
56
|
+
window: {
|
|
57
|
+
RUNNING: "Vigente",
|
|
58
|
+
SCHEDULED: "Agendado",
|
|
59
|
+
ENDED: "Encerrado",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
window: {
|
|
63
|
+
always: "Sem prazo",
|
|
64
|
+
from: "A partir de {date}",
|
|
65
|
+
until: "Até {date}",
|
|
66
|
+
between: "{from} a {to}",
|
|
67
|
+
},
|
|
68
|
+
form: {
|
|
69
|
+
createTitle: "Novo desconto",
|
|
70
|
+
editTitle: "Editar desconto",
|
|
71
|
+
submitCreate: "Criar desconto",
|
|
72
|
+
submitEdit: "Salvar alterações",
|
|
73
|
+
name: "Nome da promoção",
|
|
74
|
+
type: "Tipo",
|
|
75
|
+
percentOff: "Desconto (%)",
|
|
76
|
+
percentPlaceholder: "10",
|
|
77
|
+
amountOff: "Valor do desconto",
|
|
78
|
+
trigger: "Ativação",
|
|
79
|
+
code: "Código do cupom",
|
|
80
|
+
codePlaceholder: "BEMVINDO10",
|
|
81
|
+
scope: "Abrangência",
|
|
82
|
+
startsAt: "Início",
|
|
83
|
+
endsAt: "Término",
|
|
84
|
+
minSubtotal: "Pedido mínimo",
|
|
85
|
+
usageLimit: "Limite de usos",
|
|
86
|
+
perBuyerLimit: "Limite por cliente",
|
|
87
|
+
active: "Ativo",
|
|
88
|
+
activeHint: "Desligue para pausar a promoção sem excluí-la.",
|
|
89
|
+
stackable: "Acumulável",
|
|
90
|
+
stackableHint:
|
|
91
|
+
"Quando desligado, esta promoção é exclusiva: se vencer, nenhuma outra é aplicada.",
|
|
92
|
+
reviewFields: "Revise os campos destacados.",
|
|
93
|
+
saveFailed: "Não foi possível salvar o desconto",
|
|
94
|
+
nameRequired: "Informe o nome da promoção.",
|
|
95
|
+
invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
|
|
96
|
+
invalidAmount: "Informe um valor de desconto maior que zero.",
|
|
97
|
+
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
98
|
+
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
99
|
+
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
100
|
+
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
101
|
+
},
|
|
102
|
+
targets: {
|
|
103
|
+
pick: "{collection} com desconto",
|
|
104
|
+
search: "Buscar em {collection}…",
|
|
105
|
+
required: "Selecione ao menos uma opção.",
|
|
106
|
+
},
|
|
107
|
+
actions: {
|
|
108
|
+
menu: "Ações",
|
|
109
|
+
edit: "Editar",
|
|
110
|
+
delete: "Excluir",
|
|
111
|
+
deleteTitle: "Excluir o desconto?",
|
|
112
|
+
deleteDescription: "O desconto para de valer na vitrine e não pode ser restaurado.",
|
|
113
|
+
deleteManyTitle: "Excluir {count} descontos?",
|
|
114
|
+
deleteManyDescription: "Deixam de valer na vitrine e não podem ser restaurados.",
|
|
115
|
+
deleteFailed: "Não foi possível excluir o desconto.",
|
|
116
|
+
actionFailed: "Não foi possível concluir a ação",
|
|
117
|
+
},
|
|
118
|
+
card: {
|
|
119
|
+
paused: "Pausado",
|
|
120
|
+
ruleHeading: "Regra",
|
|
121
|
+
targetsHeading: "Alvos",
|
|
122
|
+
wholeOrder: "pedido inteiro",
|
|
123
|
+
oneTarget: "1 alvo",
|
|
124
|
+
manyTargets: "{count} alvos",
|
|
125
|
+
withCode: "código {code}",
|
|
126
|
+
noTargets: "Nenhum alvo",
|
|
127
|
+
usage: "Usos",
|
|
128
|
+
minSubtotal: "Pedido mínimo",
|
|
129
|
+
perBuyerLimit: "Limite por cliente",
|
|
130
|
+
unlimited: "Sem limite",
|
|
131
|
+
},
|
|
132
|
+
};
|
package/src/react/row.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DiscountWindowState } from "../engine/kinds";
|
|
2
|
+
|
|
3
|
+
import type { DiscountWireRecord } from "./api";
|
|
4
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
5
|
+
import { formatDiscountValue, formatWindow, windowStateOf, type DiscountsFormatters } from "./format";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One wire record plus the three strings the grid, the cards and the export all
|
|
9
|
+
* show.
|
|
10
|
+
*
|
|
11
|
+
* Derived ONCE per page render rather than inside a cell, and that is two
|
|
12
|
+
* decisions in one. Basis-point arithmetic and date formatting run once per row
|
|
13
|
+
* instead of once per re-render; and the grid, the card and the exported file
|
|
14
|
+
* then read the SAME string, so the three cannot disagree about what a
|
|
15
|
+
* promotion is worth — which they did, in the origin, until this shape existed.
|
|
16
|
+
*/
|
|
17
|
+
export interface DiscountListItem extends Record<string, unknown> {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
percentOffBp: number | null;
|
|
22
|
+
amountOffCents: number | null;
|
|
23
|
+
bundlePriceCents: number | null;
|
|
24
|
+
scope: string;
|
|
25
|
+
trigger: string;
|
|
26
|
+
code: string | null;
|
|
27
|
+
startsAt: string | null;
|
|
28
|
+
endsAt: string | null;
|
|
29
|
+
minSubtotalCents: number | null;
|
|
30
|
+
usageLimit: number | null;
|
|
31
|
+
perBuyerLimit: number | null;
|
|
32
|
+
usageCount: number;
|
|
33
|
+
stackable: boolean;
|
|
34
|
+
active: boolean;
|
|
35
|
+
categoryIds: string[];
|
|
36
|
+
menuItemIds: string[];
|
|
37
|
+
/** Pre-formatted: what it takes off. */
|
|
38
|
+
valueLabel: string;
|
|
39
|
+
/** Pre-formatted: the validity sentence. */
|
|
40
|
+
windowLabel: string;
|
|
41
|
+
/** Where it sits relative to its window, as a stable wire token. */
|
|
42
|
+
windowState: DiscountWindowState;
|
|
43
|
+
/** The record itself, for the edit dialog to re-seed from. */
|
|
44
|
+
record: DiscountWireRecord;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param now ONE instant for the whole page, passed in rather than read here:
|
|
49
|
+
* two rows judged against two different "now"s could disagree about which of
|
|
50
|
+
* them is still running, and on a list of forty that difference is invisible.
|
|
51
|
+
*/
|
|
52
|
+
export function toListItem(
|
|
53
|
+
record: DiscountWireRecord,
|
|
54
|
+
formatters: DiscountsFormatters,
|
|
55
|
+
copy: DiscountsWebCopy,
|
|
56
|
+
now: Date,
|
|
57
|
+
): DiscountListItem {
|
|
58
|
+
return {
|
|
59
|
+
...record,
|
|
60
|
+
bundlePriceCents: record.bundlePriceCents ?? null,
|
|
61
|
+
valueLabel: formatDiscountValue(record, formatters),
|
|
62
|
+
windowLabel: formatWindow(record, formatters, copy),
|
|
63
|
+
windowState: windowStateOf(record, now),
|
|
64
|
+
record,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataViewColumn,
|
|
3
|
+
DataViewQuery,
|
|
4
|
+
DataViewState,
|
|
5
|
+
FilterFieldConfig,
|
|
6
|
+
} from "@12-apps/ui/data-display/DataViews";
|
|
7
|
+
import type { ExportColumn } from "@12-apps/ui/utils";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DISCOUNT_SCOPES,
|
|
11
|
+
DISCOUNT_TRIGGERS,
|
|
12
|
+
DISCOUNT_TYPES,
|
|
13
|
+
DISCOUNT_WINDOW_STATES,
|
|
14
|
+
} from "../engine/kinds";
|
|
15
|
+
|
|
16
|
+
import type { DiscountsWebCopy } from "./copy";
|
|
17
|
+
import { EMPTY } from "./format";
|
|
18
|
+
import type { DiscountListItem } from "./row";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The grid's columns, pills and export, plus the URL ⇄ query plumbing.
|
|
22
|
+
*
|
|
23
|
+
* The pill VALUES are the WIRE values and only the labels are translated. That
|
|
24
|
+
* is what lets a selection be forwarded straight into the backend query with no
|
|
25
|
+
* second mapping table to drift out of step with the search config — and it is
|
|
26
|
+
* why `DISCOUNT_WINDOW_STATES` are English tokens rather than the words on the
|
|
27
|
+
* pill: the origin filtered on its own pt-BR labels, so its wire protocol was
|
|
28
|
+
* its language.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The grid-owned URL params, forwarded verbatim to the backend query. */
|
|
32
|
+
const OWNED_PARAMS = [
|
|
33
|
+
"q",
|
|
34
|
+
"page",
|
|
35
|
+
"sort",
|
|
36
|
+
"type_in",
|
|
37
|
+
"scope_in",
|
|
38
|
+
"trigger_in",
|
|
39
|
+
"active",
|
|
40
|
+
"window",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/** A CODE rule is identified by its coupon, not by the word "code". */
|
|
44
|
+
function triggerCell(row: DiscountListItem, copy: DiscountsWebCopy): string {
|
|
45
|
+
if (row.trigger === "CODE") return row.code ?? EMPTY;
|
|
46
|
+
return copy.labels.trigger[row.trigger as keyof typeof copy.labels.trigger] ?? row.trigger;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One label out of a pack, falling back to the raw stored value. */
|
|
50
|
+
function labelled(labels: Readonly<Record<string, string>>, value: string): string {
|
|
51
|
+
// The closed sets live in the database as CHECK constraints, so a value that
|
|
52
|
+
// arrives unknown means the set was widened server-side — and a list that
|
|
53
|
+
// renders `PERCENTAGE` is far better than one that renders a crash.
|
|
54
|
+
return labels[value] ?? value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function discountColumns(copy: DiscountsWebCopy): DataViewColumn<DiscountListItem>[] {
|
|
58
|
+
return [
|
|
59
|
+
{ id: "name", header: copy.screen.columns.name, accessor: "name", searchable: true },
|
|
60
|
+
// Pre-formatted by the page: a cell must not do arithmetic on basis points.
|
|
61
|
+
{ id: "value", header: copy.screen.columns.value, accessor: (row) => row.valueLabel },
|
|
62
|
+
{
|
|
63
|
+
id: "type",
|
|
64
|
+
header: copy.screen.columns.type,
|
|
65
|
+
accessor: (row) => labelled(copy.labels.type, row.type),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "scope",
|
|
69
|
+
header: copy.screen.columns.scope,
|
|
70
|
+
accessor: (row) => labelled(copy.labels.scope, row.scope),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "trigger",
|
|
74
|
+
header: copy.screen.columns.trigger,
|
|
75
|
+
accessor: (row) => triggerCell(row, copy),
|
|
76
|
+
searchable: true,
|
|
77
|
+
},
|
|
78
|
+
{ id: "window", header: copy.screen.columns.window, accessor: (row) => row.windowLabel },
|
|
79
|
+
{
|
|
80
|
+
id: "usageCount",
|
|
81
|
+
header: copy.screen.columns.usageCount,
|
|
82
|
+
accessor: (row) => String(row.usageCount),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "active",
|
|
86
|
+
header: copy.screen.columns.active,
|
|
87
|
+
accessor: (row) => (row.active ? copy.screen.yes : copy.screen.no),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One closed set as filter options: wire values, translated labels. */
|
|
93
|
+
function pillOptions<TKey extends string>(
|
|
94
|
+
values: readonly TKey[],
|
|
95
|
+
labels: Readonly<Record<TKey, string>>,
|
|
96
|
+
): { value: string; label: string }[] {
|
|
97
|
+
return values.map((value) => ({ value, label: labels[value] }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function discountFilters(copy: DiscountsWebCopy): FilterFieldConfig<DiscountListItem>[] {
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
id: "type",
|
|
104
|
+
label: copy.screen.columns.type,
|
|
105
|
+
accessor: (row) => row.type,
|
|
106
|
+
options: pillOptions(DISCOUNT_TYPES, copy.labels.type),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "scope",
|
|
110
|
+
label: copy.screen.columns.scope,
|
|
111
|
+
accessor: (row) => row.scope,
|
|
112
|
+
options: pillOptions(DISCOUNT_SCOPES, copy.labels.scope),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "trigger",
|
|
116
|
+
label: copy.screen.columns.trigger,
|
|
117
|
+
accessor: (row) => row.trigger,
|
|
118
|
+
options: pillOptions(DISCOUNT_TRIGGERS, copy.labels.trigger),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "active",
|
|
122
|
+
label: copy.screen.columns.active,
|
|
123
|
+
accessor: (row) => (row.active ? "true" : "false"),
|
|
124
|
+
options: [
|
|
125
|
+
{ value: "true", label: copy.screen.yes },
|
|
126
|
+
{ value: "false", label: copy.screen.no },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "window",
|
|
131
|
+
label: copy.screen.columns.window,
|
|
132
|
+
accessor: (row) => row.windowState,
|
|
133
|
+
options: pillOptions(DISCOUNT_WINDOW_STATES, copy.labels.window),
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function discountExportColumns(
|
|
139
|
+
copy: DiscountsWebCopy,
|
|
140
|
+
): ExportColumn<DiscountListItem>[] {
|
|
141
|
+
return [
|
|
142
|
+
{ header: copy.screen.columns.name, value: (row) => row.name },
|
|
143
|
+
{ header: copy.screen.columns.value, value: (row) => row.valueLabel },
|
|
144
|
+
{ header: copy.screen.columns.type, value: (row) => labelled(copy.labels.type, row.type) },
|
|
145
|
+
{ header: copy.screen.columns.scope, value: (row) => labelled(copy.labels.scope, row.scope) },
|
|
146
|
+
{
|
|
147
|
+
header: copy.screen.columns.trigger,
|
|
148
|
+
value: (row) => labelled(copy.labels.trigger, row.trigger),
|
|
149
|
+
},
|
|
150
|
+
{ header: copy.screen.columns.code, value: (row) => row.code ?? "" },
|
|
151
|
+
{ header: copy.screen.columns.window, value: (row) => row.windowLabel },
|
|
152
|
+
{ header: copy.screen.columns.usageCount, value: (row) => String(row.usageCount) },
|
|
153
|
+
{
|
|
154
|
+
header: copy.screen.columns.active,
|
|
155
|
+
value: (row) => (row.active ? copy.screen.yes : copy.screen.no),
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A comma-joined `_in` list, or undefined when the pill is untouched. */
|
|
161
|
+
function inList(values: string[] | undefined): string | undefined {
|
|
162
|
+
return values && values.length > 0 ? values.join(",") : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A single-choice pill, or undefined when both or neither option is picked. */
|
|
166
|
+
function oneOf(values: string[] | undefined, allowed: readonly string[]): string | undefined {
|
|
167
|
+
const picked = values?.length === 1 ? values[0] : undefined;
|
|
168
|
+
return picked !== undefined && allowed.includes(picked) ? picked : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Map the grid's query into the backend's search params. An omitted key clears
|
|
173
|
+
* its param from the URL, so a dropped filter never lingers.
|
|
174
|
+
*/
|
|
175
|
+
export function discountsQueryToParams(
|
|
176
|
+
query: DataViewQuery,
|
|
177
|
+
): Record<string, string | undefined> {
|
|
178
|
+
const sort = query.sortBy[0];
|
|
179
|
+
return {
|
|
180
|
+
q: query.search || undefined,
|
|
181
|
+
page: query.page > 1 ? String(query.page) : undefined,
|
|
182
|
+
sort: sort && sort.dir ? `${sort.id}:${sort.dir}` : undefined,
|
|
183
|
+
type_in: inList(query.pills.type),
|
|
184
|
+
scope_in: inList(query.pills.scope),
|
|
185
|
+
trigger_in: inList(query.pills.trigger),
|
|
186
|
+
active: oneOf(query.pills.active, ["true", "false"]),
|
|
187
|
+
window: oneOf(query.pills.window, DISCOUNT_WINDOW_STATES),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The filter pills encoded in the URL. */
|
|
192
|
+
function pillsFromParams(params: URLSearchParams): Record<string, string[]> {
|
|
193
|
+
const pills: Record<string, string[]> = {};
|
|
194
|
+
for (const [param, id] of [
|
|
195
|
+
["type_in", "type"],
|
|
196
|
+
["scope_in", "scope"],
|
|
197
|
+
["trigger_in", "trigger"],
|
|
198
|
+
] as const) {
|
|
199
|
+
const raw = params.get(param);
|
|
200
|
+
if (raw) pills[id] = raw.split(",");
|
|
201
|
+
}
|
|
202
|
+
const active = params.get("active");
|
|
203
|
+
if (active === "true" || active === "false") pills.active = [active];
|
|
204
|
+
const window = params.get("window");
|
|
205
|
+
if (window && DISCOUNT_WINDOW_STATES.some((state) => state === window)) pills.window = [window];
|
|
206
|
+
return pills;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Seed the grid's client state from the URL, so a bookmarked filtered link is
|
|
211
|
+
* reflected on first render — otherwise the empty client state emits an
|
|
212
|
+
* unfiltered query and wipes the params the operator arrived with.
|
|
213
|
+
*/
|
|
214
|
+
export function discountsAppliedState(
|
|
215
|
+
params: URLSearchParams,
|
|
216
|
+
copy: DiscountsWebCopy,
|
|
217
|
+
): DataViewState {
|
|
218
|
+
const [sortField, sortDir] = (params.get("sort") ?? "").split(":");
|
|
219
|
+
const sortBy: DataViewState["sortBy"] =
|
|
220
|
+
sortField && sortDir ? [{ id: sortField, dir: sortDir === "desc" ? "desc" : "asc" }] : [];
|
|
221
|
+
return {
|
|
222
|
+
search: params.get("q") ?? "",
|
|
223
|
+
pills: pillsFromParams(params),
|
|
224
|
+
ranges: {},
|
|
225
|
+
sortBy,
|
|
226
|
+
visibleColumns: discountColumns(copy).map((column) => column.id),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The discounts query string for the current URL — only the params we own. */
|
|
231
|
+
export function discountsSearch(params: URLSearchParams): string {
|
|
232
|
+
const query = new URLSearchParams();
|
|
233
|
+
for (const key of OWNED_PARAMS) {
|
|
234
|
+
const value = params.get(key);
|
|
235
|
+
if (value) query.set(key, value);
|
|
236
|
+
}
|
|
237
|
+
return query.toString();
|
|
238
|
+
}
|