@mohasinac/appkit 4.63.2 → 4.63.4

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 (49) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
  3. package/dist/_internal/server/features/promotions/adapters.js +43 -0
  4. package/dist/_internal/server/features/promotions/data.d.ts +26 -0
  5. package/dist/_internal/server/features/promotions/data.js +45 -0
  6. package/dist/_internal/server/features/promotions/index.d.ts +2 -1
  7. package/dist/_internal/server/features/promotions/index.js +2 -1
  8. package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
  9. package/dist/constants/field-names.d.ts +4 -0
  10. package/dist/constants/field-names.js +4 -0
  11. package/dist/features/account/components/UserSidebar.js +1 -1
  12. package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
  13. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  14. package/dist/features/blog/schemas/blog-form.js +14 -0
  15. package/dist/features/blog/schemas/firestore.d.ts +17 -0
  16. package/dist/features/blog/schemas/firestore.js +1 -0
  17. package/dist/features/blog/schemas/index.d.ts +6 -0
  18. package/dist/features/blog/schemas/index.js +3 -0
  19. package/dist/features/blog/types/index.d.ts +2 -0
  20. package/dist/features/events/components/AdminEventEditorView.js +64 -9
  21. package/dist/features/events/components/EventOfferCard.js +3 -18
  22. package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
  23. package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
  24. package/dist/features/events/schemas/event-form.d.ts +10 -0
  25. package/dist/features/events/schemas/event-form.js +19 -0
  26. package/dist/features/events/schemas/firestore.d.ts +68 -0
  27. package/dist/features/events/schemas/firestore.js +25 -0
  28. package/dist/features/events/schemas/index.d.ts +7 -0
  29. package/dist/features/events/schemas/index.js +5 -0
  30. package/dist/features/events/types/index.d.ts +4 -0
  31. package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
  32. package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
  33. package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
  34. package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
  35. package/dist/features/promotions/components/index.d.ts +4 -0
  36. package/dist/features/promotions/components/index.js +2 -0
  37. package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
  38. package/dist/features/promotions/constants/attached-coupons.js +24 -0
  39. package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
  40. package/dist/features/promotions/repository/coupons.repository.js +39 -0
  41. package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
  42. package/dist/features/promotions/schemas/attached-coupons.js +66 -0
  43. package/dist/features/shell/SectionForm.js +47 -2
  44. package/dist/schemas/registry.d.ts +16 -0
  45. package/dist/server-entry.d.ts +1 -0
  46. package/dist/server-entry.js +2 -0
  47. package/dist/server.d.ts +1 -0
  48. package/dist/server.js +4 -0
  49. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from "react";
