@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.
- 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/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
|
@@ -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
|
+
}
|
|
@@ -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;
|
package/dist/server-entry.d.ts
CHANGED
|
@@ -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";
|
package/dist/server-entry.js
CHANGED
|
@@ -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";
|
package/dist/server.js
CHANGED
|
@@ -1402,6 +1402,10 @@ export { getBrandForDetail } from "./_internal/server/features/brands/index.js";
|
|
|
1402
1402
|
export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index.js";
|
|
1403
1403
|
export { getBlogPostForDetail } from "./_internal/server/features/blog/index.js";
|
|
1404
1404
|
export { getEventForDetail } from "./_internal/server/features/events/index.js";
|
|
1405
|
+
// Attached promotional coupons — shared by the event detail page and the blog
|
|
1406
|
+
// post page, which is why it lives in its own feature rather than inside
|
|
1407
|
+
// either one.
|
|
1408
|
+
export { getAttachedCoupons, toAttachedCoupon } from "./_internal/server/features/promotions/index.js";
|
|
1405
1409
|
export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index.js";
|
|
1406
1410
|
export { getCategoryForDetail, listRootCategories, listFeaturedCategories, listMenuCategories, getCategoryTree, listSitemapCategories } from "./_internal/server/features/categories/index.js";
|
|
1407
1411
|
// OG image renderers — high-level (renderXOg) + low-level primitive (renderXOgImage)
|