@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
- package/dist/_internal/server/features/promotions/adapters.js +43 -0
- package/dist/_internal/server/features/promotions/data.d.ts +26 -0
- package/dist/_internal/server/features/promotions/data.js +45 -0
- package/dist/_internal/server/features/promotions/index.d.ts +2 -1
- package/dist/_internal/server/features/promotions/index.js +2 -1
- package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
- package/dist/constants/field-names.d.ts +4 -0
- package/dist/constants/field-names.js +4 -0
- package/dist/features/account/components/UserSidebar.js +1 -1
- package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +14 -0
- package/dist/features/blog/schemas/firestore.d.ts +17 -0
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +3 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/events/components/AdminEventEditorView.js +64 -9
- package/dist/features/events/components/EventOfferCard.js +3 -18
- package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
- package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
- package/dist/features/events/schemas/event-form.d.ts +10 -0
- package/dist/features/events/schemas/event-form.js +19 -0
- package/dist/features/events/schemas/firestore.d.ts +68 -0
- package/dist/features/events/schemas/firestore.js +25 -0
- package/dist/features/events/schemas/index.d.ts +7 -0
- package/dist/features/events/schemas/index.js +5 -0
- package/dist/features/events/types/index.d.ts +4 -0
- package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
- package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
- package/dist/features/promotions/components/index.d.ts +4 -0
- package/dist/features/promotions/components/index.js +2 -0
- package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
- package/dist/features/promotions/constants/attached-coupons.js +24 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
- package/dist/features/promotions/repository/coupons.repository.js +39 -0
- package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
- package/dist/features/promotions/schemas/attached-coupons.js +66 -0
- package/dist/features/shell/SectionForm.js +47 -2
- package/dist/schemas/registry.d.ts +16 -0
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +2 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +4 -0
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.63.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.63.4",
|
|
4
|
+
"head": "75f5bc368ad0433147e270c708aed152c9952ce0",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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-[
|
|
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";
|
|
@@ -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;
|