@mohasinac/appkit 4.63.3 → 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 (48) 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/schemas/registry.d.ts +16 -0
  44. package/dist/server-entry.d.ts +1 -0
  45. package/dist/server-entry.js +2 -0
  46. package/dist/server.d.ts +1 -0
  47. package/dist/server.js +4 -0
  48. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.63.3",
4
- "head": "aea6affd6821cc7b43256c25614e42422df7177c",
3
+ "version": "4.63.4",
4
+ "head": "75f5bc368ad0433147e270c708aed152c9952ce0",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-23T10:12:11.248Z"
7
+ "builtAt": "2026-10-23T12:57:38.033Z"
8
8
  }
@@ -0,0 +1,25 @@
1
+ import type { CouponDocument } from "../../../../features/promotions/schemas";
2
+ import type { AttachedCoupon } from "../../../../features/promotions/constants/attached-coupons";
3
+ /**
4
+ * `CouponDocument` → the public `AttachedCoupon` shape.
5
+ *
6
+ * 🛑 Built field by field. Never `{ ...doc }` with deletes — that publishes
7
+ * every key nobody thought to remove, including ones the interface does not
8
+ * declare, which is exactly how `adSettings.providerCredentials` and a
9
+ * decrypted WhatsApp token reached public HTML (Root Cause #70).
10
+ *
11
+ * Deliberately NOT carried across, each for its own reason:
12
+ * usage.* — `currentUsage` / `totalLimit` is redemption telemetry;
13
+ * a reader who learns a code is near its cap learns
14
+ * nothing useful and the seller loses a lever.
15
+ * analytics.* — `totalRevenue` / `totalDiscount` is seller revenue.
16
+ * restrictions.* — `applicableProducts` / `excludeCategories` is the
17
+ * coupon's internal targeting; checkout enforces it and
18
+ * says so there, in context, against a real cart.
19
+ * createdBy — a uid.
20
+ * scope/storeId — not rendered; see the note in AttachedCouponsSection
21
+ * about why the card does not label a seller.
22
+ * bxgy/tiers — structured configs with no renderer; adding one is a
23
+ * feature, not a projection decision.
24
+ */
25
+ export declare function toAttachedCoupon(doc: CouponDocument): AttachedCoupon;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `CouponDocument` → the public `AttachedCoupon` shape.
3
+ *
4
+ * 🛑 Built field by field. Never `{ ...doc }` with deletes — that publishes
5
+ * every key nobody thought to remove, including ones the interface does not
6
+ * declare, which is exactly how `adSettings.providerCredentials` and a
7
+ * decrypted WhatsApp token reached public HTML (Root Cause #70).
8
+ *
9
+ * Deliberately NOT carried across, each for its own reason:
10
+ * usage.* — `currentUsage` / `totalLimit` is redemption telemetry;
11
+ * a reader who learns a code is near its cap learns
12
+ * nothing useful and the seller loses a lever.
13
+ * analytics.* — `totalRevenue` / `totalDiscount` is seller revenue.
14
+ * restrictions.* — `applicableProducts` / `excludeCategories` is the
15
+ * coupon's internal targeting; checkout enforces it and
16
+ * says so there, in context, against a real cart.
17
+ * createdBy — a uid.
18
+ * scope/storeId — not rendered; see the note in AttachedCouponsSection
19
+ * about why the card does not label a seller.
20
+ * bxgy/tiers — structured configs with no renderer; adding one is a
21
+ * feature, not a projection decision.
22
+ */
23
+ export function toAttachedCoupon(doc) {
24
+ return {
25
+ id: doc.id,
26
+ code: doc.code,
27
+ name: doc.name,
28
+ description: doc.description,
29
+ type: doc.type,
30
+ discountValue: doc.discount.value,
31
+ maxDiscount: doc.discount.maxDiscount,
32
+ minPurchase: doc.discount.minPurchase,
33
+ /*
34
+ * ISO, not a `Date`. This crosses a Server → Client Component boundary and
35
+ * a `Date` survives that only because Next serialises it; an ISO string is
36
+ * the shape the renderer actually wants and removes the question.
37
+ *
38
+ * 🛑 Absent means NO END DATE, never "expired". A renderer that treats a
39
+ * missing `endsAt` as expired hides every open-ended coupon.
40
+ */
41
+ endsAt: doc.validity.endDate ? new Date(doc.validity.endDate).toISOString() : undefined,
42
+ };
43
+ }
@@ -1 +1,27 @@
1
+ import type { AttachedCoupon } from "../../../../features/promotions/constants/attached-coupons";
1
2
  export declare const getCouponByCode: (code: string) => Promise<import("../../../..").CouponDocument | null>;
3
+ /**
4
+ * Resolve the coupons a piece of content has attached, ready to render.
5
+ *
6
+ * Shared by the event detail page and the blog post page — one implementation,
7
+ * because two hand-rolled copies of "read the ids, drop the dead ones, project"
8
+ * is how a fix lands on the route and never reaches the SSR view that
9
+ * duplicates it (Root Cause #59/#99).
10
+ *
11
+ * 🛑 `cache`d because the page body and `generateMetadata` may both want it.
12
+ * Keyed on the argument list, so callers must pass the SAME array contents —
13
+ * which they do, both reading `doc.couponIds`.
14
+ *
15
+ * 🛑 EXPIRED AND INACTIVE COUPONS ARE DROPPED HERE, not in the renderer.
16
+ * A blog post from March that still advertises a code which now errors at
17
+ * checkout is worse than one that advertises nothing: the reader does the
18
+ * work of copying it and is refused at the till. `isCouponValid` is the same
19
+ * predicate checkout applies (active + inside its window + under its total
20
+ * limit), so the page cannot promise what the till will decline.
21
+ *
22
+ * It does NOT check `perUserLimit` — that is per-reader and unknowable on a
23
+ * cached public page. A reader who has personally exhausted a still-valid
24
+ * coupon is refused at checkout with a message that can explain why; that is
25
+ * the correct place for it.
26
+ */
27
+ export declare const getAttachedCoupons: (couponIds: readonly string[] | undefined) => Promise<AttachedCoupon[]>;
@@ -1,5 +1,50 @@
1
1
  import { cache } from "react";
2
2
  import { couponsRepository } from "../../../../repositories/index.js";
3
+ import { isCouponValid } from "../../../../features/promotions/schemas/index.js";
4
+ import { toAttachedCoupon } from "./adapters.js";
5
+ import { safeRead } from "../../../../errors/safe-read.js";
3
6
  export const getCouponByCode = cache(async (code) => {
4
7
  return (await couponsRepository.getCouponByCode(code)) ?? null;
5
8
  });
9
+ /**
10
+ * Resolve the coupons a piece of content has attached, ready to render.
11
+ *
12
+ * Shared by the event detail page and the blog post page — one implementation,
13
+ * because two hand-rolled copies of "read the ids, drop the dead ones, project"
14
+ * is how a fix lands on the route and never reaches the SSR view that
15
+ * duplicates it (Root Cause #59/#99).
16
+ *
17
+ * 🛑 `cache`d because the page body and `generateMetadata` may both want it.
18
+ * Keyed on the argument list, so callers must pass the SAME array contents —
19
+ * which they do, both reading `doc.couponIds`.
20
+ *
21
+ * 🛑 EXPIRED AND INACTIVE COUPONS ARE DROPPED HERE, not in the renderer.
22
+ * A blog post from March that still advertises a code which now errors at
23
+ * checkout is worse than one that advertises nothing: the reader does the
24
+ * work of copying it and is refused at the till. `isCouponValid` is the same
25
+ * predicate checkout applies (active + inside its window + under its total
26
+ * limit), so the page cannot promise what the till will decline.
27
+ *
28
+ * It does NOT check `perUserLimit` — that is per-reader and unknowable on a
29
+ * cached public page. A reader who has personally exhausted a still-valid
30
+ * coupon is refused at checkout with a message that can explain why; that is
31
+ * the correct place for it.
32
+ */
33
+ export const getAttachedCoupons = cache(async (couponIds) => {
34
+ if (!couponIds || couponIds.length === 0)
35
+ return [];
36
+ /*
37
+ * `safeRead`, so a Firestore hiccup (or a missing index on a cold
38
+ * collection) costs the page its coupon strip and not the whole render.
39
+ * These pages are prerendered, and an unguarded throw during the
40
+ * prerender ATTEMPT is fatal to the production build — see the dashboard
41
+ * note in § SSR Architecture. The failure is recorded as a DEGRADED_READ
42
+ * rather than swallowed, which is the distinction Root Cause #59 is about.
43
+ */
44
+ const docs = await safeRead(() => couponsRepository.findByIds([...couponIds]), {
45
+ route: "content.attachedCoupons",
46
+ key: "promotions.findByIds",
47
+ fallback: [],
48
+ });
49
+ return docs.filter(isCouponValid).map(toAttachedCoupon);
50
+ });
@@ -1,4 +1,5 @@
1
- export { getCouponByCode } from "./data";
1
+ export { getCouponByCode, getAttachedCoupons } from "./data";
2
+ export { toAttachedCoupon } from "./adapters";
2
3
  export { validateCoupon, computeDiscount } from "./service";
3
4
  export { applyCouponAction, createCouponAction, updateCouponAction, deactivateCouponAction } from "./actions";
4
5
  export { COUPONS_PAGE_SIZE, COUPON_CODE_MAX_LENGTH } from "../../../shared/features/promotions/config";
@@ -1,4 +1,5 @@
1
- export { getCouponByCode } from "./data.js";
1
+ export { getCouponByCode, getAttachedCoupons } from "./data.js";
2
+ export { toAttachedCoupon } from "./adapters.js";
2
3
  export { validateCoupon, computeDiscount } from "./service.js";
3
4
  export { applyCouponAction, createCouponAction, updateCouponAction, deactivateCouponAction } from "./actions.js";
4
5
  export { COUPONS_PAGE_SIZE, COUPON_CODE_MAX_LENGTH } from "../../../shared/features/promotions/config.js";
@@ -41,6 +41,7 @@ import { EVENT_ENTRY_FIELDS, EVENT_FIELDS } from "../../../../constants/field-na
41
41
  import { EVENT_ENTRY_PII_FIELDS, maskPii } from "../../../../security/index.js";
42
42
  import { resolveEventRef } from "./_event-ref.js";
43
43
  import { EVENT_DRAW_REASON } from "./event-draw-reasons.js";
44
+ import { rafflePrizeCouponIdsOf } from "../../../../features/events/schemas/firestore.js";
44
45
  /**
45
46
  * The winner's display name, masked for a PUBLIC announcement.
46
47
  *
@@ -158,22 +159,51 @@ export async function runTriggerEventRaffle(input, ctx) {
158
159
  * re-deriving it. Resolution is best-effort: a missing coupon must not cost
159
160
  * somebody their win.
160
161
  */
161
- let rafflePrizeCouponCode;
162
- if (event.rafflePrizeCouponId) {
163
- const coupSnap = await ctx.db
164
- .collection(COUPONS_COLLECTION)
165
- .doc(event.rafflePrizeCouponId)
166
- .get();
167
- if (coupSnap.exists) {
168
- rafflePrizeCouponCode = coupSnap.data().code;
169
- }
170
- else {
171
- ctx.logger.warn("Raffle prize couponId does not resolve to a coupon", {
172
- eventId,
173
- couponId: event.rafflePrizeCouponId,
174
- });
162
+ /*
163
+ * 🛑 Through `rafflePrizeCouponIdsOf`, so an event written before the
164
+ * widening — carrying only the deprecated scalar — still awards its prize.
165
+ * Reading `rafflePrizeCouponIds` alone would silently hand those winners
166
+ * nothing, and a raffle that draws a winner and awards no prize is a bug
167
+ * with no error and no second chance: the draw is irreversible.
168
+ */
169
+ const prizeCouponIds = rafflePrizeCouponIdsOf(event);
170
+ const rafflePrizeCouponCodes = [];
171
+ if (prizeCouponIds.length > 0) {
172
+ /*
173
+ * ONE `getAll` for all of them, not a loop of `.get()`. This runs inside a
174
+ * Cloud Function rather than a Vercel route, so it is not against Rule #6's
175
+ * 10s ceiling — but it sits immediately before a transaction, and holding
176
+ * N sequential round trips open there widens the window in which a
177
+ * concurrent draw can collide.
178
+ */
179
+ const refs = prizeCouponIds.map((id) => ctx.db.collection(COUPONS_COLLECTION).doc(id));
180
+ const snaps = await ctx.db.getAll(...refs);
181
+ for (const snap of snaps) {
182
+ if (snap.exists) {
183
+ const code = snap.data().code;
184
+ if (code)
185
+ rafflePrizeCouponCodes.push(code);
186
+ }
187
+ else {
188
+ /*
189
+ * Best-effort, per coupon: one deleted coupon must not cost the
190
+ * winner the other prizes, and must not cost them the win at all.
191
+ * Warned rather than thrown for exactly that reason.
192
+ */
193
+ ctx.logger.warn("Raffle prize couponId does not resolve to a coupon", {
194
+ eventId,
195
+ couponId: snap.id,
196
+ });
197
+ }
175
198
  }
176
199
  }
200
+ /*
201
+ * The first code is still written to the singular `rafflePrizeCouponCode`
202
+ * the winner page already reads, so that page keeps working untouched while
203
+ * the full list lands beside it. Dropping the scalar would blank the prize
204
+ * on every existing winner announcement.
205
+ */
206
+ const rafflePrizeCouponCode = rafflePrizeCouponCodes[0];
177
207
  /*
178
208
  * 🛑 The one-draw invariant. Re-read the event INSIDE the transaction.
179
209
  *
@@ -209,6 +239,7 @@ export async function runTriggerEventRaffle(input, ctx) {
209
239
  [EVENT_FIELDS.RAFFLE_TRIGGERED_AT]: ctx.now,
210
240
  [EVENT_FIELDS.RAFFLE_ENTRY_COUNT]: entriesSnap.size,
211
241
  ...(rafflePrizeCouponCode ? { rafflePrizeCouponCode } : {}),
242
+ ...(rafflePrizeCouponCodes.length > 0 ? { rafflePrizeCouponCodes } : {}),
212
243
  [EVENT_FIELDS.RAFFLE_GITHUB_FUNCTION_URL]: `${ctx.env("PROOF_OF_FAIRNESS_BASE_URL") ?? ""}/raffles/${eventId}.json`,
213
244
  [EVENT_FIELDS.UPDATED_AT]: ctx.now,
214
245
  });
@@ -382,11 +382,15 @@ export declare const EVENT_FIELDS: {
382
382
  readonly ENDS_AT: "endsAt";
383
383
  readonly CREATED_BY: "createdBy";
384
384
  readonly STATS: "stats";
385
+ /** Promotional coupons shown on the event page — not prizes. */
386
+ readonly COUPON_IDS: "couponIds";
385
387
  readonly HAS_RAFFLE: "hasRaffle";
386
388
  readonly RAFFLE_TYPE: "raffleType";
387
389
  readonly RAFFLE_TOP_N: "raffleTopN";
388
390
  readonly RAFFLE_PRIZE: "rafflePrize";
391
+ /** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
389
392
  readonly RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId";
393
+ readonly RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds";
390
394
  readonly RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl";
391
395
  readonly RAFFLE_WINNER_USER_ID: "raffleWinnerUserId";
392
396
  readonly RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName";
@@ -436,12 +436,16 @@ export const EVENT_FIELDS = {
436
436
  ENDS_AT: "endsAt",
437
437
  CREATED_BY: "createdBy",
438
438
  STATS: "stats",
439
+ /** Promotional coupons shown on the event page — not prizes. */
440
+ COUPON_IDS: "couponIds",
439
441
  // Raffle fields (SB9)
440
442
  HAS_RAFFLE: "hasRaffle",
441
443
  RAFFLE_TYPE: "raffleType",
442
444
  RAFFLE_TOP_N: "raffleTopN",
443
445
  RAFFLE_PRIZE: "rafflePrize",
446
+ /** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
444
447
  RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId",
448
+ RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds",
445
449
  RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl",
446
450
  RAFFLE_WINNER_USER_ID: "raffleWinnerUserId",
447
451
  RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName",
@@ -97,7 +97,7 @@ export function UserSidebar({ items, groups, mobileOpen = false, onCloseMobile,
97
97
  // ── Persistent sidebar variant (desktop left slide-over, mobile bottom-sheet) ──────
98
98
  if (variant === "sidebar") {
99
99
  const handleToggle = onToggle ?? close;
100
- return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[13rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "default", className: `flex-1 ${rail.borderClass} dark:border-[var(--appkit-color-border)] ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: "My Account" }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname }) }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: "My Account", children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }) }) })] }));
100
+ return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[18rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "default", className: `flex-1 ${rail.borderClass} dark:border-[var(--appkit-color-border)] ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: "My Account" }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname }) }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: "My Account", children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }) }) })] }));
101
101
  }
102
102
  // ── Overlay variant (default) — portal on desktop, bottom-sheet on mobile ─
103
103
  const content = (_jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }));
@@ -10,6 +10,8 @@ import { ImageUpload, MediaImage, useMediaUpload } from "../../media/index.js";
10
10
  import { apiClient } from "../../../http/index.js";
11
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
12
12
  import { blogDraftSchema } from "../../blog/schemas/blog-form.js";
13
+ import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect.js";
14
+ import { ATTACHED_COUPON_MAX } from "../../promotions/constants/attached-coupons.js";
13
15
  import { SectionForm, useSectionFormNav } from "../../shell/index.js";
14
16
  const __P = {
15
17
  p4: "p-[var(--appkit-space-4)]",
@@ -39,6 +41,7 @@ const DEFAULT_DRAFT = {
39
41
  youtubeId: "",
40
42
  category: "news",
41
43
  tags: [],
44
+ couponIds: [],
42
45
  status: "published",
43
46
  isFeatured: true,
44
47
  authorName: "Admin",
@@ -103,6 +106,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
103
106
  youtubeId: p.youtubeId ?? "",
104
107
  category: p.category ?? "news",
105
108
  tags: Array.isArray(p.tags) ? p.tags : [],
109
+ couponIds: Array.isArray(p.couponIds) ? p.couponIds : [],
106
110
  status: p.status ?? "published",
107
111
  isFeatured: p.isFeatured ?? true,
108
112
  authorName: p.authorName ?? "Admin",
@@ -125,6 +129,10 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
125
129
  youtubeId: draft.youtubeId || undefined,
126
130
  category: draft.category,
127
131
  tags: draft.tags,
132
+ // Unconditional, `[]` included — see the matching note in the event
133
+ // editor: a key omitted when empty cannot clear the stored array,
134
+ // because the route merges.
135
+ couponIds: draft.couponIds,
128
136
  status: draft.status,
129
137
  isFeatured: draft.isFeatured,
130
138
  authorName: draft.authorName,
@@ -236,8 +244,8 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
236
244
  id: "publish",
237
245
  label: "Publish",
238
246
  quick: true,
239
- fields: ["status", "publishedAt", "authorName", "isFeatured"],
240
- render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
247
+ fields: ["status", "publishedAt", "authorName", "isFeatured", "couponIds"],
248
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Coupons (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", multiple: true, value: values.couponIds, onChange: (ids) => onChange({ couponIds: ids }), placeholder: "Search coupons to feature in this post\u2026", allowCreate: false }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] mt-1", size: "xs", children: ["Shown as copyable code cards below the article. Up to ", ATTACHED_COUPON_MAX, ". Expired and deactivated coupons are hidden automatically, so an old post never advertises a dead code."] })] }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
241
249
  },
242
250
  ];
243
251
  const deleteModal = (_jsx(ConfirmDeleteModal, { isOpen: showDeleteConfirm, onConfirm: () => {
@@ -48,6 +48,7 @@ export declare const blogDraftSchema: z.ZodObject<{
48
48
  authorName: z.ZodString;
49
49
  isFeatured: z.ZodBoolean;
50
50
  isPinned: z.ZodBoolean;
51
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
51
52
  }, "strip", z.ZodTypeAny, {
52
53
  status: "published" | "draft" | "archived";
53
54
  title: string;
@@ -64,6 +65,7 @@ export declare const blogDraftSchema: z.ZodObject<{
64
65
  isPinned: boolean;
65
66
  metaTitle: string;
66
67
  metaDescription: string;
68
+ couponIds?: string[] | undefined;
67
69
  }, {
68
70
  status: "published" | "draft" | "archived";
69
71
  title: string;
@@ -80,4 +82,5 @@ export declare const blogDraftSchema: z.ZodObject<{
80
82
  isPinned: boolean;
81
83
  metaTitle: string;
82
84
  metaDescription: string;
85
+ couponIds?: string[] | undefined;
83
86
  }>;
@@ -20,6 +20,7 @@
20
20
  import { z } from "zod";
21
21
  import { annotate } from "../../shell/field-ui-meta.js";
22
22
  import { blogPostCategorySchema } from "./index.js";
23
+ import { attachedCouponIdsSchema } from "../../promotions/schemas/attached-coupons.js";
23
24
  /**
24
25
  * The blog form's own schema. Distinct from `blogPostSchema` (blog/schemas),
25
26
  * which requires an `id` this draft cannot have before its first save and
@@ -98,4 +99,17 @@ export const blogDraftSchema = z.object({
98
99
  authorName: annotate(z.string(), { section: "publish", order: 3, row: "pair" }),
99
100
  isFeatured: annotate(z.boolean(), { section: "publish", order: 4, row: "quarter" }),
100
101
  isPinned: annotate(z.boolean(), { section: "publish", order: 5, row: "quarter" }),
102
+ /*
103
+ * 🛑 Declared here even though the picker enforces the cap in the UI, and
104
+ * this file's own header says why: `z.object()` STRIPS unknown keys, so a
105
+ * field missing from this schema is silently dropped on every save — the
106
+ * form would show the coupons, accept the edit, and persist nothing. Same
107
+ * shape as the grouped-listings PATCH in Root Cause #76(b).
108
+ *
109
+ * The cap is `attachedCouponIdsSchema`'s, imported rather than re-stated, so
110
+ * the form and the route cannot disagree about what "too many" means.
111
+ */
112
+ couponIds: annotate(attachedCouponIdsSchema, {
113
+ section: "publish", order: 6, row: "full", kind: "list",
114
+ }),
101
115
  });
@@ -28,6 +28,22 @@ export interface BlogPostDocument extends BaseDocument {
28
28
  youtubeId?: string;
29
29
  category: BlogPostCategory;
30
30
  tags: string[];
31
+ /**
32
+ * PROMOTIONAL coupons shown below the article for a reader to copy.
33
+ *
34
+ * Mirrors `EventDocument.couponIds` exactly — same name, same shape, same
35
+ * `<AttachedCouponsSection>` renderer, same `getAttachedCoupons` resolver.
36
+ * That symmetry is the point: "attach coupons to content" is one feature
37
+ * with two hosts, not two features that happen to look alike.
38
+ *
39
+ * 🛑 OPTIONAL, unlike `isPinned` above, and for a reason worth keeping
40
+ * straight: `isPinned` had to be required because it is an `orderBy` field
41
+ * and Firestore excludes a document that lacks one (Root Cause #100).
42
+ * `couponIds` is never sorted or filtered on — it is read only after a post
43
+ * has already been fetched by slug — so an absent field costs nothing and
44
+ * needs no backfill across the 21 existing posts.
45
+ */
46
+ couponIds?: string[];
31
47
  isFeatured: boolean;
32
48
  /**
33
49
  * Keeps the post at the top of the blog index and the homepage
@@ -90,6 +106,7 @@ export declare const BLOG_POST_FIELDS: {
90
106
  readonly YOUTUBE_ID: "youtubeId";
91
107
  readonly CATEGORY: "category";
92
108
  readonly TAGS: "tags";
109
+ readonly COUPON_IDS: "couponIds";
93
110
  readonly IS_FEATURED: "isFeatured";
94
111
  readonly IS_PINNED: "isPinned";
95
112
  readonly STATUS: "status";
@@ -50,6 +50,7 @@ export const BLOG_POST_FIELDS = {
50
50
  YOUTUBE_ID: "youtubeId",
51
51
  CATEGORY: "category",
52
52
  TAGS: "tags",
53
+ COUPON_IDS: "couponIds",
53
54
  IS_FEATURED: "isFeatured",
54
55
  IS_PINNED: "isPinned",
55
56
  STATUS: "status",
@@ -98,6 +98,7 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
98
98
  }>, "many">>;
99
99
  category: z.ZodString;
100
100
  tags: z.ZodArray<z.ZodString, "many">;
101
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
101
102
  isFeatured: z.ZodBoolean;
102
103
  isPinned: z.ZodBoolean;
103
104
  status: z.ZodEnum<["draft", "published", "archived"]>;
@@ -139,6 +140,7 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
139
140
  publishedAt?: string | Date | z.objectOutputType<{
140
141
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
141
142
  }, z.ZodTypeAny, "passthrough"> | undefined;
143
+ couponIds?: string[] | undefined;
142
144
  coverImage?: {
143
145
  type: "video" | "image" | "file";
144
146
  url: string;
@@ -193,6 +195,7 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
193
195
  publishedAt?: string | Date | z.objectInputType<{
194
196
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
195
197
  }, z.ZodTypeAny, "passthrough"> | undefined;
198
+ couponIds?: string[] | undefined;
196
199
  coverImage?: {
197
200
  type: "video" | "image" | "file";
198
201
  url: string;
@@ -345,6 +348,7 @@ export declare const blogPostSchema: z.ZodObject<{
345
348
  }>, "many">>>;
346
349
  category: z.ZodEnum<["news", "tips", "guides", "updates", "community"]>;
347
350
  tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
351
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
348
352
  isFeatured: z.ZodOptional<z.ZodBoolean>;
349
353
  isPinned: z.ZodOptional<z.ZodBoolean>;
350
354
  status: z.ZodEnum<["draft", "published", "archived"]>;
@@ -400,6 +404,7 @@ export declare const blogPostSchema: z.ZodObject<{
400
404
  publishedAt?: string | undefined;
401
405
  views?: number | undefined;
402
406
  tags?: string[] | undefined;
407
+ couponIds?: string[] | undefined;
403
408
  isFeatured?: boolean | undefined;
404
409
  excerpt?: string | undefined;
405
410
  authorId?: string | undefined;
@@ -421,6 +426,7 @@ export declare const blogPostSchema: z.ZodObject<{
421
426
  publishedAt?: string | undefined;
422
427
  views?: number | undefined;
423
428
  tags?: string[] | undefined;
429
+ couponIds?: string[] | undefined;
424
430
  isFeatured?: boolean | undefined;
425
431
  excerpt?: string | undefined;
426
432
  coverImage?: string | {
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { mediaUrlSchema } from "../../../validation/schemas.js";
4
4
  import { mediaFieldSchema } from "../../media/types/index.js";
5
5
  import { auditTimestampsShape, firestoreDateSchema } from "../../../schemas/firestore-helpers.js";
6
+ import { attachedCouponIdsSchema } from "../../promotions/schemas/attached-coupons.js";
6
7
  // ─── Firestore document schema (W2) ───────────────────────────────────────────
7
8
  // Mirrors BlogPostDocument in ./firestore.ts. Registered into SCHEMAS.firestore.blogPosts.
8
9
  export const blogPostStatusEnumSchema = z.enum(["draft", "published", "archived"]);
@@ -17,6 +18,7 @@ export const blogPostFirestoreSchema = z.object({
17
18
  additionalImages: z.array(mediaFieldSchema).optional(),
18
19
  category: z.string(),
19
20
  tags: z.array(z.string()),
21
+ couponIds: attachedCouponIdsSchema,
20
22
  isFeatured: z.boolean(),
21
23
  isPinned: z.boolean(),
22
24
  status: blogPostStatusEnumSchema,
@@ -72,6 +74,7 @@ export const blogPostSchema = z.object({
72
74
  additionalImages: z.array(mediaFieldSchema).max(5).optional().default([]),
73
75
  category: blogPostCategorySchema,
74
76
  tags: z.array(z.string()).optional(),
77
+ couponIds: attachedCouponIdsSchema,
75
78
  isFeatured: z.boolean().optional(),
76
79
  isPinned: z.boolean().optional(),
77
80
  status: blogPostStatusSchema,
@@ -14,6 +14,8 @@ export interface BlogPost {
14
14
  youtubeId?: string;
15
15
  category: BlogPostCategory;
16
16
  tags?: string[];
17
+ /** Promotional coupons shown below the article — see `BlogPostDocument.couponIds`. */
18
+ couponIds?: string[];
17
19
  isFeatured?: boolean;
18
20
  /** Keeps the post above newer ones on the blog index and homepage. */
19
21
  isPinned?: boolean;