@12-apps/discounts 1.11.0 → 1.13.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 (60) hide show
  1. package/dist/e2e/globs.d.ts +40 -0
  2. package/dist/e2e/globs.d.ts.map +1 -0
  3. package/dist/e2e/globs.js +66 -0
  4. package/dist/e2e/globs.js.map +1 -0
  5. package/dist/e2e/index.d.ts +12 -0
  6. package/dist/e2e/index.d.ts.map +1 -0
  7. package/dist/e2e/index.js +12 -0
  8. package/dist/e2e/index.js.map +1 -0
  9. package/dist/e2e/steps/journey.steps.d.ts +2 -0
  10. package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
  11. package/dist/e2e/steps/journey.steps.js +146 -0
  12. package/dist/e2e/steps/journey.steps.js.map +1 -0
  13. package/dist/e2e/world.d.ts +76 -0
  14. package/dist/e2e/world.d.ts.map +1 -0
  15. package/dist/e2e/world.js +25 -0
  16. package/dist/e2e/world.js.map +1 -0
  17. package/features/promotions.feature +52 -0
  18. package/package.json +28 -9
  19. package/prisma/discounts.prisma +23 -0
  20. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  21. package/src/e2e/globs.ts +70 -0
  22. package/src/e2e/index.ts +16 -0
  23. package/src/e2e/steps/journey.steps.ts +170 -0
  24. package/src/e2e/world.ts +93 -0
  25. package/src/engine/combo-offer.ts +21 -3
  26. package/src/engine/eligibility.ts +76 -0
  27. package/src/engine/en-US.ts +1 -0
  28. package/src/engine/evaluate.ts +8 -2
  29. package/src/engine/kinds.ts +7 -0
  30. package/src/engine/preview.ts +32 -6
  31. package/src/engine/pt-BR.ts +8 -0
  32. package/src/engine/schedule.ts +191 -0
  33. package/src/engine/teaser.ts +150 -0
  34. package/src/engine/types.ts +36 -0
  35. package/src/index.ts +32 -0
  36. package/src/manifest/index.ts +12 -0
  37. package/src/react/api.ts +13 -0
  38. package/src/react/copy.ts +3 -0
  39. package/src/react/create-web-discounts.tsx +39 -1
  40. package/src/react/discount-actions-menu.tsx +10 -0
  41. package/src/react/discount-form-fields.tsx +90 -6
  42. package/src/react/discount-form-validate.ts +40 -0
  43. package/src/react/discount-form.tsx +38 -2
  44. package/src/react/discounts-screen.tsx +12 -28
  45. package/src/react/en-US.ts +44 -0
  46. package/src/react/format.ts +15 -0
  47. package/src/react/pt-BR.ts +44 -0
  48. package/src/react/row.ts +64 -0
  49. package/src/react/schedule-builder.tsx +253 -0
  50. package/src/react/schedule-copy.ts +74 -0
  51. package/src/react/schedule-summary.ts +100 -0
  52. package/src/react/screen-bindings.ts +57 -0
  53. package/src/react/table-config.ts +15 -0
  54. package/src/server/copy.ts +3 -0
  55. package/src/server/en-US.ts +1 -0
  56. package/src/server/mcp.ts +32 -0
  57. package/src/server/pt-BR.ts +1 -0
  58. package/src/server/store.ts +8 -0
  59. package/src/server/validate.ts +43 -0
  60. package/src/timezone.ts +96 -0
