@mohasinac/appkit 4.14.0 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/auctions/actions.js +2 -2
- package/dist/_internal/server/features/cart/service.d.ts +3 -3
- package/dist/_internal/server/features/checkout/actions.js +19 -0
- package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
- package/dist/_internal/server/features/checkout/source-context.js +157 -0
- package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
- package/dist/_internal/server/features/offers/adapters.js +88 -0
- package/dist/_internal/server/features/offers/index.d.ts +2 -0
- package/dist/_internal/server/features/offers/index.js +1 -0
- package/dist/_internal/server/features/orders/adapters.js +24 -0
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
- package/dist/_internal/shared/actions/action-registry.js +3 -1
- package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
- package/dist/_internal/shared/features/auctions/schema.js +13 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +14 -9
- package/dist/_internal/shared/features/cart/schema.js +15 -0
- package/dist/_internal/shared/features/products/schema.d.ts +2 -2
- package/dist/_internal/shared/history/history.d.ts +22 -0
- package/dist/_internal/shared/history/history.js +41 -1
- package/dist/client.d.ts +13 -2
- package/dist/client.js +20 -2
- package/dist/constants/field-names.d.ts +8 -6
- package/dist/constants/field-names.js +48 -6
- package/dist/features/account/components/UserBidsView.js +12 -4
- package/dist/features/account/components/UserReturnsView.d.ts +3 -0
- package/dist/features/account/components/UserReturnsView.js +17 -1
- package/dist/features/admin/components/AdminAuditLogView.js +1 -0
- package/dist/features/admin/components/AdminBidsView.js +15 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
- package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
- package/dist/features/admin/components/AdminNavEditorView.js +15 -5
- package/dist/features/admin/components/AdminNewsletterView.js +18 -2
- package/dist/features/admin/components/AdminOffersView.js +62 -18
- package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
- package/dist/features/admin/components/AdminScammersView.js +3 -8
- package/dist/features/admin/components/AdminSectionsView.js +32 -23
- package/dist/features/admin/components/AdminSessionsView.js +17 -2
- package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
- package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
- package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
- package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
- package/dist/features/admin/constants/filter-tabs.js +1 -0
- package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
- package/dist/features/admin/schemas/homepage-section-form.js +68 -0
- package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
- package/dist/features/admin/schemas/nav-item-form.js +72 -0
- package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
- package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
- package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
- package/dist/features/auctions/components/bid-detail-fields.js +103 -0
- package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
- package/dist/features/audit-log/schemas/firestore.js +7 -0
- package/dist/features/blog/schemas/blog-form.d.ts +80 -0
- package/dist/features/blog/schemas/blog-form.js +100 -0
- package/dist/features/events/components/AdminEventEditorView.js +32 -54
- package/dist/features/events/components/AdminEventEntriesView.js +22 -3
- package/dist/features/events/schemas/event-form.d.ts +113 -0
- package/dist/features/events/schemas/event-form.js +82 -0
- package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
- package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
- package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
- package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
- package/dist/features/homepage/schemas/index.d.ts +31 -0
- package/dist/features/homepage/schemas/index.js +24 -0
- package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
- package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
- package/dist/features/orders/repository/orders.repository.js +5 -0
- package/dist/features/orders/schemas/firestore.d.ts +36 -1
- package/dist/features/orders/types/index.d.ts +35 -7
- package/dist/features/products/components/GroupSettingsPanel.js +5 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/products/schemas/product-features.validators.d.ts +73 -0
- package/dist/features/products/schemas/product-features.validators.js +63 -0
- package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
- package/dist/features/scams/constants/scammer-status-display.js +79 -0
- package/dist/features/seller/actions/offer-actions.js +32 -2
- package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
- package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
- package/dist/features/seller/components/SellerBidsView.js +11 -4
- package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
- package/dist/features/seller/components/SellerOffersView.js +2 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
- package/dist/features/seller/repository/offer.repository.d.ts +69 -8
- package/dist/features/seller/repository/offer.repository.js +127 -23
- package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
- package/dist/features/seller/schemas/coupon-form.js +116 -0
- package/dist/features/seller/schemas/firestore.d.ts +56 -2
- package/dist/features/seller/schemas/firestore.js +27 -0
- package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
- package/dist/features/seller/schemas/offer-forms.js +21 -0
- package/dist/features/shell/build-sections.d.ts +63 -0
- package/dist/features/shell/build-sections.js +207 -0
- package/dist/features/shell/field-ui-meta.d.ts +115 -0
- package/dist/features/shell/field-ui-meta.js +324 -0
- package/dist/features/shell/index.d.ts +4 -0
- package/dist/features/shell/index.js +2 -0
- package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
- package/dist/features/status-history/components/StatusTimeline.js +46 -0
- package/dist/features/status-history/index.d.ts +11 -0
- package/dist/features/status-history/index.js +10 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
- package/dist/features/store-extensions/index.d.ts +12 -0
- package/dist/features/store-extensions/index.js +12 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
- package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
- package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
- package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
- package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
- package/dist/features/store-extensions/schemas/firestore.js +60 -0
- package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
- package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
- package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
- package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
- package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
- package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
- package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
- package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
- package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
- package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
- package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
- package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +326 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +12 -2
- package/dist/next/routing/route-map.d.ts +16 -2
- package/dist/next/routing/route-map.js +9 -2
- package/dist/schemas/firestore-value.d.ts +8 -0
- package/dist/schemas/firestore-value.js +44 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/registry.d.ts +634 -1
- package/dist/schemas/registry.js +64 -1
- package/dist/seed/bids-seed-data.js +30 -0
- package/dist/seed/offers-seed-data.js +212 -7
- package/dist/seed/orders-seed-data.js +336 -1
- package/dist/seed/products-auctions-seed-data.js +41 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/RecordDetailModal.d.ts +10 -1
- package/dist/ui/components/RecordDetailModal.js +2 -2
- package/package.json +2 -1
- package/scripts/node-esm-loader.mjs +65 -0
- package/scripts/seed-cli-loader.mjs +6 -49
- package/scripts/seed-cli.mjs +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BidDocument } from "../schemas/firestore";
|
|
2
|
+
/** Which portal is looking. Decides whether bidder identity is shown. */
|
|
3
|
+
export type BidDetailViewer = "buyer" | "seller" | "admin";
|
|
4
|
+
type FieldRow = {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A bid's status as a badge.
|
|
10
|
+
*
|
|
11
|
+
* `forfeited` is deliberately distinguished from `cancelled`: a forfeited bid
|
|
12
|
+
* WON and then let the payment window lapse, which is the seller's problem and
|
|
13
|
+
* the buyer's fault; a cancelled one never won anything. Collapsing them would
|
|
14
|
+
* hide exactly the distinction a support query is about.
|
|
15
|
+
*/
|
|
16
|
+
export declare function bidStatusBadge(status: string | undefined): {
|
|
17
|
+
label: string;
|
|
18
|
+
tone: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The scalar rows for a bid's detail modal.
|
|
22
|
+
*
|
|
23
|
+
* `bidderName` appears only for seller and admin. A buyer opening their OWN
|
|
24
|
+
* bid does not need to be told their own name, and the same component is used
|
|
25
|
+
* by all three, so gating here rather than at each call site is what stops a
|
|
26
|
+
* future copy-paste showing one buyer another's identity.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildBidDetailFields(bid: BidDocument, viewer: BidDetailViewer): FieldRow[];
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: All three bid listings — buyer, seller and admin — were dead ends. A
|
|
3
|
+
* bid could be SEEN in a list and never opened, so the amount, the
|
|
4
|
+
* auction it belongs to, when it was placed and whether it was outbid,
|
|
5
|
+
* won or forfeited were only ever visible as a table row. `bid` is also
|
|
6
|
+
* one of the three `relatedType` values with no per-record page in ANY
|
|
7
|
+
* role, so a bid notification had nowhere to link.
|
|
8
|
+
* WHAT: The `<RecordDetailModal>` field list for a bid, built once.
|
|
9
|
+
*
|
|
10
|
+
* ## One builder, three portals
|
|
11
|
+
*
|
|
12
|
+
* The three views differ only in whether they may show the bidder's identity:
|
|
13
|
+
* a buyer sees their own bid, a seller sees who bid on their auction, and an
|
|
14
|
+
* admin sees everything. Writing the field list three times would be three
|
|
15
|
+
* chances to leak a bidder's name into the buyer view or to forget a field in
|
|
16
|
+
* one portal — the shape of Root Cause #38, one layer up.
|
|
17
|
+
*
|
|
18
|
+
* `maskPublicBid` already exists for the PUBLIC bid history and is unchanged;
|
|
19
|
+
* this is the dashboard equivalent, where the viewer is authenticated and the
|
|
20
|
+
* question is which role they hold.
|
|
21
|
+
*
|
|
22
|
+
* EXPORTS:
|
|
23
|
+
* buildBidDetailFields, bidStatusBadge, type BidDetailViewer
|
|
24
|
+
*
|
|
25
|
+
* @tag domain:auctions
|
|
26
|
+
* @tag layer:feature-util
|
|
27
|
+
* @tag pattern:none
|
|
28
|
+
* @tag access:isomorphic
|
|
29
|
+
* @tag consumers:UserBidsView,SellerBidsView,AdminBidsView
|
|
30
|
+
* @tag sideEffects:none
|
|
31
|
+
*/
|
|
32
|
+
const money = (amount, currency = "INR") => amount == null
|
|
33
|
+
? "—"
|
|
34
|
+
: new Intl.NumberFormat("en-IN", { style: "currency", currency, maximumFractionDigits: 2 }).format(amount);
|
|
35
|
+
const when = (value) => {
|
|
36
|
+
if (!value)
|
|
37
|
+
return "—";
|
|
38
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
39
|
+
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A bid's status as a badge.
|
|
43
|
+
*
|
|
44
|
+
* `forfeited` is deliberately distinguished from `cancelled`: a forfeited bid
|
|
45
|
+
* WON and then let the payment window lapse, which is the seller's problem and
|
|
46
|
+
* the buyer's fault; a cancelled one never won anything. Collapsing them would
|
|
47
|
+
* hide exactly the distinction a support query is about.
|
|
48
|
+
*/
|
|
49
|
+
export function bidStatusBadge(status) {
|
|
50
|
+
switch (status) {
|
|
51
|
+
case "won":
|
|
52
|
+
return { label: "Won", tone: "success" };
|
|
53
|
+
case "active":
|
|
54
|
+
return { label: "Leading", tone: "info" };
|
|
55
|
+
case "outbid":
|
|
56
|
+
return { label: "Outbid", tone: "warning" };
|
|
57
|
+
case "forfeited":
|
|
58
|
+
return { label: "Forfeited — payment window lapsed", tone: "danger" };
|
|
59
|
+
case "cancelled":
|
|
60
|
+
return { label: "Cancelled", tone: "default" };
|
|
61
|
+
case "lost":
|
|
62
|
+
return { label: "Lost", tone: "default" };
|
|
63
|
+
default:
|
|
64
|
+
return { label: status ?? "Unknown", tone: "default" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The scalar rows for a bid's detail modal.
|
|
69
|
+
*
|
|
70
|
+
* `bidderName` appears only for seller and admin. A buyer opening their OWN
|
|
71
|
+
* bid does not need to be told their own name, and the same component is used
|
|
72
|
+
* by all three, so gating here rather than at each call site is what stops a
|
|
73
|
+
* future copy-paste showing one buyer another's identity.
|
|
74
|
+
*/
|
|
75
|
+
export function buildBidDetailFields(bid, viewer) {
|
|
76
|
+
const rows = [
|
|
77
|
+
{ label: "Auction", value: bid.productTitle || bid.productId },
|
|
78
|
+
{ label: "Bid amount", value: money(bid.bidAmount, bid.currency) },
|
|
79
|
+
];
|
|
80
|
+
// Only meaningful when the bid raised a previous one.
|
|
81
|
+
if (bid.previousBidAmount != null) {
|
|
82
|
+
rows.push({ label: "Previous high bid", value: money(bid.previousBidAmount, bid.currency) });
|
|
83
|
+
}
|
|
84
|
+
if (bid.autoMaxBid != null) {
|
|
85
|
+
rows.push({ label: "Auto-bid limit", value: money(bid.autoMaxBid, bid.currency) });
|
|
86
|
+
}
|
|
87
|
+
rows.push({ label: "Placed", value: when(bid.bidDate ?? bid.createdAt) });
|
|
88
|
+
if (viewer !== "buyer") {
|
|
89
|
+
rows.push({ label: "Bidder", value: bid.userName || bid.userId });
|
|
90
|
+
}
|
|
91
|
+
// A buyout is a real bid, not a bid-free purchase — surfacing it here is
|
|
92
|
+
// what makes "why is there no ladder on this auction" answerable.
|
|
93
|
+
if (bid.isBuyout) {
|
|
94
|
+
rows.push({ label: "Placed via", value: "Buy Now" });
|
|
95
|
+
}
|
|
96
|
+
// The order this bid became. Present only once the win was actually paid,
|
|
97
|
+
// which is exactly the question "did they ever pay?" asks.
|
|
98
|
+
if (bid.orderId) {
|
|
99
|
+
rows.push({ label: "Order", value: bid.orderId });
|
|
100
|
+
}
|
|
101
|
+
rows.push({ label: "Bid ID", value: bid.id });
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
@@ -23,6 +23,13 @@ export declare const AdminAuditActionValues: {
|
|
|
23
23
|
readonly PAYOUT_MARK_PAID: "payout_mark_paid";
|
|
24
24
|
readonly STORE_STATUS_CHANGE: "store_status_change";
|
|
25
25
|
readonly USER_ROLE_CHANGE: "user_role_change";
|
|
26
|
+
/**
|
|
27
|
+
* Admin cancelled an offer. The store keeps sole authority to accept,
|
|
28
|
+
* counter and decline; cancelling is the ONE write an admin may make, and
|
|
29
|
+
* it is an escalation — reason required, and recorded both here and in the
|
|
30
|
+
* offer's own `statusHistory`.
|
|
31
|
+
*/
|
|
32
|
+
readonly OFFER_CANCEL: "offer_cancel";
|
|
26
33
|
};
|
|
27
34
|
export type AdminAuditAction = (typeof AdminAuditActionValues)[keyof typeof AdminAuditActionValues];
|
|
28
35
|
export interface AdminAuditLogDocument extends BaseDocument {
|
|
@@ -55,5 +62,12 @@ export declare const ADMIN_AUDIT_LOG_FIELDS: {
|
|
|
55
62
|
readonly PAYOUT_MARK_PAID: "payout_mark_paid";
|
|
56
63
|
readonly STORE_STATUS_CHANGE: "store_status_change";
|
|
57
64
|
readonly USER_ROLE_CHANGE: "user_role_change";
|
|
65
|
+
/**
|
|
66
|
+
* Admin cancelled an offer. The store keeps sole authority to accept,
|
|
67
|
+
* counter and decline; cancelling is the ONE write an admin may make, and
|
|
68
|
+
* it is an escalation — reason required, and recorded both here and in the
|
|
69
|
+
* offer's own `statusHistory`.
|
|
70
|
+
*/
|
|
71
|
+
readonly OFFER_CANCEL: "offer_cancel";
|
|
58
72
|
};
|
|
59
73
|
};
|
|
@@ -21,6 +21,13 @@ export const AdminAuditActionValues = {
|
|
|
21
21
|
PAYOUT_MARK_PAID: "payout_mark_paid",
|
|
22
22
|
STORE_STATUS_CHANGE: "store_status_change",
|
|
23
23
|
USER_ROLE_CHANGE: "user_role_change",
|
|
24
|
+
/**
|
|
25
|
+
* Admin cancelled an offer. The store keeps sole authority to accept,
|
|
26
|
+
* counter and decline; cancelling is the ONE write an admin may make, and
|
|
27
|
+
* it is an escalation — reason required, and recorded both here and in the
|
|
28
|
+
* offer's own `statusHistory`.
|
|
29
|
+
*/
|
|
30
|
+
OFFER_CANCEL: "offer_cancel",
|
|
24
31
|
};
|
|
25
32
|
export const ADMIN_AUDIT_LOG_FIELDS = {
|
|
26
33
|
ACTOR_UID: "actorUid",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The blog form's own schema. Distinct from `blogPostSchema` (blog/schemas),
|
|
4
|
+
* which requires an `id` this draft cannot have before its first save and
|
|
5
|
+
* models `coverImage` as a `MediaField` where the form holds a plain URL.
|
|
6
|
+
*
|
|
7
|
+
* ## Why `.passthrough()` is gone
|
|
8
|
+
*
|
|
9
|
+
* It used to name FOUR of the draft's fourteen fields and rely on passthrough
|
|
10
|
+
* to keep the other ten alive. That is the single most dangerous shape in this
|
|
11
|
+
* codebase — `z.object()` strips unknown keys, so the moment anyone tightened
|
|
12
|
+
* it, ten fields would vanish on the next save with no error. Verified safe to
|
|
13
|
+
* remove here: every one of the fourteen is now named, and the save payload
|
|
14
|
+
* (below) maps each explicitly rather than spreading the draft, so there is no
|
|
15
|
+
* extra key for passthrough to have been protecting.
|
|
16
|
+
*
|
|
17
|
+
* ## Section, row and control come from the field itself
|
|
18
|
+
*
|
|
19
|
+
* 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
|
|
20
|
+
* instance and every zod wrapper returns a new one, so
|
|
21
|
+
* `annotate(z.string(), …).optional()` silently loses the metadata and drops
|
|
22
|
+
* the field into the "advanced" fallback. `audit-field-ui-meta` blocks it.
|
|
23
|
+
*/
|
|
24
|
+
export declare const blogDraftSchema: z.ZodObject<{
|
|
25
|
+
title: z.ZodString;
|
|
26
|
+
/**
|
|
27
|
+
* NO `blog-` prefix. Surfaced by `roundtrip-diff` in W4: the schema demanded
|
|
28
|
+
* `/^blog-/`, and **all 20 stored posts fail it** — every seeded post holds
|
|
29
|
+
* the bare slug (`spot-genuine-takara-tomy-beyblade`) while only the
|
|
30
|
+
* DOCUMENT ID carries the prefix (`generateBlogPostId` adds it).
|
|
31
|
+
*
|
|
32
|
+
* `BlogRepository.findBySlug` queries this field directly, so the public URL
|
|
33
|
+
* is the bare slug. The regex therefore broke the editor from both ends:
|
|
34
|
+
* opening any existing post flagged its slug invalid, and "fixing" it to
|
|
35
|
+
* satisfy the rule would rewrite the stored slug and 404 the live URL.
|
|
36
|
+
*/
|
|
37
|
+
slug: z.ZodString;
|
|
38
|
+
excerpt: z.ZodString;
|
|
39
|
+
content: z.ZodString;
|
|
40
|
+
coverImage: z.ZodString;
|
|
41
|
+
youtubeId: z.ZodString;
|
|
42
|
+
category: z.ZodEnum<["news", "tips", "guides", "updates", "community"]>;
|
|
43
|
+
tags: z.ZodArray<z.ZodString, "many">;
|
|
44
|
+
metaTitle: z.ZodString;
|
|
45
|
+
metaDescription: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
|
|
46
|
+
status: z.ZodEnum<["draft", "published", "archived"]>;
|
|
47
|
+
publishedAt: z.ZodString;
|
|
48
|
+
authorName: z.ZodString;
|
|
49
|
+
isFeatured: z.ZodBoolean;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
status: "draft" | "published" | "archived";
|
|
52
|
+
title: string;
|
|
53
|
+
content: string;
|
|
54
|
+
youtubeId: string;
|
|
55
|
+
slug: string;
|
|
56
|
+
category: "news" | "tips" | "guides" | "updates" | "community";
|
|
57
|
+
tags: string[];
|
|
58
|
+
publishedAt: string;
|
|
59
|
+
isFeatured: boolean;
|
|
60
|
+
excerpt: string;
|
|
61
|
+
coverImage: string;
|
|
62
|
+
authorName: string;
|
|
63
|
+
metaTitle: string;
|
|
64
|
+
metaDescription: string;
|
|
65
|
+
}, {
|
|
66
|
+
status: "draft" | "published" | "archived";
|
|
67
|
+
title: string;
|
|
68
|
+
content: string;
|
|
69
|
+
youtubeId: string;
|
|
70
|
+
slug: string;
|
|
71
|
+
category: "news" | "tips" | "guides" | "updates" | "community";
|
|
72
|
+
tags: string[];
|
|
73
|
+
publishedAt: string;
|
|
74
|
+
isFeatured: boolean;
|
|
75
|
+
excerpt: string;
|
|
76
|
+
coverImage: string;
|
|
77
|
+
authorName: string;
|
|
78
|
+
metaTitle: string;
|
|
79
|
+
metaDescription: string;
|
|
80
|
+
}>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: The blog form's schema used to live inside `AdminBlogEditorView.tsx`.
|
|
3
|
+
* Registering it in `SCHEMAS.forms` from there would have made
|
|
4
|
+
* `schemas/registry.ts` import a `.tsx` component — and `routeHandler.ts`
|
|
5
|
+
* imports SCHEMAS, so every API route would have pulled the admin blog
|
|
6
|
+
* editor's whole React tree into the server bundle. Schemas belong in
|
|
7
|
+
* `schemas/`; that is the layering this restores.
|
|
8
|
+
* WHAT: The blog editor's form schema, with its UI metadata.
|
|
9
|
+
*
|
|
10
|
+
* EXPORTS:
|
|
11
|
+
* blogDraftSchema
|
|
12
|
+
*
|
|
13
|
+
* @tag domain:blog
|
|
14
|
+
* @tag layer:schema
|
|
15
|
+
* @tag pattern:none
|
|
16
|
+
* @tag access:isomorphic
|
|
17
|
+
* @tag consumers:AdminBlogEditorView,SCHEMAS.forms
|
|
18
|
+
* @tag sideEffects:none
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { annotate } from "../../shell/field-ui-meta";
|
|
22
|
+
import { blogPostCategorySchema } from "./index";
|
|
23
|
+
/**
|
|
24
|
+
* The blog form's own schema. Distinct from `blogPostSchema` (blog/schemas),
|
|
25
|
+
* which requires an `id` this draft cannot have before its first save and
|
|
26
|
+
* models `coverImage` as a `MediaField` where the form holds a plain URL.
|
|
27
|
+
*
|
|
28
|
+
* ## Why `.passthrough()` is gone
|
|
29
|
+
*
|
|
30
|
+
* It used to name FOUR of the draft's fourteen fields and rely on passthrough
|
|
31
|
+
* to keep the other ten alive. That is the single most dangerous shape in this
|
|
32
|
+
* codebase — `z.object()` strips unknown keys, so the moment anyone tightened
|
|
33
|
+
* it, ten fields would vanish on the next save with no error. Verified safe to
|
|
34
|
+
* remove here: every one of the fourteen is now named, and the save payload
|
|
35
|
+
* (below) maps each explicitly rather than spreading the draft, so there is no
|
|
36
|
+
* extra key for passthrough to have been protecting.
|
|
37
|
+
*
|
|
38
|
+
* ## Section, row and control come from the field itself
|
|
39
|
+
*
|
|
40
|
+
* 🛑 `annotate()` must be the OUTERMOST call — it keys a WeakMap by schema
|
|
41
|
+
* instance and every zod wrapper returns a new one, so
|
|
42
|
+
* `annotate(z.string(), …).optional()` silently loses the metadata and drops
|
|
43
|
+
* the field into the "advanced" fallback. `audit-field-ui-meta` blocks it.
|
|
44
|
+
*/
|
|
45
|
+
export const blogDraftSchema = z.object({
|
|
46
|
+
title: annotate(z.string().min(1, "Title is required"), {
|
|
47
|
+
section: "content", sectionLabel: "Content", sectionRequired: true,
|
|
48
|
+
quick: true, order: 1, row: "full",
|
|
49
|
+
}),
|
|
50
|
+
/**
|
|
51
|
+
* NO `blog-` prefix. Surfaced by `roundtrip-diff` in W4: the schema demanded
|
|
52
|
+
* `/^blog-/`, and **all 20 stored posts fail it** — every seeded post holds
|
|
53
|
+
* the bare slug (`spot-genuine-takara-tomy-beyblade`) while only the
|
|
54
|
+
* DOCUMENT ID carries the prefix (`generateBlogPostId` adds it).
|
|
55
|
+
*
|
|
56
|
+
* `BlogRepository.findBySlug` queries this field directly, so the public URL
|
|
57
|
+
* is the bare slug. The regex therefore broke the editor from both ends:
|
|
58
|
+
* opening any existing post flagged its slug invalid, and "fixing" it to
|
|
59
|
+
* satisfy the rule would rewrite the stored slug and 404 the live URL.
|
|
60
|
+
*/
|
|
61
|
+
slug: annotate(z.string().min(1, "Slug is required"), {
|
|
62
|
+
section: "content", quick: true, order: 2, row: "full",
|
|
63
|
+
// T1 calculated: derived from the title on CREATE only, then frozen.
|
|
64
|
+
// Recomputing on rename would break every inbound link and bookmark —
|
|
65
|
+
// categories, brands and bundles all establish that convention.
|
|
66
|
+
tier: "t1-derive",
|
|
67
|
+
help: "Auto-filled from the title. Changing it after publishing breaks existing links.",
|
|
68
|
+
}),
|
|
69
|
+
excerpt: annotate(z.string().max(300, "Keep the excerpt under 300 characters."), {
|
|
70
|
+
section: "content", order: 3, row: "full",
|
|
71
|
+
}),
|
|
72
|
+
content: annotate(z.string().min(1, "Content is required"), {
|
|
73
|
+
section: "content", quick: true, order: 4, row: "full",
|
|
74
|
+
}),
|
|
75
|
+
coverImage: annotate(z.string(), {
|
|
76
|
+
section: "media", sectionLabel: "Media", order: 1, row: "full", kind: "media",
|
|
77
|
+
}),
|
|
78
|
+
youtubeId: annotate(z.string(), {
|
|
79
|
+
section: "media", order: 2, row: "full",
|
|
80
|
+
help: "A YouTube video id or watch URL — rendered as a privacy-mode embed.",
|
|
81
|
+
}),
|
|
82
|
+
category: annotate(blogPostCategorySchema, {
|
|
83
|
+
section: "seo", sectionLabel: "SEO & Tags", quick: true, order: 1, row: "pair",
|
|
84
|
+
}),
|
|
85
|
+
tags: annotate(z.array(z.string()), {
|
|
86
|
+
section: "seo", order: 2, row: "full", kind: "list",
|
|
87
|
+
}),
|
|
88
|
+
metaTitle: annotate(z.string().max(60, "Keep the meta title under 60 characters."), {
|
|
89
|
+
section: "seo", order: 3, row: "full", tier: "t1-derive",
|
|
90
|
+
}),
|
|
91
|
+
metaDescription: annotate(z.string().max(160, "Keep the meta description under 160 characters.").or(z.literal("")), { section: "seo", order: 4, row: "full", tier: "t1-derive" }),
|
|
92
|
+
status: annotate(z.enum(["draft", "published", "archived"]), {
|
|
93
|
+
section: "publish", sectionLabel: "Publish", quick: true, order: 1, row: "pair",
|
|
94
|
+
}),
|
|
95
|
+
publishedAt: annotate(z.string(), {
|
|
96
|
+
section: "publish", order: 2, row: "pair", kind: "date",
|
|
97
|
+
}),
|
|
98
|
+
authorName: annotate(z.string(), { section: "publish", order: 3, row: "pair" }),
|
|
99
|
+
isFeatured: annotate(z.boolean(), { section: "publish", order: 4, row: "quarter" }),
|
|
100
|
+
});
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
5
|
-
import React, { useEffect
|
|
6
|
-
import { z } from "zod";
|
|
5
|
+
import React, { useEffect } from "react";
|
|
7
6
|
import { useQuery } from "@tanstack/react-query";
|
|
8
7
|
import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui";
|
|
9
8
|
import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
|
|
10
9
|
import { apiClient } from "../../../http";
|
|
11
10
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
12
|
-
import {
|
|
11
|
+
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
12
|
+
import { eventDraftSchema } from "../schemas/event-form";
|
|
13
13
|
import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
|
|
14
14
|
import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect";
|
|
15
15
|
// Covers the cross-type required fields plus the two conditional rules the
|
|
@@ -19,27 +19,6 @@ import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect";
|
|
|
19
19
|
// form-builder arrays, raffle/spin config) are deliberately left unvalidated
|
|
20
20
|
// — modeling their full conditional shape correctly would need much deeper
|
|
21
21
|
// per-type domain rules than this pass is scoped for.
|
|
22
|
-
const eventDraftSchema = z.object({
|
|
23
|
-
type: z.string(),
|
|
24
|
-
title: z.string().min(1, "Title is required"),
|
|
25
|
-
startsAt: z.string().min(1, "Start date is required"),
|
|
26
|
-
endsAt: z.string().min(1, "End date is required"),
|
|
27
|
-
couponId: z.string(),
|
|
28
|
-
displayCode: z.string(),
|
|
29
|
-
pollOptions: z.array(z.object({ id: z.string(), label: z.string() })),
|
|
30
|
-
}).passthrough().superRefine((v, ctx) => {
|
|
31
|
-
if (v.type === "offer") {
|
|
32
|
-
if (!v.couponId.trim()) {
|
|
33
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["couponId"], message: "Coupon is required for offer events" });
|
|
34
|
-
}
|
|
35
|
-
if (!v.displayCode.trim()) {
|
|
36
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["displayCode"], message: "Display code is required for offer events" });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (v.type === "poll" && v.pollOptions.filter((o) => o.label.trim()).length < 2) {
|
|
40
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["pollOptions"], message: "Poll events require at least 2 options" });
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
22
|
const __P = {
|
|
44
23
|
p4: "p-[var(--appkit-space-4)]",
|
|
45
24
|
};
|
|
@@ -226,7 +205,6 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
226
205
|
const isEdit = Boolean(eventId);
|
|
227
206
|
const [draft, setDraft] = React.useState(DEFAULT_DRAFT);
|
|
228
207
|
const [slugManual, setSlugManual] = React.useState(false);
|
|
229
|
-
const [currentStep, setCurrentStep] = React.useState(0);
|
|
230
208
|
const [saveMessage, setSaveMessage] = React.useState(null);
|
|
231
209
|
const [triggerMessage, setTriggerMessage] = React.useState(null);
|
|
232
210
|
const [raffleWinnerName, setRaffleWinnerName] = React.useState("");
|
|
@@ -358,44 +336,49 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
358
336
|
});
|
|
359
337
|
const isLoading = saveMutation.isPending || eventQuery.isLoading;
|
|
360
338
|
// --- steps ---
|
|
361
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Sections, not steps — every field reachable at once, one submit at the
|
|
341
|
+
* bottom. The old wizard gated Settings behind Details validating, so a
|
|
342
|
+
* missing end date made an event's poll options unreachable.
|
|
343
|
+
*
|
|
344
|
+
* Ids match the schema's `section` annotations exactly, which is what lets
|
|
345
|
+
* `<FormErrorSummary>` jump to the right panel.
|
|
346
|
+
*/
|
|
347
|
+
const sections = [
|
|
362
348
|
{
|
|
349
|
+
id: "details",
|
|
350
|
+
required: true,
|
|
351
|
+
quick: true,
|
|
363
352
|
label: "Details",
|
|
364
353
|
fields: ["type", "title", "slug", "description", "startsAt", "endsAt", "tags"],
|
|
365
|
-
validate:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!values.startsAt)
|
|
369
|
-
return "Start date is required";
|
|
370
|
-
if (!values.endsAt)
|
|
371
|
-
return "End date is required";
|
|
372
|
-
return null;
|
|
373
|
-
},
|
|
354
|
+
// The per-step `validate` is gone: every rule it stated is already in
|
|
355
|
+
// eventDraftSchema (title/startsAt/endsAt are .min(1)), and it only ever
|
|
356
|
+
// acted as a gate to the next step.
|
|
374
357
|
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Details" }), !isEdit && (_jsx(Select, { label: "Event type", value: values.type, options: allowedTypes ? EVENT_TYPE_OPTIONS.filter((o) => allowedTypes.includes(o.value)) : EVENT_TYPE_OPTIONS, onChange: (e) => onChange({ type: e.target.value }) })), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
|
|
375
358
|
const v = e.target.value;
|
|
376
359
|
onChange({ title: v, ...(!slugManual && { slug: toEventSlug(v) }) });
|
|
377
360
|
}, placeholder: "Charizard Flash Sale 2026", required: true }), _jsx(Input, { label: "Slug", value: values.slug, onChange: (e) => { setSlugManual(true); onChange({ slug: e.target.value }); }, placeholder: "event-charizard-flash-sale-2026", helperText: "Auto-generated from title. Must start with 'event-'." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { children: "Description" }), _jsx(RichTextEditor, { value: values.description, onChange: (v) => onChange({ description: v }), minHeightClassName: "min-h-[120px]", placeholder: "Describe this event\u2026" })] }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Starts at", type: "datetime-local", value: values.startsAt, onChange: (e) => onChange({ startsAt: e.target.value }), required: true }), _jsx(Input, { label: "Ends at", type: "datetime-local", value: values.endsAt, onChange: (e) => onChange({ endsAt: e.target.value }), required: true })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, sale, 2026" })] })),
|
|
378
361
|
},
|
|
379
362
|
{
|
|
363
|
+
id: "media",
|
|
380
364
|
label: "Media",
|
|
365
|
+
// <Collapse> unmounts its children — without this, collapsing the
|
|
366
|
+
// section mid-upload discards the transfer.
|
|
367
|
+
keepMounted: true,
|
|
381
368
|
fields: ["coverImageUrl"],
|
|
382
369
|
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(Input, { label: "Cover Image URL (optional)", value: values.coverImageUrl, onChange: (e) => onChange({ coverImageUrl: e.target.value }), placeholder: "https://\u2026", helperText: "Direct link to the event cover image." })] })),
|
|
383
370
|
},
|
|
384
371
|
{
|
|
372
|
+
id: "settings",
|
|
385
373
|
label: "Settings",
|
|
386
374
|
fields: ["status", "allowGuestParticipation", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (values.type === "offer" && !values.displayCode.trim())
|
|
391
|
-
return "Display code is required for offer events";
|
|
392
|
-
if (values.type === "poll" && values.pollOptions.filter((o) => o.label.trim()).length < 2)
|
|
393
|
-
return "Poll events require at least 2 options";
|
|
394
|
-
return null;
|
|
395
|
-
},
|
|
375
|
+
// Same: the offer-coupon, offer-display-code and poll-minimum-options
|
|
376
|
+
// rules are all stated in eventDraftSchema's superRefine, which now
|
|
377
|
+
// surfaces them inline on the field instead of as a step-blocking banner.
|
|
396
378
|
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label, onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
|
|
397
379
|
},
|
|
398
380
|
{
|
|
381
|
+
id: "raffle",
|
|
399
382
|
label: "Raffle / Spin",
|
|
400
383
|
fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponId", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
|
|
401
384
|
render: ({ values, onChange }) => {
|
|
@@ -407,16 +390,11 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
407
390
|
},
|
|
408
391
|
];
|
|
409
392
|
const alertSection = eventQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load event", children: eventQuery.error instanceof Error ? eventQuery.error.message : "Unknown error" })) : null;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
return map;
|
|
416
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
417
|
-
}, [steps.length]);
|
|
418
|
-
const wizardShellCtx = useMemo(() => ({ ...shellCtx, fieldToStepIndex, goToStep: (n) => setCurrentStep(n) }), [shellCtx, fieldToStepIndex, setCurrentStep]);
|
|
419
|
-
const formContent = (_jsxs(Stack, { gap: "sm", children: [alertSection, _jsxs(FormShellContext.Provider, { value: wizardShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(StepForm, { steps: steps, values: draft, onChange: update, onComplete: () => { saveMutation.mutate(); }, formId: "admin-event", currentStep: currentStep, onStepChange: setCurrentStep, completeLabel: isEdit ? "Save Changes" : "Create Event", isLoading: isLoading })] }), saveMessage && (_jsx(Alert, { variant: saveMessage.toLowerCase().includes("fail") || saveMessage.toLowerCase().includes("error") ? "error" : "success", title: "Save status", children: saveMessage }))] }));
|
|
393
|
+
// Replaces the hand-rolled fieldToStepIndex + goToStep pair. The hook owns
|
|
394
|
+
// the expand-then-scroll ordering a raw setState cannot: a collapsed panel
|
|
395
|
+
// must MOUNT before scrollIntoView and focus can reach the offending field.
|
|
396
|
+
const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
|
|
397
|
+
const formContent = (_jsxs(Stack, { gap: "sm", children: [alertSection, _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: () => { saveMutation.mutate(); }, schema: eventDraftSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save Changes" : "Create Event", isLoading: isLoading })] }), saveMessage && (_jsx(Alert, { variant: saveMessage.toLowerCase().includes("fail") || saveMessage.toLowerCase().includes("error") ? "error" : "success", title: "Save status", children: saveMessage }))] }));
|
|
420
398
|
if (embedded) {
|
|
421
399
|
return _jsx(Div, { className: `${__O.yAuto} ${__P.p4}`, children: formContent });
|
|
422
400
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useApiMutation, sieveFilter, SIEVE_OP, sortBy } from "@mohasinac/appkit/client";
|
|
4
|
-
import React from "react";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { Alert, Button, Div, Input, Row, Select, Stack, Text, useToast } from "../../../ui";
|
|
6
|
+
import { Alert, Button, Div, Input, Row, Select, Stack, Text, useToast, RecordDetailModal, } from "../../../ui";
|
|
7
7
|
import { apiClient } from "../../../http";
|
|
8
8
|
import { normalizeError } from "../../../errors/normalize";
|
|
9
9
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
@@ -128,6 +128,10 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
|
|
|
128
128
|
},
|
|
129
129
|
];
|
|
130
130
|
const eventTitle = statsQuery.data?.event?.title;
|
|
131
|
+
// Approve / Waitlist / Cancel were offered on an entry the admin could never
|
|
132
|
+
// read — the poll vote, the comment and the survey answers were all invisible
|
|
133
|
+
// (Root Cause #56, "acting blind"). The row already carries the whole entry.
|
|
134
|
+
const [detail, setDetail] = useState(null);
|
|
131
135
|
const config = {
|
|
132
136
|
portal: "admin",
|
|
133
137
|
title: eventTitle ? `${eventTitle} Entries` : "Event Entries",
|
|
@@ -139,6 +143,7 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
|
|
|
139
143
|
endpoint: resolvedEntriesEndpoint,
|
|
140
144
|
sortOptions: [{ value: sortBy("submittedAt", "DESC"), label: "Most recent" }],
|
|
141
145
|
columns,
|
|
146
|
+
onRowClick: (row) => setDetail(row),
|
|
142
147
|
mapRows: (response) => response.items ?? [],
|
|
143
148
|
getTotal: (response) => response.total ?? (response.items ?? []).length,
|
|
144
149
|
buildFilters: (state) => state.reviewStatus ? sieveFilter("reviewStatus", SIEVE_OP.EQ, state.reviewStatus) : undefined,
|
|
@@ -146,5 +151,19 @@ export function AdminEventEntriesView({ eventId, entriesEndpoint, entryReviewEnd
|
|
|
146
151
|
toolbarExtra: eventId ? (_jsx(Button, { variant: "ghost", action: ACTIONS.ADMIN["export-event-entries"], onClick: handleExportReport })) : undefined,
|
|
147
152
|
renderAboveContent: () => (_jsxs(Stack, { gap: "3", className: "px-[var(--appkit-space-3)] pb-[var(--appkit-space-2)]", children: [!eventId ? (_jsx(Alert, { variant: "warning", title: "Missing event id", children: "Event id is required to load entries." })) : null, statsQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load event stats", children: statsQuery.error instanceof Error ? statsQuery.error.message : "Unknown error" })) : null, _jsxs(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-3", children: [_jsx(Alert, { variant: "info", title: "Total entries", children: String(statsQuery.data?.stats?.totalEntries ?? 0) }), _jsx(Alert, { variant: "success", title: "Approved", children: String(statsQuery.data?.stats?.approvedEntries ?? 0) }), _jsx(Alert, { variant: "warning", title: "Flagged", children: String(statsQuery.data?.stats?.flaggedEntries ?? 0) })] })] })),
|
|
148
153
|
};
|
|
149
|
-
return _jsx(DataListingView, { config: config })
|
|
154
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.userDisplayName || detail?.userEmail || "Entry", badges: detail ? [{ label: detail.reviewStatus }] : undefined, description: detail?.pollComment, fields: detail
|
|
155
|
+
? [
|
|
156
|
+
{ label: "Entrant", value: detail.userDisplayName ?? detail.userId ?? "—" },
|
|
157
|
+
{ label: "Email", value: detail.userEmail ?? "—" },
|
|
158
|
+
{ label: "Review status", value: detail.reviewStatus },
|
|
159
|
+
{ label: "Points", value: detail.points != null ? String(detail.points) : "—" },
|
|
160
|
+
{ label: "Raffle eligible", value: detail.raffleEligible ? "Yes" : "No" },
|
|
161
|
+
{ label: "Poll votes", value: detail.pollVotes?.join(", ") || "—" },
|
|
162
|
+
{ label: "Reviewed by", value: detail.reviewedBy ?? "—" },
|
|
163
|
+
{ label: "Review note", value: detail.reviewNote ?? "—" },
|
|
164
|
+
{ label: "Entry ID", value: detail.id },
|
|
165
|
+
]
|
|
166
|
+
: undefined,
|
|
167
|
+
// The survey/feedback answers — the actual submission being judged.
|
|
168
|
+
metadata: detail?.formResponses })] }));
|
|
150
169
|
}
|