4
+ import { Badge, Div, Grid, Heading, Stack, Text } from "../../../ui/index.js";
5
+ import { formatCurrency } from "../../../utils/number.formatter.js";
6
+ import { CouponCodeCopyRow } from "./CouponCodeCopyRow.js";
7
+ /**
8
+ * Headline summary of what a coupon gives — "25% off", "₹200 off",
9
+ * "Free shipping".
10
+ *
11
+ * 🛑 `type` is a plain `string` here, not the `CouponType` union, because this
12
+ * renders a PROJECTION of a document rather than the document. That is a
13
+ * deliberate trade: a new coupon type added upstream falls through to the
14
+ * coupon's own `name` instead of failing to compile. The alternative —
15
+ * `Record<CouponType, …>` — is the right shape when the exhaustiveness is the
16
+ * point (Root Cause #61), and the wrong one across a serialisation boundary
17
+ * where the value has already been stringified.
18
+ */
19
+ function summarise(coupon) {
20
+ switch (coupon.type) {
21
+ case "percentage":
22
+ return `${coupon.discountValue}% off`;
23
+ case "fixed":
24
+ return `${formatCurrency(coupon.discountValue, "INR")} off`;
25
+ case "free_shipping":
26
+ return "Free shipping";
27
+ case "points_earn":
28
+ return `${coupon.discountValue}% back in points`;
29
+ default:
30
+ // buy_x_get_y and anything added later: the name already says it.
31
+ return null;
32
+ }
33
+ }
34
+ /** "Min. spend ₹999 · Up to ₹500 off · Ends 10 Nov" — only the parts that exist. */
35
+ function conditionsOf(coupon) {
36
+ const parts = [];
37
+ if (coupon.minPurchase)
38
+ parts.push(`Min. spend ${formatCurrency(coupon.minPurchase, "INR")}`);
39
+ if (coupon.maxDiscount)
40
+ parts.push(`Up to ${formatCurrency(coupon.maxDiscount, "INR")} off`);
41
+ /*
42
+ * 🛑 An absent `endsAt` means NO END DATE, not "expired" — it is `undefined`
43
+ * for every open-ended coupon. Rendering "Expired" here would libel every
44
+ * one of them, and rendering nothing is correct: there is no deadline to
45
+ * report. Expiry is not this component's job at all; `getAttachedCoupons`
46
+ * has already dropped anything past its window.
47
+ */
48
+ if (coupon.endsAt) {
49
+ parts.push(`Ends ${new Date(coupon.endsAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}`);
50
+ }
51
+ return parts.length > 0 ? parts.join(" · ") : null;
52
+ }
53
+ /**
54
+ * The coupons a piece of content advertises — events and blog posts alike.
55
+ *
56
+ * Copy-code only, by design: the reader takes the code to checkout, where
57
+ * every existing validation, per-user limit and stacking rule already applies
58
+ * against a real cart. An "apply to cart" button here would have to reproduce
59
+ * `detectCouponConflict`'s one-seller-coupon-per-store-plus-one-admin rule and
60
+ * explain its refusals on a page that knows nothing about the cart.
61
+ *
62
+ * 🛑 Renders NOTHING when there are no coupons — not an empty state. This sits
63
+ * inside an article or an event description, and a "no coupons available" box
64
+ * on the 95% of posts that never had one is noise that makes the 5% harder to
65
+ * notice.
66
+ *
67
+ * 🛑 It deliberately does NOT label which seller a coupon belongs to, even
68
+ * though `CouponDocument` carries `scope`/`storeId`. A reader cannot act on
69
+ * that: the coupon either applies to their cart or it does not, and checkout
70
+ * is where that is decided and explained. Printing a store name here would
71
+ * imply a scope the card cannot actually enforce.
72
+ */
73
+ export function AttachedCouponsSection({ coupons, title = "Coupons", description, }) {
74
+ /*
75
+ * 🛑 `useId`, not a literal. This component is reusable and nothing stops a
76
+ * page mounting it twice — a hardcoded id would then appear on two elements
77
+ * and `aria-labelledby` would resolve to whichever came first, silently
78
+ * mislabelling the second section. React generates one id per instance.
79
+ *
80
+ * Called before the empty-return below because it is a HOOK: an early
81
+ * return above it would change the hook order between a post with coupons
82
+ * and one without.
83
+ */
84
+ const headingId = useId();
85
+ if (coupons.length === 0)
86
+ return null;
87
+ return (_jsxs(Stack, { gap: "md", "aria-labelledby": headingId, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { id: headingId, level: 2, weight: "semibold", size: "xl", children: title }), description ? (_jsx(Text, { size: "sm", color: "muted", children: description })) : null] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: coupons.map((coupon) => {
88
+ const summary = summarise(coupon);
89
+ const conditions = conditionsOf(coupon);
90
+ /*
91
+ * `surface="card"` already carries the border, radius and shadow —
92
+ * adding `border=` / `rounded=` beside it emits a second, competing
93
+ * set of utilities, and with `important: true` in both Tailwind
94
+ * configs the winner is decided by emission order rather than by
95
+ * anything readable here (Root Cause #79).
96
+ */
97
+ return (_jsx(Div, { surface: "card", padding: "card", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Stack, { gap: "xs", children: [summary ? (_jsx(Div, { children: _jsx(Badge, { variant: "primary", children: summary }) })) : null, _jsx(Text, { weight: "semibold", children: coupon.name }), coupon.description ? (_jsx(Text, { size: "sm", color: "muted", children: coupon.description })) : null] }), _jsx(CouponCodeCopyRow, { code: coupon.code, ariaContext: coupon.name }), conditions ? (_jsx(Text, { size: "xs", color: "muted", children: conditions })) : null] }) }, coupon.id));
98
+ }) })] }));
99
+ }
@@ -0,0 +1,19 @@
1
+ export interface CouponCodeCopyRowProps {
2
+ /** The code to copy. Rendered verbatim — it is what the buyer must type. */
3
+ code: string;
4
+ /** Accessible context, e.g. the coupon name. Appended to the button label for screen readers. */
5
+ ariaContext?: string;
6
+ }
7
+ /**
8
+ * The code + "Copy code" row. ONE implementation, shared by `EventOfferCard`
9
+ * (offer events, one coupon) and `AttachedCouponsSection` (events and blog
10
+ * posts, several).
11
+ *
12
+ * Extracted rather than copied: this is the second consumer, and the
13
+ * Duplication Framework's keep-it rule ("Rule of Three — two copies is
14
+ * observation") is outranked here by its consolidate rule #2, the bug-fix
15
+ * multiplier. The clipboard call has a real failure mode — it is denied
16
+ * outright on insecure origins and by permission — and a fix to that handling
17
+ * in one copy would silently not reach the other.
18
+ */
19
+ export declare function CouponCodeCopyRow({ code, ariaContext }: CouponCodeCopyRowProps): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { normalizeError } from "../../../errors/normalize.js";
5
+ import { Code, Row } from "../../../ui/index.js";
6
+ import { Button } from "../../../ui/components/Button.js";
7
+ /**
8
+ * The code + "Copy code" row. ONE implementation, shared by `EventOfferCard`
9
+ * (offer events, one coupon) and `AttachedCouponsSection` (events and blog
10
+ * posts, several).
11
+ *
12
+ * Extracted rather than copied: this is the second consumer, and the
13
+ * Duplication Framework's keep-it rule ("Rule of Three — two copies is
14
+ * observation") is outranked here by its consolidate rule #2, the bug-fix
15
+ * multiplier. The clipboard call has a real failure mode — it is denied
16
+ * outright on insecure origins and by permission — and a fix to that handling
17
+ * in one copy would silently not reach the other.
18
+ */
19
+ export function CouponCodeCopyRow({ code, ariaContext }) {
20
+ const [copied, setCopied] = useState(false);
21
+ const copyCode = async () => {
22
+ try {
23
+ await navigator.clipboard.writeText(code);
24
+ setCopied(true);
25
+ setTimeout(() => setCopied(false), 2000);
26
+ }
27
+ catch (_err) {
28
+ /*
29
+ * Clipboard access is denied outright on insecure origins and by user
30
+ * permission. The button simply never flips to "Copied", and the code is
31
+ * still on screen to select by hand — so the failure costs convenience,
32
+ * never the ability to use the coupon. Nothing is shown to the user,
33
+ * because there is nothing they could do about it.
34
+ */
35
+ void normalizeError(_err);
36
+ }
37
+ };
38
+ return (_jsxs(Row, { surface: "muted", padding: "inline", align: "center", gap: "3", rounded: "lg", children: [_jsx(Code, { className: "flex-1", weight: "bold", size: "base", color: "primary", children: code }), _jsx(Button, { type: "button", variant: "outline", onClick: copyCode, "aria-label": ariaContext ? `Copy coupon code ${code} — ${ariaContext}` : `Copy coupon code ${code}`, children: copied ? "Copied!" : "Copy code" })] }));
39
+ }
@@ -1,3 +1,7 @@
1
+ export { AttachedCouponsSection } from "./AttachedCouponsSection";
2
+ export type { AttachedCouponsSectionProps } from "./AttachedCouponsSection";
3
+ export { CouponCodeCopyRow } from "./CouponCodeCopyRow";
4
+ export type { CouponCodeCopyRowProps } from "./CouponCodeCopyRow";
1
5
  export { CouponCard } from "./CouponCard";