@@ -0,0 +1,40 @@
1
+ /** Every packaged `.feature`, for `defineBddConfig({ features })`. */
2
+ export declare const discountsFeatures: string;
3
+ /**
4
+ * The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
5
+ * optional decoration.
6
+ *
7
+ * bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
8
+ * `outputDir`. Left unset it defaults to the config's own directory, so a
9
+ * feature living under `node_modules/...` compiles to a path Playwright then
10
+ * IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
11
+ *
12
+ * The result is the worst kind of green: bddgen reports the features compiled,
13
+ * Playwright collects zero specs from them, and the run passes with the whole
14
+ * packaged suite silently absent.
15
+ *
16
+ * A host running SEVERAL packaged suites gives `featuresRoot` the directory they
17
+ * all sit under, since bddgen takes exactly one — and that is safe to do,
18
+ * because a feature outside it is a hard exit from bddgen, never a quiet
19
+ * omission.
20
+ */
21
+ export declare const discountsFeaturesRoot: string;
22
+ /**
23
+ * Every packaged step definition, for `defineBddConfig({ steps })`.
24
+ *
25
+ * COMPILED JavaScript, and that is the reason this package has a build step at
26
+ * all while everything else it exports is raw `.ts` through the `exports` map.
27
+ * Those entries are consumed by an application's BUNDLER, which transpiles
28
+ * whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
29
+ * imports this module, and bddgen imports the step files — and Node refuses to
30
+ * strip types from anything under `node_modules`
31
+ * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
32
+ * does not rescue them either; it skips `node_modules` by design.
33
+ *
34
+ * The host's own steps glob must ALSO be listed — that is where its
35
+ * `defineDiscountsWorld` call lives, and playwright-bdd imports every step
36
+ * file before any scenario runs, which is what makes the registration land in
37
+ * time in every worker.
38
+ */
39
+ export declare const discountsSteps: string;
40
+ //# sourceMappingURL=globs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"globs.d.ts","sourceRoot":"","sources":["../../src/e2e/globs.ts"],"names":[],"mappings":"AA6BA,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,EAAE,MAAqD,CAAC;AAEtF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAwC,CAAC;AAE7E;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,cAAc,EAAE,MAAsD,CAAC"}
@@ -0,0 +1,66 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join } from 'node:path';
3
+ /**
4
+ * Where this package's journeys and their steps live, ON DISK, in the consumer's
5
+ * `node_modules`.
6
+ *
7
+ * `defineBddConfig` takes filesystem globs, not module specifiers — it hands
8
+ * them to a glob matcher, so `'@12-apps/discounts/features/**'` matches
9
+ * nothing and, worse, matches nothing SILENTLY: bddgen compiles the features it
10
+ * found, finds none, and the run is green with zero journeys.
11
+ *
12
+ * So the paths are RESOLVED here instead of written down by every consumer. A
13
+ * host that hard-codes `node_modules/@12-apps/discounts/...` is broken by
14
+ * pnpm's nested store, by a workspace link, and by this package's own layout
15
+ * changing; resolving from the package's own entry point is correct under all
16
+ * three.
17
+ */
18
+ const require_ = createRequire(import.meta.url);
19
+ /**
20
+ * The package root, found from a file this package definitely exports.
21
+ * `./package.json` is exported precisely so this lookup needs no guess about
22
+ * directory depth.
23
+ */
24
+ function packageRoot() {
25
+ return dirname(require_.resolve('@12-apps/discounts/package.json'));
26
+ }
27
+ /** Every packaged `.feature`, for `defineBddConfig({ features })`. */
28
+ export const discountsFeatures = join(packageRoot(), 'features/**/*.feature');
29
+ /**
30
+ * The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
31
+ * optional decoration.
32
+ *
33
+ * bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
34
+ * `outputDir`. Left unset it defaults to the config's own directory, so a
35
+ * feature living under `node_modules/...` compiles to a path Playwright then
36
+ * IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
37
+ *
38
+ * The result is the worst kind of green: bddgen reports the features compiled,
39
+ * Playwright collects zero specs from them, and the run passes with the whole
40
+ * packaged suite silently absent.
41
+ *
42
+ * A host running SEVERAL packaged suites gives `featuresRoot` the directory they
43
+ * all sit under, since bddgen takes exactly one — and that is safe to do,
44
+ * because a feature outside it is a hard exit from bddgen, never a quiet
45
+ * omission.
46
+ */
47
+ export const discountsFeaturesRoot = join(packageRoot(), 'features');
48
+ /**
49
+ * Every packaged step definition, for `defineBddConfig({ steps })`.
50
+ *
51
+ * COMPILED JavaScript, and that is the reason this package has a build step at
52
+ * all while everything else it exports is raw `.ts` through the `exports` map.
53
+ * Those entries are consumed by an application's BUNDLER, which transpiles
54
+ * whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
55
+ * imports this module, and bddgen imports the step files — and Node refuses to
56
+ * strip types from anything under `node_modules`
57
+ * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
58
+ * does not rescue them either; it skips `node_modules` by design.
59
+ *
60
+ * The host's own steps glob must ALSO be listed — that is where its
61
+ * `defineDiscountsWorld` call lives, and playwright-bdd imports every step
62
+ * file before any scenario runs, which is what makes the registration land in
63
+ * time in every worker.
64
+ */
65
+ export const discountsSteps = join(packageRoot(), 'dist/e2e/steps/**/*.js');
66
+ //# sourceMappingURL=globs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"globs.js","sourceRoot":"","sources":["../../src/e2e/globs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;;;;;;;;;GAcG;AACH,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAEhD;;;;GAIG;AACH,SAAS,WAAW;IAClB,OAAO,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAC,CAAC;AACtE,CAAC;AAED,sEAAsE;AACtE,MAAM,CAAC,MAAM,iBAAiB,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,uBAAuB,CAAC,CAAC;AAEtF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAAC;AAE7E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,wBAAwB,CAAC,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
3
+ * host implements to run them.
4
+ *
5
+ * The `e2e` capability the manifest declares. A host adds the three globs to
6
+ * its bdd config and calls `defineDiscountsWorld` from inside its own steps
7
+ * glob; every scenario this package ships then runs in that host, including the
8
+ * ones added after it integrated. Nothing is copied, so nothing can rot.
9
+ */
10
+ export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
11
+ export { defineDiscountsWorld, discountsWorld, type DiscountsFixtures, type DiscountsWorld, } from './world.js';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/e2e/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,KAAK,iBAAiB,EACtB,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
3
+ * host implements to run them.
4
+ *
5
+ * The `e2e` capability the manifest declares. A host adds the three globs to
6
+ * its bdd config and calls `defineDiscountsWorld` from inside its own steps
7
+ * glob; every scenario this package ships then runs in that host, including the
8
+ * ones added after it integrated. Nothing is copied, so nothing can rot.
9
+ */
10
+ export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
11
+ export { defineDiscountsWorld, discountsWorld, } from './world.js';
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/e2e/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,oBAAoB,EACpB,cAAc,GAGf,MAAM,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=journey.steps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journey.steps.d.ts","sourceRoot":"","sources":["../../../src/e2e/steps/journey.steps.ts"],"names":[],"mappings":""}
@@ -0,0 +1,146 @@
1
+ import { expect } from '@playwright/test';
2
+ import { createBdd } from 'playwright-bdd';
3
+ import { discountsWorld } from '../world.js';
4
+ /**
5
+ * The packaged journeys' step definitions.
6
+ *
7
+ * Every locator is a test id THIS package's own components render, which is
8
+ * what makes the scenarios portable: `discounts-grid`, `discount-dialog`,
9
+ * `discount-action-edit`, `combo-slots` and the `total-form-field-*` family
10
+ * mean the same thing in any app that mounts `createWebDiscounts`. Everything a
11
+ * host owns — sign-in, routing, how a percentage reads — arrives through the
12
+ * world port rather than being written down here.
13
+ *
14
+ * ## Why `package.json` declares this file under `sideEffects`
15
+ *
16
+ * Every Given/When/Then below runs at IMPORT time — registering a step is the
17
+ * whole point of the module, and it exports nothing anybody imports by name. To
18
+ * a bundler doing tree-shaking that reads as dead weight, and dropping it is
19
+ * licensed: the result is a suite where bddgen reports the features compiled
20
+ * and every scenario then fails on an undefined step.
21
+ *
22
+ * Not one step asserts a SENTENCE. The origin host's spec did — it clicked
23
+ * `menuitem { name: 'Editar' }` and a `Salvar alterações` button — which is why
24
+ * it could only ever have run in that host: copy is required config, so the next
25
+ * adopter's words are different by construction and the spec would fail for a
26
+ * reason unrelated to promotions working.
27
+ */
28
+ const { Given, When, Then } = createBdd();
29
+ const GRID = 'discounts-grid';
30
+ const DIALOG = 'discount-dialog';
31
+ /** The name the current scenario minted, so later steps can find its row. */
32
+ let currentName = '';
33
+ Given('I am signed in as somebody who may manage promotions', async ({ page }) => {
34
+ await discountsWorld().signInAsManager(page);
35
+ });
36
+ When('I open the promotions screen', async ({ page }) => {
37
+ await discountsWorld().openDiscountsScreen(page);
38
+ await expect(page.getByTestId(GRID)).toBeVisible();
39
+ });
40
+ Then('the promotions grid is visible', async ({ page }) => {
41
+ await expect(page.getByTestId(GRID)).toBeVisible();
42
+ });
43
+ /** The create dialog, open and ready to be filled. */
44
+ async function openCreateDialog(page) {
45
+ await page.getByTestId('new-discount-button').click();
46
+ const dialog = page.getByTestId(DIALOG);
47
+ await expect(dialog).toBeVisible();
48
+ return dialog;
49
+ }
50
+ When('I compose a percentage promotion', async ({ page }) => {
51
+ const { newName, percent } = discountsWorld().fixtures;
52
+ currentName = newName('Journey');
53
+ const dialog = await openCreateDialog(page);
54
+ await dialog.getByTestId('total-form-field-name').fill(currentName);
55
+ // Percentage is the default kind; clicking it anyway is the interaction that
56
+ // proves the toggle drives which value field is mounted.
57
+ await dialog.getByTestId('total-form-field-kind-item-PERCENTAGE').click();
58
+ await dialog.getByTestId('total-form-field-percentOff').fill(percent);
59
+ await dialog.getByTestId('discount-form-submit').click();
60
+ await expect(dialog).toBeHidden();
61
+ });
62
+ /**
63
+ * The row is found by NAME, and a name is the one user-visible string here that
64
+ * is not copy — the scenario chose it a moment ago, so naming it is naming a
65
+ * fixture rather than an adopter's wording.
66
+ */
67
+ function rowFor(page, name) {
68
+ return page.getByTestId(GRID).getByRole('row', { name: new RegExp(name) });
69
+ }
70
+ Then('the promotion is listed with its rate', async ({ page }) => {
71
+ const row = rowFor(page, currentName);
72
+ await expect(row).toBeVisible();
73
+ // The round trip is the claim: a rate typed as a percentage comes back as one
74
+ // only if the conversion into basis points and back out BOTH ran. How it
75
+ // reads is the host's formatting, so the host states it.
76
+ await expect(row.getByText(discountsWorld().fixtures.percentInGrid)).toBeVisible();
77
+ });
78
+ When('I start composing a promotion', async ({ page }) => {
79
+ await openCreateDialog(page);
80
+ });
81
+ Then('the percentage field is the one on screen', async ({ page }) => {
82
+ await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-percentOff')).toBeVisible();
83
+ });
84
+ When('I switch it to a fixed amount', async ({ page }) => {
85
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
86
+ });
87
+ Then('the amount field REPLACES the percentage one', async ({ page }) => {
88
+ const dialog = page.getByTestId(DIALOG);
89
+ await expect(dialog.getByTestId('total-form-field-amountOff')).toBeVisible();
90
+ // Replaced, not hidden: a merely-hidden percentage input would still submit a
91
+ // stale rate alongside an amount the operator typed.
92
+ await expect(dialog.getByTestId('total-form-field-percentOff')).toHaveCount(0);
93
+ });
94
+ When('I switch it to a combo', async ({ page }) => {
95
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-COMBO').click();
96
+ });
97
+ Then('the scope question disappears and the group builder takes its place', async ({ page }) => {
98
+ const dialog = page.getByTestId(DIALOG);
99
+ // A combo COVERS the groups it is made of, so scope stops being a question at
100
+ // all — unmounted rather than disabled. That is what stops an operator
101
+ // building a combination the write path then refuses.
102
+ await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toHaveCount(0);
103
+ await expect(dialog.getByTestId('combo-slots')).toBeVisible();
104
+ });
105
+ Then('the scope question comes back for a plain kind', async ({ page }) => {
106
+ const dialog = page.getByTestId(DIALOG);
107
+ await dialog.getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
108
+ await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toBeVisible();
109
+ });
110
+ When('I set the activation to a coupon code', async ({ page }) => {
111
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-trigger-item-CODE').click();
112
+ });
113
+ Then('the coupon field is revealed', async ({ page }) => {
114
+ await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-code')).toBeVisible();
115
+ });
116
+ /** Open the row's kebab and take one of its entries by its STABLE id. */
117
+ async function chooseRowAction(page, action) {
118
+ await rowFor(page, currentName).locator('[data-testid^="discount-actions-"]').click();
119
+ await page.getByTestId(`discount-action-${action}`).click();
120
+ }
121
+ When('I rename it from the row menu', async ({ page }) => {
122
+ await chooseRowAction(page, 'edit');
123
+ const dialog = page.getByTestId(DIALOG);
124
+ await expect(dialog).toBeVisible();
125
+ currentName = `${currentName} II`;
126
+ await dialog.getByTestId('total-form-field-name').fill(currentName);
127
+ await dialog.getByTestId('discount-form-submit').click();
128
+ await expect(dialog).toBeHidden();
129
+ });
130
+ Then('the grid shows the new name', async ({ page }) => {
131
+ // The grid re-read the server — proof the write landed, rather than proof the
132
+ // dialog closed.
133
+ await expect(rowFor(page, currentName)).toBeVisible();
134
+ });
135
+ When('I delete it from the row menu', async ({ page }) => {
136
+ await chooseRowAction(page, 'delete');
137
+ // The menu entry only OPENS the confirmation; the DELETE leaves when it is
138
+ // confirmed.
139
+ await page.getByTestId('discount-delete-confirm-confirm-button').click();
140
+ });
141
+ Then('the promotion is gone from the grid', async ({ page }) => {
142
+ // A soft delete: the row leaves the list even though the record survives for
143
+ // its redemption history.
144
+ await expect(rowFor(page, currentName)).toHaveCount(0);
145
+ });
146
+ //# sourceMappingURL=journey.steps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journey.steps.js","sourceRoot":"","sources":["../../../src/e2e/steps/journey.steps.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * The port a HOST implements to run the packaged promotions journeys.
4
+ *
5
+ * The journeys are portable because every assertion in them reads a test id
6
+ * this package's own components render — `discounts-grid`, `discount-dialog`,
7
+ * `discount-action-edit`, `total-form-field-*` mean the same thing in any app
8
+ * that mounts `createWebDiscounts`. What is NOT portable is everything around
9
+ * them: how an app signs somebody in, how it routes to the promotions screen,
10
+ * how it returns to a known state, and how it renders a percentage.
11
+ *
12
+ * It replaces a 167-line spec in the origin host (`discounts.e2e.ts`), which
13
+ * asserted this package's test ids from a repo it does not build — the
14
+ * arrangement whose failure mode is silent from here: rename an id inside the
15
+ * package and a spec it cannot see goes red somewhere it does not run.
16
+ */
17
+ /**
18
+ * Facts about the host that the assertions have to name.
19
+ *
20
+ * A journey has to talk about SOMETHING: a permission to tick, a name to type,
21
+ * a rendered value to look for. None of that can live in a feature file without
22
+ * inventing one host's fixture as though it were everybody's.
23
+ */
24
+ export interface DiscountsFixtures {
25
+ /**
26
+ * A name no seed uses, minted FRESH per call.
27
+ *
28
+ * A function rather than a string because these journeys WRITE: the
29
+ * promotions table carries a per-tenant unique index on the name, so a
30
+ * constant would collide with itself on the second scenario and on every
31
+ * re-run against the same database.
32
+ */
33
+ newName: (prefix: string) => string;
34
+ /** The percentage a create journey types, as the form takes it (e.g. `15`). */
35
+ percent: string;
36
+ /**
37
+ * How that percentage READS in the grid once saved (e.g. `15%`).
38
+ *
39
+ * Stated by the host because formatting is the host's: `createWebDiscounts`
40
+ * takes a locale and a currency, so the same 15 renders differently in two
41
+ * adopters. It is the round-trip that matters — a value typed as a percentage
42
+ * comes back as one only if the conversion into basis points and back out
43
+ * both ran — and this is the half a package cannot spell.
44
+ */
45
+ percentInGrid: string;
46
+ }
47
+ /** What a host must be able to do for these journeys to run in it. */
48
+ export interface DiscountsWorld {
49
+ /**
50
+ * Put the browser in a known signed-in state as somebody who may manage
51
+ * promotions, and return the store to a known one.
52
+ *
53
+ * These journeys write, so the reset is load-bearing rather than hygiene: a
54
+ * scenario that renamed a promotion would otherwise hand the next one a
55
+ * catalog it did not expect.
56
+ */
57
+ signInAsManager(page: Page): Promise<void>;
58
+ /** Land on the promotions screen and wait until its grid has rendered. */
59
+ openDiscountsScreen(page: Page): Promise<void>;
60
+ fixtures: DiscountsFixtures;
61
+ }
62
+ /**
63
+ * Install the host's implementation. Call this from a module inside the host's
64
+ * OWN steps glob — playwright-bdd imports every step file before any scenario
65
+ * runs, so a top-level call there is registered in time, in every worker.
66
+ */
67
+ export declare function defineDiscountsWorld(world: DiscountsWorld): void;
68
+ /**
69
+ * The installed world, or a refusal naming the fix.
70
+ *
71
+ * Throws rather than returning null: a step that ran against an absent world
72
+ * would fail on whatever it touched next, somewhere unrelated to the actual
73
+ * mistake, which is a diagnosis nobody should have to make twice.
74
+ */
75
+ export declare function discountsWorld(): DiscountsWorld;
76
+ //# sourceMappingURL=world.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"world.d.ts","sourceRoot":"","sources":["../../src/e2e/world.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAE7C;;;;;;;;;;;;;;GAcG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,0EAA0E;IAC1E,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,EAAE,iBAAiB,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,cAAc,CAS/C"}
@@ -0,0 +1,25 @@
1
+ let installed = null;
2
+ /**
3
+ * Install the host's implementation. Call this from a module inside the host's
4
+ * OWN steps glob — playwright-bdd imports every step file before any scenario
5
+ * runs, so a top-level call there is registered in time, in every worker.
6
+ */
7
+ export function defineDiscountsWorld(world) {
8
+ installed = world;
9
+ }
10
+ /**
11
+ * The installed world, or a refusal naming the fix.
12
+ *
13
+ * Throws rather than returning null: a step that ran against an absent world
14
+ * would fail on whatever it touched next, somewhere unrelated to the actual
15
+ * mistake, which is a diagnosis nobody should have to make twice.
16
+ */
17
+ export function discountsWorld() {
18
+ if (!installed) {
19
+ throw new Error('No discounts e2e world is installed. Call defineDiscountsWorld({ … }) from a module ' +
20
+ "inside your own `steps` glob — playwright-bdd imports those before any " +
21
+ 'scenario runs, which is what makes the registration land in every worker.');
22
+ }
23
+ return installed;
24
+ }
25
+ //# sourceMappingURL=world.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"world.js","sourceRoot":"","sources":["../../src/e2e/world.ts"],"names":[],"mappings":"AAiEA,IAAI,SAAS,GAA0B,IAAI,CAAC;AAE5C;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAqB;IACxD,SAAS,GAAG,KAAK,CAAC;AACpB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,sFAAsF;YACpF,yEAAyE;YACzE,2EAA2E,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -0,0 +1,52 @@
1
+ # The promotions admin's journeys, portable to any host that mounts
2
+ # `createWebDiscounts`.
3
+ #
4
+ # Every assertion reads a test id THIS package renders. Nothing here names a
5
+ # sentence: the origin host's spec clicked `Editar` and `Salvar alterações`,
6
+ # which is its pt-BR and would fail in the next adopter for a reason that has
7
+ # nothing to do with promotions working. What a scenario needs to NAME — a
8
+ # fresh promotion name, a rate, how that rate reads once saved — comes from the
9
+ # host's fixtures.
10
+ Feature: Promotions
11
+
12
+ Background:
13
+ Given I am signed in as somebody who may manage promotions
14
+
15
+ Scenario: The promotions grid renders
16
+ When I open the promotions screen
17
+ Then the promotions grid is visible
18
+
19
+ Scenario: A manager composes a percentage promotion and finds it in the grid
20
+ # The round trip is the claim: a rate typed as a percentage comes back as
21
+ # one only if the conversion into basis points AND back out both ran.
22
+ When I open the promotions screen
23
+ And I compose a percentage promotion
24
+ Then the promotion is listed with its rate
25
+
26
+ Scenario: The value field follows the kind, and scope disappears for a combo
27
+ # Three rules in one dialog, all of them about the form refusing to let an
28
+ # operator build something the write path would reject.
29
+ When I open the promotions screen
30
+ And I start composing a promotion
31
+ Then the percentage field is the one on screen
32
+ When I switch it to a fixed amount
33
+ Then the amount field REPLACES the percentage one
34
+ When I switch it to a combo
35
+ Then the scope question disappears and the group builder takes its place
36
+ And the scope question comes back for a plain kind
37
+ When I set the activation to a coupon code
38
+ Then the coupon field is revealed
39
+
40
+ Scenario: A manager renames a promotion from its row menu
41
+ When I open the promotions screen
42
+ And I compose a percentage promotion
43
+ And I rename it from the row menu
44
+ Then the grid shows the new name
45
+
46
+ Scenario: A manager deletes a promotion from its row menu
47
+ # Confirm-gated, and a SOFT delete — the row leaves the list while the
48
+ # record survives for its redemption history.
49
+ When I open the promotions screen
50
+ And I compose a percentage promotion
51
+ And I delete it from the row menu
52
+ Then the promotion is gone from the grid
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
- "sideEffects": false,
5
+ "sideEffects": [
6
+ "**/e2e/**"
7
+ ],
6
8
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
7
9
  "exports": {
8
10
  ".": "./src/index.ts",
@@ -18,9 +20,15 @@
18
20
  "./server/en-US": "./src/server/en-US.ts",
19
21
  "./react/en-US": "./src/react/en-US.ts",
20
22
  "./locales": "./src/locales.ts",
21
- "./package.json": "./package.json"
23
+ "./package.json": "./package.json",
24
+ "./e2e": {
25
+ "types": "./dist/e2e/index.d.ts",
26
+ "default": "./dist/e2e/index.js"
27
+ },
28
+ "./timezone": "./src/timezone.ts"
22
29
  },
23
30
  "scripts": {
31
+ "build": "tsc -p tsconfig.e2e.json",
24
32
  "check-types": "tsc --noEmit",
25
33
  "clean": "rm -rf node_modules coverage dist",
26
34
  "dev": "storybook dev -p 6009",
@@ -33,10 +41,10 @@
33
41
  "typecheck": "tsc --noEmit"
34
42
  },
35
43
  "dependencies": {
36
- "@12-apps/app-shell": "^5.5.0",
44
+ "@12-apps/app-shell": "^5.7.0",
37
45
  "@12-apps/forms-core": "^1.21.0",
38
46
  "@12-apps/shared-helpers": "^2.1.0",
39
- "@12-apps/ui": "^6.10.0",
47
+ "@12-apps/ui": "^6.15.1",
40
48
  "@mui/icons-material": "^6.5.0",
41
49
  "zod": "^4.3.5"
42
50
  },
@@ -44,7 +52,9 @@
44
52
  "@12-apps/wiring": ">=1.3.0",
45
53
  "react": ">=19.0.0",
46
54
  "react-dom": ">=19.0.0",
47
- "react-router-dom": ">=7.0.0"
55
+ "react-router-dom": ">=7.0.0",
56
+ "@playwright/test": ">=1.55.0",
57
+ "playwright-bdd": ">=8.3.0"
48
58
  },