2
6
  export { CouponHelpDetails } from "./CouponHelpDetails";
3
7
  export type { CouponHelpDetailsProps } from "./CouponHelpDetails";
@@ -1,3 +1,5 @@
1
+ export { AttachedCouponsSection } from "./AttachedCouponsSection.js";
2
+ export { CouponCodeCopyRow } from "./CouponCodeCopyRow.js";
1
3
  export { CouponCard } from "./CouponCard.js";
2
4
  export { CouponHelpDetails } from "./CouponHelpDetails.js";
3
5
  export { CouponsIndexListing } from "./CouponsIndexListing.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Attached coupons — the contract shared by every layer that carries a set of
3
+ * coupon ids on a piece of CONTENT (`EventDocument.couponIds`,
4
+ * `BlogPostDocument.couponIds`).
5
+ *
6
+ * This file is deliberately dependency-free: the Zod write schemas, the admin
7
+ * editors, the repository's batch read and the public renderer all import the
8
+ * cap from here, so the number cannot drift between what an editor may save
9
+ * and what a page is willing to read. A cap enforced in one of those four
10
+ * places is not a cap.
11
+ */
12
+ /**
13
+ * Most coupons one event or blog post may carry.
14
+ *
15
+ * 🛑 This is a Rule #6 bound, not a style preference. The public event and
16
+ * blog pages resolve these ids in a Server Component, so the number is a
17
+ * direct multiplier on a guest-reachable read. Ten is already generous for a
18
+ * promotion readers are expected to scan; the write schemas reject more, and
19
+ * `couponsRepository.findByIds` re-clamps so a document hand-edited in the
20
+ * Firestore console still cannot widen the read.
21
+ *
22
+ * Raising it means re-checking both: the schemas AND the repository.
23
+ */
24
+ export declare const ATTACHED_COUPON_MAX = 10;
25
+ /**
26
+ * The public projection of a coupon when it is attached to content.
27
+ *
28
+ * 🛑 This is an ALLOW-LIST, and the reason it exists is Root Cause #70: a
29
+ * public payload built by spreading a document and deleting keys publishes
30
+ * every field nobody thought to delete — including fields the TypeScript
31
+ * interface never declared. `CouponDocument` carries `usage.currentUsage`,
32
+ * `analytics.totalRevenue`, `analytics.totalDiscount`, the whole
33
+ * `restrictions` block and `createdBy`; none of that is a reader's business,
34
+ * and `analytics.*` is seller revenue.
35
+ *
36
+ * Note the existing `GET /api/coupons` returns whole documents. That is
37
+ * pre-existing and out of scope here — but it is precisely why this path
38
+ * starts with a projection instead of inheriting the habit.
39
+ */
40
+ export interface AttachedCoupon {
41
+ id: string;
42
+ code: string;
43
+ name: string;
44
+ description: string;
45
+ /** `percentage` | `fixed` | `free_shipping` | `buy_x_get_y` | `points_earn` */
46
+ type: string;
47
+ /** The headline number: percent for `percentage`, rupees for `fixed`. */
48
+ discountValue: number;
49
+ /** Cap on a percentage discount, in rupees. */
50
+ maxDiscount?: number;
51
+ /** Minimum cart value before the coupon applies, in rupees. */
52
+ minPurchase?: number;
53
+ /** ISO string — `undefined` means no end date, NOT "expired". */
54
+ endsAt?: string;
55
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Attached coupons — the contract shared by every layer that carries a set of
3
+ * coupon ids on a piece of CONTENT (`EventDocument.couponIds`,
4
+ * `BlogPostDocument.couponIds`).
5
+ *
6
+ * This file is deliberately dependency-free: the Zod write schemas, the admin
7
+ * editors, the repository's batch read and the public renderer all import the
8
+ * cap from here, so the number cannot drift between what an editor may save
9
+ * and what a page is willing to read. A cap enforced in one of those four
10
+ * places is not a cap.
11
+ */
12
+ /**
13
+ * Most coupons one event or blog post may carry.
14
+ *
15
+ * 🛑 This is a Rule #6 bound, not a style preference. The public event and
16
+ * blog pages resolve these ids in a Server Component, so the number is a
17
+ * direct multiplier on a guest-reachable read. Ten is already generous for a
18
+ * promotion readers are expected to scan; the write schemas reject more, and
19
+ * `couponsRepository.findByIds` re-clamps so a document hand-edited in the
20
+ * Firestore console still cannot widen the read.
21
+ *
22
+ * Raising it means re-checking both: the schemas AND the repository.
23
+ */
24
+ export const ATTACHED_COUPON_MAX = 10;
@@ -27,6 +27,26 @@ export declare class CouponsRepository extends BaseRepository<CouponDocument> {
27
27
  * @returns Promise<CouponDocument | null>
28
28
  */
29
29
  getCouponByCode(code: string): Promise<CouponDocument | null>;
30
+ /**
31
+ * Batch-read coupons by id, for content that ATTACHES a set of them
32
+ * (`EventDocument.couponIds`, `BlogPostDocument.couponIds`).
33
+ *
34
+ * 🛑 ONE `getAll`, never a loop of `findById`. A blog post carrying six
35
+ * coupons would otherwise be six sequential round trips on a public,
36
+ * ISR-regenerated page — Rule #6 budgets about three for a whole request.
37
+ *
38
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled — the same
39
+ * contract as `categoriesRepository.findByIds`, deliberately, so the two
40
+ * batch readers in this codebase cannot behave differently. A content
41
+ * editor's ordering is the display order, and a coupon that was deleted
42
+ * after being attached must not render as a hole in the grid.
43
+ *
44
+ * Bounded at `ATTACHED_COUPON_MAX`. The cap is the reason this is safe to
45
+ * call from a Server Component: the write schemas enforce the same number,
46
+ * so a hand-edited Firestore document cannot turn a public page into an
47
+ * unbounded read.
48
+ */
49
+ findByIds(ids: string[]): Promise<CouponDocument[]>;
30
50
  /**
31
51
  * Get all active coupons
32
52
  *
@@ -9,6 +9,7 @@ import { serverLogger } from "../../../monitoring/server-logger.js";
9
9
  */
10
10
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
11
11
  import { COUPONS_COLLECTION, COUPON_FIELDS, isValidCouponCode, isCouponValid, canUserUseCoupon, calculateDiscount, createCouponId, } from "../schemas/index.js";
12
+ import { ATTACHED_COUPON_MAX } from "../constants/attached-coupons.js";
12
13
  import { USER_COLLECTION } from "../../auth/schemas/index.js";
13
14
  import { DatabaseError } from "../../../errors/index.js";
14
15
  import { increment, arrayUnion, serverTimestamp } from "../../../contracts/field-ops.js";
@@ -71,6 +72,44 @@ export class CouponsRepository extends BaseRepository {
71
72
  throw new DatabaseError(`Failed to retrieve coupon by code: ${error instanceof Error ? error.message : "Unknown error"}`);
72
73
  }
73
74
  }
75
+ /**
76
+ * Batch-read coupons by id, for content that ATTACHES a set of them
77
+ * (`EventDocument.couponIds`, `BlogPostDocument.couponIds`).
78
+ *
79
+ * 🛑 ONE `getAll`, never a loop of `findById`. A blog post carrying six
80
+ * coupons would otherwise be six sequential round trips on a public,
81
+ * ISR-regenerated page — Rule #6 budgets about three for a whole request.
82
+ *
83
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled — the same
84
+ * contract as `categoriesRepository.findByIds`, deliberately, so the two
85
+ * batch readers in this codebase cannot behave differently. A content
86
+ * editor's ordering is the display order, and a coupon that was deleted
87
+ * after being attached must not render as a hole in the grid.
88
+ *
89
+ * Bounded at `ATTACHED_COUPON_MAX`. The cap is the reason this is safe to
90
+ * call from a Server Component: the write schemas enforce the same number,
91
+ * so a hand-edited Firestore document cannot turn a public page into an
92
+ * unbounded read.
93
+ */
94
+ async findByIds(ids) {
95
+ const unique = Array.from(new Set(ids.filter(Boolean))).slice(0, ATTACHED_COUPON_MAX);
96
+ if (unique.length === 0)
97
+ return [];
98
+ try {
99
+ const refs = unique.map((id) => this.db.collection(this.collection).doc(id));
100
+ const snaps = await this.db.getAll(...refs);
101
+ return snaps.filter((s) => s.exists).map((s) => this.mapDoc(s));
102
+ }
103
+ catch (error) {
104
+ const e = normalizeError(error);
105
+ /*
106
+ * `kind` and a COUNT, never the raw message and never the ids —
107
+ * Root Cause #86 (a thrown message is written for a developer and this
108
+ * one can reach a user through a 500) plus Hobby's ~4 KB/s log budget.
109
+ */
110
+ throw new DatabaseError(`Failed to batch-read ${unique.length} coupon(s) by id (${e.kind})`);
111
+ }
112
+ }
74
113
  /**
75
114
  * Get all active coupons
76
115
  *
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The write shape for any `couponIds`-style field — one definition, imported
4
+ * by the event schema, the blog schema and both admin API routes.
5
+ *
6
+ * 🛑 ONE definition on purpose. Four hand-written `z.array(z.string())`s would
7
+ * be four chances for the cap to drift, and a cap enforced on three of four
8
+ * write paths is not a cap — the fourth is simply the way in. This is the same
9
+ * failure as the ten hand-maintained copies of the `ListingType` union in
10
+ * Root Cause #61.
11
+ *
12
+ * `.max()` carries a message because this one is reachable by a human in an
13
+ * admin form, and "Too big: expected array to have <=10 items" is not an
14
+ * instruction.
15
+ */
16
+ export declare const attachedCouponIdsSchema: z.ZodType<string[] | undefined>;
17
+ /**
18
+ * Dedupe and drop blanks. Still ONE definition — it moved out of the schema,
19
+ * it did not become four copies.
20
+ *
21
+ * 🛑 WHY IT IS NOT A `.transform()` ANY MORE, which is where it started:
22
+ *
23
+ * `.transform()` wraps the schema in `z.ZodEffects`, and **`ZodEffects` is a
24
+ * zod 3 type that does not exist in zod 4**. This repo runs both — appkit is
25
+ * pinned to zod 3.25.76 (peer `^3.24.0`) while the consumer app resolves zod
26
+ * 4.4.3 — so appkit's emitted `.d.ts` referenced a type the consumer's zod
27
+ * cannot resolve, the inference collapsed to `unknown`, and four admin routes
28
+ * failed to typecheck with `Type 'unknown' is not assignable to type
29
+ * 'string[] | undefined'`.
30
+ *
31
+ * The declared peer range is the thing that is actually wrong here, and
32
+ * aligning the two majors is a real migration rather than a drive-by. Until
33
+ * then: no schema crossing the package boundary may use `.transform()`,
34
+ * `.refine()` or `.preprocess()`, because all three produce `ZodEffects`.
35
+ *
36
+ * Callers apply this after parsing, which is the same boundary the dedupe
37
+ * always ran at — the route, not the client.
38
+ */
39
+ export declare function dedupeAttachedCouponIds(ids: readonly string[] | undefined): string[] | undefined;
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { ATTACHED_COUPON_MAX } from "../constants/attached-coupons.js";
3
+ /**
4
+ * The write shape for any `couponIds`-style field — one definition, imported
5
+ * by the event schema, the blog schema and both admin API routes.
6
+ *
7
+ * 🛑 ONE definition on purpose. Four hand-written `z.array(z.string())`s would
8
+ * be four chances for the cap to drift, and a cap enforced on three of four
9
+ * write paths is not a cap — the fourth is simply the way in. This is the same
10
+ * failure as the ten hand-maintained copies of the `ListingType` union in
11
+ * Root Cause #61.
12
+ *
13
+ * `.max()` carries a message because this one is reachable by a human in an
14
+ * admin form, and "Too big: expected array to have <=10 items" is not an
15
+ * instruction.
16
+ */
17
+ /*
18
+ * 🛑 THE EXPLICIT ANNOTATION IS LOAD-BEARING — do not delete it as redundant.
19
+ *
20
+ * This repo runs TWO zod majors: appkit is pinned to 3.25.76 (peer `^3.24.0`)
21
+ * and the consumer app resolves 4.4.3. Inferred, this export's `.d.ts` reads
22
+ * `z.ZodOptional<z.ZodArray<z.ZodString, "many">>` — and zod 4's `ZodArray`
23
+ * takes ONE type parameter, not two. The consumer cannot resolve that shape,
24
+ * the inference collapses to `unknown`, and four admin routes fail with
25
+ * `Type 'unknown' is not assignable to type 'string[] | undefined'`.
26
+ *
27
+ * `z.ZodType<T>` is spelled the same in both majors and `z.infer` yields `T`
28
+ * from it either way, so the emitted declaration stays resolvable on both
29
+ * sides of the boundary.
30
+ *
31
+ * The real defect is the version split — appkit's declared peer range and the
32
+ * app's installed major disagree — and aligning them is a migration, not a
33
+ * drive-by. Until that happens, any appkit zod schema a consumer infers from
34
+ * needs this treatment.
35
+ */
36
+ export const attachedCouponIdsSchema = z
37
+ .array(z.string().min(1))
38
+ .max(ATTACHED_COUPON_MAX, `At most ${ATTACHED_COUPON_MAX} coupons can be attached.`)
39
+ .optional();
40
+ /**
41
+ * Dedupe and drop blanks. Still ONE definition — it moved out of the schema,
42
+ * it did not become four copies.
43
+ *
44
+ * 🛑 WHY IT IS NOT A `.transform()` ANY MORE, which is where it started:
45
+ *
46
+ * `.transform()` wraps the schema in `z.ZodEffects`, and **`ZodEffects` is a
47
+ * zod 3 type that does not exist in zod 4**. This repo runs both — appkit is
48
+ * pinned to zod 3.25.76 (peer `^3.24.0`) while the consumer app resolves zod
49
+ * 4.4.3 — so appkit's emitted `.d.ts` referenced a type the consumer's zod
50
+ * cannot resolve, the inference collapsed to `unknown`, and four admin routes
51
+ * failed to typecheck with `Type 'unknown' is not assignable to type
52
+ * 'string[] | undefined'`.
53
+ *
54
+ * The declared peer range is the thing that is actually wrong here, and
55
+ * aligning the two majors is a real migration rather than a drive-by. Until
56
+ * then: no schema crossing the package boundary may use `.transform()`,
57
+ * `.refine()` or `.preprocess()`, because all three produce `ZodEffects`.
58
+ *
59
+ * Callers apply this after parsing, which is the same boundary the dedupe
60
+ * always ran at — the route, not the client.
61
+ */
62
+ export function dedupeAttachedCouponIds(ids) {
63
+ if (!ids)
64
+ return undefined;
65
+ return Array.from(new Set(ids.filter(Boolean)));
66
+ }
@@ -209,6 +209,13 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
209
209
  // validator on every render and re-fire the effect below in a loop.
210
210
  const onValidationChangeRef = useRef(onValidationChange);
211
211
  onValidationChangeRef.current = onValidationChange;
212
+ /**
213
+ * The last error map actually HANDED TO the caller.
214
+ *
215
+ * 🛑 This is what stops an infinite render loop, and the loop froze the whole
216
+ * app — see `emitValidation` below.
217
+ */
218
+ const lastEmittedRef = useRef(null);
212
219
  /**
213
220
  * Are two field-error maps the same set of messages?
214
221
  *
@@ -232,6 +239,44 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
232
239
  .filter((s) => s.fields?.some((f) => keys.some((k) => k === f || k.startsWith(`${f}.`))))
233
240
  .map((s) => s.id);
234
241
  }, [ordered]);
242
+ /**
243
+ * Hand the result to the caller, but ONLY when it actually changed.
244
+ *
245
+ * ## 🛑 The loop this closes froze every admin editor drawer
246
+ *
247
+ * `runValidation` runs on EVERY render by design: `values` is a fresh object
248
+ * each time, so `ordered` → `erroringSectionIds` → `runValidation` are all
249
+ * new identities and the effect below re-fires. The comment there argues
250
+ * that is safe, and for this component's OWN state it is — `sameErrors` and
251
+ * the `forceOpenIds` length check both return the previous object, so React
252
+ * bails out.
253
+ *
254
+ * It was NOT safe for the caller. `onValidationChange` was invoked
255
+ * unconditionally on every run, and the real call site is
256
+ * `onValidationChange={() => form.validate(draft)}` — which setStates. So:
257
+ * render → effect → emit → caller setState → render → … forever. React
258
+ * reported "Maximum update depth exceeded" ~4x/second.
259
+ *
260
+ * The visible symptom was not a validation bug at all. A React tree stuck in
261
+ * an update loop never commits a transition, and App Router navigation IS a
262
+ * transition — so with the drawer open, Cancel, the X, the sort dropdown and
263
+ * even plain nav links all did nothing. Measured on production: `router.replace`
264
+ * produced ZERO `history.replaceState` calls, no error, no failed request.
265
+ * The only escape was a full page reload.
266
+ *
267
+ * Emitting only on a real change is also what the prop NAME promises; firing
268
+ * it with an identical map on every render was never meaningful to a caller.
269
+ */
270
+ const emitValidation = useCallback((next, index) => {
271
+ const prev = lastEmittedRef.current;
272
+ if (prev && sameErrors(prev, next))
273
+ return;
274
+ lastEmittedRef.current = next;
275
+ onValidationChangeRef.current?.(next, index);
276
+ },
277
+ // `sameErrors` is a pure local helper; `lastEmittedRef`/`onValidationChangeRef`
278
+ // are refs. Nothing here needs to re-create this callback.
279
+ []);
235
280
  const runValidation = useCallback((nextValues) => {
236
281
  const activeSchema = schema ?? inheritedSchema;
237
282
  if (!activeSchema)
@@ -243,7 +288,7 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
243
288
  if (parsed.success) {
244
289
  setFieldErrors((prev) => (Object.keys(prev).length === 0 ? prev : {}));
245
290
  setForceOpenIds((prev) => (prev.length === 0 ? prev : []));
246
- onValidationChangeRef.current?.({}, fieldToSectionIndex);
291
+ emitValidation({}, fieldToSectionIndex);
247
292
  return;
248
293
  }
249
294
  const next = {};
@@ -266,7 +311,7 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
266
311
  const kept = prev.filter((id) => stillBad.has(id));
267
312
  return kept.length === prev.length ? prev : kept;
268
313
  });
269
- onValidationChangeRef.current?.(next, fieldToSectionIndex);
314
+ emitValidation(next, fieldToSectionIndex);
270
315
  }, [schema, inheritedSchema, fieldToSectionIndex, erroringSectionIds]);