49
59
  "peerDependenciesMeta": {
50
60
  "@12-apps/wiring": {
@@ -58,17 +68,24 @@
58
68
  },
59
69
  "react-router-dom": {
60
70
  "optional": true
71
+ },
72
+ "@playwright/test": {
73
+ "optional": true
74
+ },
75
+ "playwright-bdd": {
76
+ "optional": true
61
77
  }
62
78
  },
63
79
  "devDependencies": {
64
80
  "@12-apps/eslint-config": "^1.22.0",
65
- "@12-apps/i18n": "^1.2.0",
81
+ "@12-apps/i18n": "^1.3.0",
66
82
  "@12-apps/typescript-config": "^1.21.0",
67
- "@12-apps/wiring": "^1.14.0",
83
+ "@12-apps/wiring": "^1.16.0",
68
84
  "@electric-sql/pglite": "^0.2.17",
69
85
  "@emotion/react": "^11.14.0",
70
86
  "@emotion/styled": "^11.14.0",
71
87
  "@mui/material": "^6.5.0",
88
+ "@playwright/test": "^1.61.1",
72
89
  "@storybook/addon-docs": "^9.1.10",
73
90
  "@storybook/addon-links": "^9.1.10",
74
91
  "@storybook/react-vite": "^9.1.10",
@@ -80,6 +97,7 @@
80
97
  "eslint-plugin-storybook": "9.1.10",
81
98
  "eslint-plugin-test-flakiness": "^1.4.0",
82
99
  "jsdom": "^25.0.1",
100
+ "playwright-bdd": "^9.2.0",
83
101
  "react": "^19.2.0",
84
102
  "react-dom": "^19.2.0",
85
103
  "react-router-dom": "^7.0.0",
@@ -117,7 +135,8 @@
117
135
  "!**/*.stories.*",
118
136
  "!**/*.test-story.*",
119
137
  "!**/test-helpers.*",
120
- "!**/__stories__/**"
138
+ "!**/__stories__/**",
139
+ "features"
121
140
  ],