271
316
  /*
272
317
  * 🛑 `values` MUST be a dependency, and leaving it out cost the entire admin
@@ -169,6 +169,7 @@ export declare const SCHEMAS: {
169
169
  }>, "many">>;
170
170
  category: import("zod").ZodString;
171
171
  tags: import("zod").ZodArray<import("zod").ZodString, "many">;
172
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
172
173
  isFeatured: import("zod").ZodBoolean;
173
174
  isPinned: import("zod").ZodBoolean;
174
175
  status: import("zod").ZodEnum<["draft", "published", "archived"]>;
@@ -210,6 +211,7 @@ export declare const SCHEMAS: {
210
211
  publishedAt?: string | Date | import("zod").objectOutputType<{
211
212
  toDate: import("zod").ZodFunction<import("zod").ZodTuple<[], import("zod").ZodUnknown>, import("zod").ZodDate>;
212
213
  }, import("zod").ZodTypeAny, "passthrough"> | undefined;
214
+ couponIds?: string[] | undefined;
213
215
  coverImage?: {
214
216
  type: "video" | "image" | "file";
215
217
  url: string;
@@ -264,6 +266,7 @@ export declare const SCHEMAS: {
264
266
  publishedAt?: string | Date | import("zod").objectInputType<{
265
267
  toDate: import("zod").ZodFunction<import("zod").ZodTuple<[], import("zod").ZodUnknown>, import("zod").ZodDate>;
266
268
  }, import("zod").ZodTypeAny, "passthrough"> | undefined;
269
+ couponIds?: string[] | undefined;
267
270
  coverImage?: {
268
271
  type: "video" | "image" | "file";
269
272
  url: string;
@@ -4895,6 +4898,7 @@ export declare const SCHEMAS: {
4895
4898
  authorName: import("zod").ZodString;
4896
4899
  isFeatured: import("zod").ZodBoolean;
4897
4900
  isPinned: import("zod").ZodBoolean;
4901
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4898
4902
  }, "strip", import("zod").ZodTypeAny, {
4899
4903
  status: "published" | "draft" | "archived";
4900
4904
  title: string;
@@ -4911,6 +4915,7 @@ export declare const SCHEMAS: {
4911
4915
  isPinned: boolean;
4912
4916
  metaTitle: string;
4913
4917
  metaDescription: string;
4918
+ couponIds?: string[] | undefined;
4914
4919
  }, {
4915
4920
  status: "published" | "draft" | "archived";
4916
4921
  title: string;
@@ -4927,6 +4932,7 @@ export declare const SCHEMAS: {
4927
4932
  isPinned: boolean;
4928
4933
  metaTitle: string;
4929
4934
  metaDescription: string;
4935
+ couponIds?: string[] | undefined;
4930
4936
  }>;
4931
4937
  readonly event: import("zod").ZodEffects<import("zod").ZodObject<{
4932
4938
  type: import("zod").ZodString;
@@ -4945,6 +4951,8 @@ export declare const SCHEMAS: {
4945
4951
  id: string;
4946
4952
  label: string;
4947
4953
  }>, "many">;
4954
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4955
+ rafflePrizeCouponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4948
4956
  }, "passthrough", import("zod").ZodTypeAny, import("zod").objectOutputType<{
4949
4957
  type: import("zod").ZodString;
4950
4958
  title: import("zod").ZodString;
@@ -4962,6 +4970,8 @@ export declare const SCHEMAS: {
4962
4970
  id: string;
4963
4971
  label: string;
4964
4972
  }>, "many">;
4973
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4974
+ rafflePrizeCouponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4965
4975
  }, import("zod").ZodTypeAny, "passthrough">, import("zod").objectInputType<{
4966
4976
  type: import("zod").ZodString;
4967
4977
  title: import("zod").ZodString;
@@ -4979,6 +4989,8 @@ export declare const SCHEMAS: {
4979
4989
  id: string;
4980
4990
  label: string;
4981
4991
  }>, "many">;
4992
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4993
+ rafflePrizeCouponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4982
4994
  }, import("zod").ZodTypeAny, "passthrough">>, import("zod").objectOutputType<{
4983
4995
  type: import("zod").ZodString;
4984
4996
  title: import("zod").ZodString;
@@ -4996,6 +5008,8 @@ export declare const SCHEMAS: {
4996
5008
  id: string;
4997
5009
  label: string;
4998
5010
  }>, "many">;
5011
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
5012
+ rafflePrizeCouponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
4999
5013
  }, import("zod").ZodTypeAny, "passthrough">, import("zod").objectInputType<{
5000
5014
  type: import("zod").ZodString;
5001
5015
  title: import("zod").ZodString;
@@ -5013,6 +5027,8 @@ export declare const SCHEMAS: {
5013
5027
  id: string;
5014
5028
  label: string;
5015
5029
  }>, "many">;
5030
+ couponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
5031
+ rafflePrizeCouponIds: import("zod").ZodType<string[] | undefined, import("zod").ZodTypeDef, string[] | undefined>;
5016
5032
  }, import("zod").ZodTypeAny, "passthrough">>;
5017
5033
  readonly productFeature: import("zod").ZodEffects<import("zod").ZodObject<{
5018
5034
  label: import("zod").ZodString;
@@ -52,6 +52,7 @@ export { listPublicProducts, parsePublicProductParams, defaultAvailabilityForLis
52
52
  export { getAuctionForDetail, getProductFeaturesForAuction, } from "./_internal/server/features/auctions/index";
53
53
  export { getPreOrderForDetail, getProductFeaturesForPreOrder, } from "./_internal/server/features/pre-orders/index";
54
54
  export { getBrandForDetail, getBrandCategoryForDetail, createBrandAction, updateBrandAction, deleteBrandAction, toggleBrandActiveAction, } from "./_internal/server/features/brands/index";
55
+ export { getAttachedCoupons, toAttachedCoupon } from "./_internal/server/features/promotions/index";
55
56
  export { getBlogPostForDetail, getBlogPostById, createBlogPostAction, updateBlogPostAction, deleteBlogPostAction, publishBlogPostAction, unpublishBlogPostAction, } from "./_internal/server/features/blog/index";
56
57
  export { getEventForDetail, registerForEventAction, cancelEventRegistrationAction, createEventAction, updateEventAction, } from "./_internal/server/features/events/index";
57
58
  export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./_internal/server/features/products/index";
@@ -61,6 +61,8 @@ export { getAuctionForDetail, getProductFeaturesForAuction, } from "./_internal/
61
61
  export { getPreOrderForDetail, getProductFeaturesForPreOrder, } from "./_internal/server/features/pre-orders/index.js";
62
62
  // S3: brands data layer + actions
63
63
  export { getBrandForDetail, getBrandCategoryForDetail, createBrandAction, updateBrandAction, deleteBrandAction, toggleBrandActiveAction, } from "./_internal/server/features/brands/index.js";
64
+ // Promotional coupons attached to content (events + blog posts)
65
+ export { getAttachedCoupons, toAttachedCoupon } from "./_internal/server/features/promotions/index.js";
64
66
  // S3: blog data layer + actions
65
67
  export { getBlogPostForDetail, getBlogPostById, createBlogPostAction, updateBlogPostAction, deleteBlogPostAction, publishBlogPostAction, unpublishBlogPostAction, } from "./_internal/server/features/blog/index.js";
66
68
  // S3: events data layer + actions
package/dist/server.d.ts CHANGED
@@ -478,6 +478,7 @@ export { getBrandForDetail } from "./_internal/server/features/brands/index";
478
478
  export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index";
479
479
  export { getBlogPostForDetail } from "./_internal/server/features/blog/index";
480
480
  export { getEventForDetail } from "./_internal/server/features/events/index";
481
+ export { getAttachedCoupons, toAttachedCoupon } from "./_internal/server/features/promotions/index";
481
482
  export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index";
482
483
  export { getCategoryForDetail, listRootCategories, listFeaturedCategories, listMenuCategories, getCategoryTree, listSitemapCategories } from "./_internal/server/features/categories/index";
483
484
  export { renderProductOg, renderProductOgImage, type ProductOgData, type OgOptions } from "./_internal/server/features/products/og";