122
141
  "wiring": {
123
142
  "db": {
@@ -79,6 +79,29 @@ model Discount {
79
79
  // either side = open-ended.
80
80
  startsAt DateTime? @map("starts_at")
81
81
  endsAt DateTime? @map("ends_at")
82
+ // The WEEKLY schedule INSIDE that window (FUT-996) — "toda sexta, das 16:00
83
+ // às 20:00", "segunda e terça à tarde". NULL = always, within
84
+ // [starts_at, ends_at), which is what every row predating the feature means.
85
+ //
86
+ // {"windows":[{"days":[4],"from":"16:00","to":"20:00"}]}
87
+ //
88
+ // `days` is Monday-first 0..6 — the axis every hours surface uses, and NOT
89
+ // `Date#getDay()`'s Sunday-first one. `from`/`to` are "HH:MM" in the STORE's
90
+ // timezone, half-open [from, to) exactly as the campaign window is, and a
91
+ // `to` earlier than `from` runs past midnight rather than being invalid.
92
+ //
93
+ // JSON rather than a fourth table because the recurrence is not usefully
94
+ // SQL-queryable (the grid's vigência filter already cannot express the
95
+ // campaign window either), and a fourth table would put a third `include` on
96
+ // the storefront's hottest read. The cost is that the DATABASE cannot check
97
+ // this shape: `assertSchedule` is the only gate, and the evaluator treats a
98
+ // malformed window as covering NOTHING, so a bad blob is a promotion that
99
+ // does not fire rather than a cart priced wrong.
100
+ //
101
+ // A recurring window is a WALL-CLOCK fact, so it is only meaningful against
102
+ // the store's own timezone. The engine never reads one: a host resolves the
103
+ // instant into a weekday and a minute-of-day and passes those in.
104
+ schedule Json? @map("schedule")
82
105
  // Minimum PRE-DISCOUNT cart subtotal, in cents. Always compared against the
83
106
  // untouched subtotal, never a running total — which is what makes the result
84
107
  // independent of the order the discounts are applied in.
@@ -0,0 +1,40 @@
1
+ -- @12-apps/discounts: the WEEKLY schedule (FUT-996).
2
+ --
3
+ -- "Toda sexta, das 16:00 às 20:00" — a recurring window INSIDE the campaign
4
+ -- `[starts_at, ends_at)`, which until now was the only scheduling a rule had.
5
+ --
6
+ -- ── PURELY ADDITIVE, AND THAT IS THE POINT ────────────────────────────────
7
+ -- One nullable column, no backfill, no CHECK to widen, nothing dropped. NULL
8
+ -- means "always, within the campaign window", which is exactly what every
9
+ -- existing row already means — so this migration cannot change the price of
10
+ -- anything, and the release carrying it needs none of the expand/contract
11
+ -- ceremony a destructive DDL would (see the host's CLAUDE.md on why a
12
+ -- `DROP COLUMN` takes production down for the length of a healthcheck).
13
+ --
14
+ -- Replay-safe like its predecessor: `IF NOT EXISTS` throughout.
15
+ --
16
+ -- ── WHY JSON RATHER THAN A FOURTH TABLE ───────────────────────────────────
17
+ -- The recurrence is not SQL-queryable in any useful way. The admin grid's
18
+ -- vigência filter already punts on comparing two nullable timestamps against
19
+ -- `now()` and rides as a separate parameter the store resolves, so a schedule
20
+ -- table would buy queryability that nothing asks for — while adding a third
21
+ -- `include` to `loadDiscountRules`, which is on the storefront's hottest read
22
+ -- (a whole menu page's badge preview walks it once per request).
23
+ --
24
+ -- The trade is stated plainly: the database cannot check this column's SHAPE.
25
+ -- `assertSchedule` in `src/server/validate.ts` is the only gate in front of it,
26
+ -- and the evaluator is deliberately tolerant of a row that got past it — a
27
+ -- malformed window covers NOTHING rather than everything, so the failure mode
28
+ -- of a bad blob is a promotion that does not fire, never a cart priced at zero.
29
+ --
30
+ -- Shape, for a reader with only the database in front of them:
31
+ --
32
+ -- {"windows":[{"days":[4],"from":"16:00","to":"20:00"}]}
33
+ --
34
+ -- * `days` — Monday-first, 0..6. NOT `Date#getDay()`'s Sunday-first axis.
35
+ -- * `from`/`to` — "HH:MM", 24-hour, in the STORE's timezone, half-open
36
+ -- [from, to) exactly as [starts_at, ends_at) is.
37
+ -- * `to` < `from` means the window runs past midnight (a bar shutting at
38
+ -- 02:00), which is the most common happy hour there is.
39
+
40
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "schedule" JSONB;