@mohasinac/appkit 4.63.5 → 4.64.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/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +87 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/shared/actions/action-registry.js +61 -0
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/constants/api-endpoints.d.ts +24 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +17 -4
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/repository/store.repository.js +23 -0
- package/dist/features/stores/schemas/firestore.d.ts +60 -1
- package/dist/features/stores/schemas/firestore.js +18 -0
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/next/routing/route-map.d.ts +3 -1
- package/dist/next/routing/route-map.js +13 -0
- package/dist/schemas/registry.d.ts +10 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +10 -0
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart specs for event pages — pure builders, no React, no recharts.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any import that reaches the DOM so a Server Component can call
|
|
5
|
+
* these and hand the result to `<Chart>`, whose own `"use client"` boundary is
|
|
6
|
+
* internal (`chart-spec.ts` is deliberately the same shape of module).
|
|
7
|
+
*
|
|
8
|
+
* ## 🛑 EVERY NUMBER HERE IS MEASURED, NOT ILLUSTRATIVE
|
|
9
|
+
*
|
|
10
|
+
* A chart asserts "we measured this". So each builder returns `null` rather
|
|
11
|
+
* than a plausible-looking shape when it has nothing real to plot — an absent
|
|
12
|
+
* chart says "not enough data yet", a flat or invented one says "we measured,
|
|
13
|
+
* and this is the result", which is a different and false claim.
|
|
14
|
+
*
|
|
15
|
+
* @tag domain:events
|
|
16
|
+
* @tag layer:utils
|
|
17
|
+
*/
|
|
18
|
+
import { calculateDiscount } from "../../promotions/schemas/firestore.js";
|
|
19
|
+
/** A poll needs at least one real vote before a bar chart means anything. */
|
|
20
|
+
const MIN_VOTES = 1;
|
|
21
|
+
/**
|
|
22
|
+
* Poll results as bars.
|
|
23
|
+
*
|
|
24
|
+
* The data already existed and was rendered as the plain string
|
|
25
|
+
* "12 votes (34%)" — `getEventPollResults` computes both server-side and
|
|
26
|
+
* `EventLeaderboard` printed them in a flex row. This is the same numbers,
|
|
27
|
+
* shaped so the differences between options are visible at a glance.
|
|
28
|
+
*/
|
|
29
|
+
export function buildPollChart(
|
|
30
|
+
// Structural, not the full `PollConfig`: this reads `options` and nothing
|
|
31
|
+
// else, and the page carries a narrower local declaration of its own.
|
|
32
|
+
pollConfig, results) {
|
|
33
|
+
if (!pollConfig?.options?.length || !results?.length)
|
|
34
|
+
return null;
|
|
35
|
+
const byId = new Map(results.map((r) => [r.optionId, r.count]));
|
|
36
|
+
const total = results.reduce((sum, r) => sum + r.count, 0);
|
|
37
|
+
if (total < MIN_VOTES)
|
|
38
|
+
return null;
|
|
39
|
+
return {
|
|
40
|
+
kind: "bar",
|
|
41
|
+
title: "Votes so far",
|
|
42
|
+
categories: pollConfig.options.map((o) => o.label),
|
|
43
|
+
series: [{ label: "Votes", values: pollConfig.options.map((o) => byId.get(o.id) ?? 0) }],
|
|
44
|
+
unit: "votes",
|
|
45
|
+
caption: `${total} vote${total === 1 ? "" : "s"} counted.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The basket sizes the savings chart compares across.
|
|
50
|
+
*
|
|
51
|
+
* 🛑 THE RANGE IS CHOSEN TO SHOW THE CROSSOVER, which is the only thing this
|
|
52
|
+
* chart can usefully tell someone. At ₹2,000 the two 10% codes TIE at ₹200 and
|
|
53
|
+
* their caps are irrelevant; an uncapped 5% only overtakes a ₹500-capped 10%
|
|
54
|
+
* once the basket passes ₹10,000. A single-basket bar chart would show three
|
|
55
|
+
* bars, two of them identical, and teach nothing.
|
|
56
|
+
*/
|
|
57
|
+
const BASKET_SIZES = [1000, 2000, 5000, 12000];
|
|
58
|
+
const INR = new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0 });
|
|
59
|
+
/**
|
|
60
|
+
* What each cashback coupon actually saves, across basket sizes.
|
|
61
|
+
*
|
|
62
|
+
* 🛑 COMPUTED WITH THE REAL `calculateDiscount`, never a re-derived formula.
|
|
63
|
+
* That function is where `maxDiscount`, `minPurchase` and the tier table are
|
|
64
|
+
* honoured; computing the percentage inline here would silently ignore all three and
|
|
65
|
+
* overstate every capped code. Root Cause #75 is a money rule hand-copied into
|
|
66
|
+
* six places, where the copies never learned about a branch added later.
|
|
67
|
+
*
|
|
68
|
+
* 🛑 `free_shipping` AND `buy_x_get_y` ARE EXCLUDED, not plotted at zero.
|
|
69
|
+
* `calculateDiscount` returns 0 for free shipping BY DESIGN — the waiver is a
|
|
70
|
+
* shipping line, not a subtotal discount — and a BOGO's value depends on the
|
|
71
|
+
* basket's composition, not its total (₹500 on 4x₹500, ₹0 on 2x₹1,000). A bar
|
|
72
|
+
* at zero for either would read as "this code saves you nothing", which is a
|
|
73
|
+
* lie by omission. They belong in the caption, and that is where they go.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* 🛑 TAKES THE PUBLIC PROJECTION, NOT THE DOCUMENT — and still calls the
|
|
77
|
+
* real `calculateDiscount`.
|
|
78
|
+
*
|
|
79
|
+
* The event page only ever holds `AttachedCoupon`, the allow-list
|
|
80
|
+
* projection (Root Cause #70 — the full document carries fields no public
|
|
81
|
+
* surface should see). That projection carries exactly the four facts the
|
|
82
|
+
* calculator needs, under different names: `type`, `discountValue`,
|
|
83
|
+
* `maxDiscount`, `minPurchase`.
|
|
84
|
+
*
|
|
85
|
+
* So this MAPS NAMES and delegates. The tempting shortcut — computing
|
|
86
|
+
* the percentage inline right here, because the projection makes it
|
|
87
|
+
* easy — is precisely Root Cause #75: a money rule re-derived at a call
|
|
88
|
+
* site, which then never learns about the cap, the minimum or the tier
|
|
89
|
+
* table the real function honours.
|
|
90
|
+
*/
|
|
91
|
+
function toCalculable(c) {
|
|
92
|
+
return {
|
|
93
|
+
type: c.type,
|
|
94
|
+
discount: {
|
|
95
|
+
value: c.discountValue,
|
|
96
|
+
maxDiscount: c.maxDiscount,
|
|
97
|
+
minPurchase: c.minPurchase,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function buildCouponSavingsChart(coupons) {
|
|
102
|
+
const plottable = coupons.filter((c) => c.type === "percentage" || c.type === "fixed");
|
|
103
|
+
if (plottable.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const excluded = coupons
|
|
106
|
+
.filter((c) => !plottable.includes(c))
|
|
107
|
+
.map((c) => c.code);
|
|
108
|
+
const series = plottable.map((coupon) => ({
|
|
109
|
+
label: coupon.code,
|
|
110
|
+
values: BASKET_SIZES.map((basket) => Math.round(calculateDiscount(toCalculable(coupon), basket))),
|
|
111
|
+
}));
|
|
112
|
+
// Every code saving nothing at every size means the minimums are all above
|
|
113
|
+
// our largest basket — a chart of four empty groups.
|
|
114
|
+
if (series.every((s) => s.values.every((v) => v === 0)))
|
|
115
|
+
return null;
|
|
116
|
+
const captionParts = ["One code per order — this is a choose-one comparison."];
|
|
117
|
+
if (excluded.length) {
|
|
118
|
+
captionParts.push(`${excluded.join(" and ")} ${excluded.length === 1 ? "is" : "are"} not shown: ` +
|
|
119
|
+
`their value depends on delivery or on what is in the basket, not on the total.`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
kind: "groupedBar",
|
|
123
|
+
title: "What each code saves",
|
|
124
|
+
categories: BASKET_SIZES.map((b) => `₹${INR.format(b)}`),
|
|
125
|
+
series,
|
|
126
|
+
unit: "₹",
|
|
127
|
+
caption: captionParts.join(" "),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Likes over time. Data comes from `getLikeTrend`, which returns `null` below
|
|
132
|
+
* `LIKE_TREND_MIN_POINTS` so a two-like event plots nothing rather than a flat
|
|
133
|
+
* line with axes.
|
|
134
|
+
*/
|
|
135
|
+
export function buildLikeTrendChart(trend) {
|
|
136
|
+
if (!trend)
|
|
137
|
+
return null;
|
|
138
|
+
return {
|
|
139
|
+
kind: "area",
|
|
140
|
+
title: "Interest over the last fortnight",
|
|
141
|
+
categories: trend.categories,
|
|
142
|
+
series: [{ label: "Likes", values: trend.values }],
|
|
143
|
+
unit: "likes",
|
|
144
|
+
caption: "Likes received per day.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Time elapsed in the sale window, and redemptions against a real cap.
|
|
149
|
+
*
|
|
150
|
+
* 🛑 `claimedPercent` IS null UNLESS A REAL CAP EXISTS. `usage.totalLimit` is
|
|
151
|
+
* optional, and an uncapped coupon has no denominator — rendering a progress
|
|
152
|
+
* bar against an invented 100 would show a bar filling toward a limit nobody
|
|
153
|
+
* set. Absent is the honest state and the caller renders only the countdown.
|
|
154
|
+
*/
|
|
155
|
+
export function buildSaleProgress(startsAt, endsAt, coupon, now = new Date()) {
|
|
156
|
+
if (!startsAt || !endsAt)
|
|
157
|
+
return null;
|
|
158
|
+
const start = startsAt.getTime();
|
|
159
|
+
const end = endsAt.getTime();
|
|
160
|
+
if (!(end > start))
|
|
161
|
+
return null;
|
|
162
|
+
const elapsed = ((now.getTime() - start) / (end - start)) * 100;
|
|
163
|
+
const limit = coupon?.usage?.totalLimit;
|
|
164
|
+
const used = coupon?.usage?.currentUsage ?? 0;
|
|
165
|
+
const hasCap = typeof limit === "number" && limit > 0;
|
|
166
|
+
return {
|
|
167
|
+
elapsedPercent: Math.min(100, Math.max(0, Math.round(elapsed))),
|
|
168
|
+
endsAt: now.getTime() < end ? endsAt : null,
|
|
169
|
+
claimedPercent: hasCap ? Math.min(100, Math.round((used / limit) * 100)) : null,
|
|
170
|
+
claimedLabel: hasCap ? `${INR.format(used)} of ${INR.format(limit)} claimed` : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -52,6 +52,15 @@ export const FAQ_CATEGORY_HELP_GUIDE = {
|
|
|
52
52
|
// live.
|
|
53
53
|
scam_awareness: { path: "/help/account", label: "Account & Safety guide" },
|
|
54
54
|
fees_and_taxes: { path: "/help/shopping", label: "Shopping & Checkout guide" },
|
|
55
|
+
/*
|
|
56
|
+
* The BUYER-facing badge page, not `/seller-guide`.
|
|
57
|
+
*
|
|
58
|
+
* This map sits on the public FAQ page, so its audience is a visitor rather
|
|
59
|
+
* than a seller — and "what does Trusted Seller mean" is the question they
|
|
60
|
+
* are most likely to have arrived with. A seller reading these FAQs still
|
|
61
|
+
* gets there: the badge page links on to the seller guide.
|
|
62
|
+
*/
|
|
63
|
+
selling: { path: "/help/seller-badges", label: "What seller badges mean" },
|
|
55
64
|
technical_support: null,
|
|
56
65
|
general: null,
|
|
57
66
|
};
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
export type FAQAnswerFormat = "plain" | "markdown" | "html";
|
|
2
2
|
export type FAQCategory = "orders_payment" | "shipping_delivery" | "returns_refunds" | "product_information" | "account_security" | "technical_support" | "general" | "scam_awareness"
|
|
3
|
+
/**
|
|
4
|
+
* Questions asked by SELLERS, and the buyer-facing questions about them —
|
|
5
|
+
* how to open a store, what the badges mean, how standing is earned and
|
|
6
|
+
* lost.
|
|
7
|
+
*
|
|
8
|
+
* Kept out of `general` for the reason `fees_and_taxes` was: these have a
|
|
9
|
+
* distinct audience. A buyer browsing FAQs is not looking for "how do I
|
|
10
|
+
* list an item", and a seller looking for it should not have to read past
|
|
11
|
+
* twenty buyer questions to find it.
|
|
12
|
+
*
|
|
13
|
+
* 🛑 Adding a member here is FIVE coordinated edits, three of which are
|
|
14
|
+
* compile-checked and two of which are not: this union,
|
|
15
|
+
* `FAQ_CATEGORY_LABELS`, the `Record<FAQCategory, …>` in
|
|
16
|
+
* `constants/help-guides.ts` — and then the HARDCODED category arrays in
|
|
17
|
+
* `src/app/[locale]/faqs/page.tsx` and `faqs/[category]/page.tsx`, which
|
|
18
|
+
* nothing type-checks. A comment in those files records `fees_and_taxes`
|
|
19
|
+
* needing exactly this.
|
|
20
|
+
*/
|
|
21
|
+
| "selling"
|
|
3
22
|
/**
|
|
4
23
|
* D6b. Money questions, kept out of `orders_payment` on purpose: that
|
|
5
24
|
* category is about an ORDER (where is it, how do I pay, can I cancel),
|
|
@@ -29,7 +29,31 @@ function BrandLogo({ brand }) {
|
|
|
29
29
|
const iconSrc = brand.display?.icon;
|
|
30
30
|
const coverImage = brand.display?.coverImage;
|
|
31
31
|
const initial = brand.name[0]?.toUpperCase() ?? "?";
|
|
32
|
-
return (_jsxs(Link
|
|
32
|
+
return (_jsxs(Link
|
|
33
|
+
/*
|
|
34
|
+
* 🛑 BRAND_DETAIL, not CATEGORY_DETAIL.
|
|
35
|
+
*
|
|
36
|
+
* A brand IS a `categoryType: "brand"` row, so `/categories/brand-hasbro`
|
|
37
|
+
* resolves just as `/brands/brand-hasbro` does — and both are indexable.
|
|
38
|
+
* This component was the one surface linking brands by the category path
|
|
39
|
+
* while every other surface used `/brands/…`, so the homepage shipped two
|
|
40
|
+
* different URLs for the same entity and split its signals between them.
|
|
41
|
+
* `/brands/{slug}` is the one the sitemap advertises and the one the
|
|
42
|
+
* brand page declares canonical.
|
|
43
|
+
*/
|
|
44
|
+
, {
|
|
45
|
+
/*
|
|
46
|
+
* 🛑 BRAND_DETAIL, not CATEGORY_DETAIL.
|
|
47
|
+
*
|
|
48
|
+
* A brand IS a `categoryType: "brand"` row, so `/categories/brand-hasbro`
|
|
49
|
+
* resolves just as `/brands/brand-hasbro` does — and both are indexable.
|
|
50
|
+
* This component was the one surface linking brands by the category path
|
|
51
|
+
* while every other surface used `/brands/…`, so the homepage shipped two
|
|
52
|
+
* different URLs for the same entity and split its signals between them.
|
|
53
|
+
* `/brands/{slug}` is the one the sitemap advertises and the one the
|
|
54
|
+
* brand page declares canonical.
|
|
55
|
+
*/
|
|
56
|
+
href: ROUTES.PUBLIC.BRAND_DETAIL(brand.slug), className: "flex h-32 w-36 flex-col items-center justify-center gap-[var(--appkit-space-2)] rounded-xl border p-[var(--appkit-space-3)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] md:h-40", children: [iconSrc || coverImage ? (_jsx(Image, { src: resolveMediaUrl(iconSrc ?? coverImage), alt: brand.name, width: 64, height: 64, className: "h-16 w-16 rounded object-contain" })) : (_jsx(Row, { textWeight: "bold", textSize: "sm", className: "h-16 w-16 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: initial })), _jsx(Text, { className: "w-full truncate", color: "muted", size: "xs", weight: "medium", align: "start", children: brand.name })] }));
|
|
33
57
|
}
|
|
34
58
|
export function BrandsSection({ title = "Top Brands", subtitle, limit = 12, viewMoreHref, viewMoreLabel = "All brands →", className = "", initialItems, cta, filters, autoScroll = true, scrollInterval = 4000, loop = true, }) {
|
|
35
59
|
const themed = { textPrimary: THEMED_TEXT_PRIMARY };
|
|
@@ -72,7 +72,16 @@ function getSlideHeightClass(height) {
|
|
|
72
72
|
/** Renders the slide background — image, video, solid colour, or gradient. */
|
|
73
73
|
function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
|
|
74
74
|
const effectiveBg = bg ?? (legacy ? { type: legacy.type, url: legacy.url } : undefined);
|
|
75
|
-
|
|
75
|
+
/*
|
|
76
|
+
* 🛑 Both halves are gated on `isMobile`. `effectiveBg.mobileUrl` was read
|
|
77
|
+
* UNCONDITIONALLY, so any slide carrying a mobile crop served that
|
|
78
|
+
* phone-sized image to desktop too — a narrow picture stretched across a
|
|
79
|
+
* 1920px hero, and the desktop asset never loaded at all. The legacy half
|
|
80
|
+
* was already gated correctly, which is what made the asymmetry easy to miss.
|
|
81
|
+
*/
|
|
82
|
+
const mobileUrl = isMobile
|
|
83
|
+
? (effectiveBg?.mobileUrl ?? mobileLegacy?.url)
|
|
84
|
+
: undefined;
|
|
76
85
|
const src = mobileUrl ?? effectiveBg?.url ?? "";
|
|
77
86
|
if (!effectiveBg) {
|
|
78
87
|
return _jsx(Div, { className: `${POSITION_FILL} bg-neutral-900` });
|
|
@@ -111,17 +120,25 @@ function CardBackground({ bg }) {
|
|
|
111
120
|
return null;
|
|
112
121
|
}
|
|
113
122
|
function CarouselCardRenderer({ card, isMobile, push, }) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
/*
|
|
124
|
+
* 🛑 On mobile the card takes NO explicit grid placement at all — the parent
|
|
125
|
+
* is a one-column auto-flow grid, so source order is the layout.
|
|
126
|
+
*
|
|
127
|
+
* The old branch mapped `mobileZone` to a `gridColumn`, which against a
|
|
128
|
+
* single declared column created an implicit column 2 and stacked every
|
|
129
|
+
* card into one cell. `mobileZone` was also typed `2 | 5` only, so it could
|
|
130
|
+
* not express a six-card slide even in principle. Zones are a desktop
|
|
131
|
+
* concept; a phone has one column and nothing to place into.
|
|
132
|
+
*/
|
|
133
|
+
const gridPos = isMobile ? null : resolveZoneToGrid(card.zone);
|
|
118
134
|
const hoverClass = getHoverClass(card.hover?.effect);
|
|
119
135
|
const textAlign = card.content?.textAlign ?? "left";
|
|
120
136
|
// audit-content-alignment-ok: admin-configurable per-card textAlign field, defaults to "left" — not a hardcoded center
|
|
121
137
|
const textAlignClass = textAlign === "center" ? "text-center" : textAlign === "right" ? "text-right" : "text-left";
|
|
122
138
|
return (_jsxs(Div, { className: `relative overflow-hidden transition-all duration-300 ${hoverClass}`, rounded: "lg", shadow: "lg", style: {
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
...(gridPos
|
|
140
|
+
? { gridRow: String(gridPos.gridRow), gridColumn: String(gridPos.gridColumn) }
|
|
141
|
+
: {}),
|
|
125
142
|
minHeight: isMobile ? 100 : 140,
|
|
126
143
|
}, children: [_jsx(CardBackground, { bg: card.background }), !card.isButtonOnly && (_jsxs(_Fragment, { children: [_jsx(Scrim, { direction: "bottom-up", intensity: "strong", className: POSITION_FILL }), _jsxs(Stack, { justify: "end", className: `${POSITION_FILL} md:p-[1.5rem] ${textAlignClass}`, padding: "xs", children: [card.content?.eyebrow && (_jsx(Text, { color: "inverse", shadow: "sm", className: "text-[10px] opacity-70 mb-0.5 tracking-wider", mdSize: "xs", transform: "uppercase", children: card.content.eyebrow })), card.content?.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "hidden md:block opacity-90 mb-0.5 md:mb-2", mdSize: "sm", size: "xs", children: card.content.subtitle })), card.content?.title && (
|
|
127
144
|
// level={3}: a card sits INSIDE a slide, so it is subordinate to
|
|
@@ -202,15 +219,35 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
|
|
|
202
219
|
onMouseEnter: pauseOnHover ? () => setIsPaused(true) : undefined, onMouseLeave: pauseOnHover ? () => setIsPaused(false) : undefined, onFocus: () => setIsPaused(true), onBlur: () => setIsPaused(false), tabIndex: 0, children: [_jsx(Div, { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: `Slide ${currentSlide + 1} of ${slides.length}` }), _jsx(Div, { className: POSITION_FILL, children: _jsx(Div, { layout: "flex", ref: slidesRef, onScroll: handleSlidesScroll, className: "h-full overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", style: { gap: 0 }, children: slides.map((slide, slideIndex) => {
|
|
203
220
|
const slideHeightClass = getSlideHeightClass(slide.settings?.height);
|
|
204
221
|
const hasCards = slide.cards && slide.cards.length > 0;
|
|
205
|
-
return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card",
|
|
222
|
+
return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card",
|
|
223
|
+
/* Sits on the slide photograph — without this it renders
|
|
224
|
+
* the light `surface-elevated` chip, a pale rounded box
|
|
225
|
+
* floating on the artwork. `onMedia` selects the dark
|
|
226
|
+
* translucent backdrop that variant exists for. */
|
|
227
|
+
onMedia: true, recordLabel: slide.title ?? `Slide ${slideIndex + 1}`, editHref: String(ROUTES.ADMIN.CAROUSEL_EDIT(slide.id)) }) }), _jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && slide.overlay.enabled !== false && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
|
|
206
228
|
// level={2}, NOT 1. The welcome section above this
|
|
207
229
|
// carousel owns the page's single <h1>; a carousel that
|
|
208
230
|
// emits one <h1> per slide gives the document as many
|
|
209
231
|
// h1s as it has slides. `size` is independent of `level`,
|
|
210
232
|
// so this changes the outline and not one pixel.
|
|
211
|
-
_jsx(Heading, { color: "inverse", level: 2,
|
|
212
|
-
|
|
233
|
+
_jsx(Heading, { color: "inverse", level: 2,
|
|
234
|
+
/*
|
|
235
|
+
* 🛑 `max-w-[16ch]` is load-bearing, not taste. The box
|
|
236
|
+
* is `absolute inset-0`, so with no cap the title runs
|
|
237
|
+
* the full slide width minus padding and an 8xl
|
|
238
|
+
* headline wraps into a wall. A character-based cap
|
|
239
|
+
* tracks the font size instead of guessing at pixels.
|
|
240
|
+
*
|
|
241
|
+
* `text-balance` evens the lines so a two-line title
|
|
242
|
+
* does not leave one word stranded on line two.
|
|
243
|
+
*
|
|
244
|
+
* (A stray bare `!` token sat in this className and
|
|
245
|
+
* generated nothing — removed.)
|
|
246
|
+
*/
|
|
247
|
+
className: "stagger-2 font-display mb-2 md:mb-4 break-words text-balance max-w-[16ch] md:max-w-[18ch] lg:max-w-[20ch]", size: "4xl", shadow: "2xl", mdSize: "6xl", lgSize: "8xl", children: slide.overlay.title })), slide.overlay.description && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-3 opacity-90 mb-4 md:mb-8 max-w-2xl", mdSize: "lg", lgSize: "xl", size: "sm", children: slide.overlay.description })), slide.overlay.button && (_jsx(Div, { className: "stagger-4", children: _jsx(Button, { variant: slide.overlay.button.variant, size: "sm", onClick: makeButtonClickHandler(slide.overlay.button.link, slide.overlay.button.openInNewTab, push), children: slide.overlay.button.text }) }))] })), hasCards && (_jsx(Div, { className: `${POSITION_FILL} grid gap-[var(--appkit-space-2)] md:gap-[var(--appkit-space-4)] md:p-[var(--appkit-space-8)] overflow-y-auto`, padding: "md", style: {
|
|
248
|
+
gridTemplateRows: isMobile ? "auto" : "repeat(2, 1fr)",
|
|
213
249
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(3, 1fr)",
|
|
250
|
+
gridAutoFlow: "row",
|
|
214
251
|
alignContent: "center",
|
|
215
252
|
alignItems: "stretch",
|
|
216
253
|
}, children: slide.cards.slice(0, 6).map((card) => (_jsx(CarouselCardRenderer, { card: card, isMobile: isMobile, push: push }, card.id))) }))] }, slide.id));
|
|
@@ -125,6 +125,13 @@ export interface CarouselSlide {
|
|
|
125
125
|
};
|
|
126
126
|
cards: CarouselSlideCard[];
|
|
127
127
|
overlay?: {
|
|
128
|
+
/**
|
|
129
|
+
* Absent means SHOWN — most stored overlays predate this flag. Only an
|
|
130
|
+
* explicit `false` hides it. The seed and `push-editorial-content.mjs`
|
|
131
|
+
* have always written `enabled: true`; the renderer simply never read it,
|
|
132
|
+
* so an admin switching it off changed nothing.
|
|
133
|
+
*/
|
|
134
|
+
enabled?: boolean;
|
|
128
135
|
title?: string;
|
|
129
136
|
subtitle?: string;
|
|
130
137
|
description?: string;
|
|
@@ -6,6 +6,7 @@ import { BaseListingCard, Div, Heading, RichText, Row, Scrim, Span, Stack } from
|
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
7
7
|
import { normalizeRichTextHtml } from "../../../utils/index.js";
|
|
8
8
|
import { useLongPress } from "../../../react/hooks/useLongPress.js";
|
|
9
|
+
import { SellerTierBadge } from "./SellerTierBadge.js";
|
|
9
10
|
const __O = {
|
|
10
11
|
hidden: "overflow-hidden",
|
|
11
12
|
};
|
|
@@ -25,7 +26,7 @@ export function InteractiveStoreCard({ store, href, selectable, isSelected = fal
|
|
|
25
26
|
].join(" "), onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => {
|
|
26
27
|
e.preventDefault();
|
|
27
28
|
onSelect(store.id, !isSelected);
|
|
28
|
-
}, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-20 select-none", size: "5xl", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: "-mt-5 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
|
|
29
|
+
}, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-20 select-none", size: "5xl", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: "-mt-5 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), _jsx(SellerTierBadge, { isVerified: store.isVerified, sellerTier: store.sellerTier, size: "xs" }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
|
|
29
30
|
if (!renderOwnerActions)
|
|
30
31
|
return card;
|
|
31
32
|
/*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Trusted Seller criteria, rendered once for three audiences.
|
|
3
|
+
*
|
|
4
|
+
* - the seller's dashboard panel (their real figures against each target)
|
|
5
|
+
* - the public `/seller-guide` (targets only — no seller to measure)
|
|
6
|
+
* - `/help/seller-badges` and the admin trust guide (same, as a reference)
|
|
7
|
+
*
|
|
8
|
+
* 🛑 ONE component for all of them, driven by `sellerStandingRows()`. The
|
|
9
|
+
* whole point of this feature's config module is that the numbers a seller is
|
|
10
|
+
* chasing and the numbers a guide page promises cannot be different numbers —
|
|
11
|
+
* and the surest way to reintroduce that is a second table (Root Cause
|
|
12
|
+
* #73/#75). `metrics` absent is what distinguishes the guide from the panel,
|
|
13
|
+
* not a separate component.
|
|
14
|
+
*/
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { type SellerStandingThresholds, type SellerTier } from "../../../_internal/shared/features/seller-standing/config";
|
|
17
|
+
import type { SellerStandingSnapshot } from "../types";
|
|
18
|
+
export interface SellerStandingPanelProps {
|
|
19
|
+
/** Absent on a store that has never been measured, and on the guide pages. */
|
|
20
|
+
standing?: SellerStandingSnapshot;
|
|
21
|
+
tier?: SellerTier;
|
|
22
|
+
/** Admin-tuned thresholds; defaults apply when omitted. */
|
|
23
|
+
thresholds?: SellerStandingThresholds;
|
|
24
|
+
/**
|
|
25
|
+
* Show "N of 5 met" and the progress bar. Off for the public guide, where
|
|
26
|
+
* there is no seller to be N-of-5 about.
|
|
27
|
+
*/
|
|
28
|
+
showProgress?: boolean;
|
|
29
|
+
heading?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function SellerStandingPanel({ standing, tier, thresholds, showProgress, heading, }: SellerStandingPanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Div, Heading, Row, Stack, Text } from "../../../ui/index.js";
|
|
3
|
+
import { Icon } from "../../../ui/components/Icon.js";
|
|
4
|
+
import { Progress } from "../../../ui/components/Progress.js";
|
|
5
|
+
import { SELLER_TIER, formatSellerStandingValue, sellerStandingRows, } from "../../../_internal/shared/features/seller-standing/config.js";
|
|
6
|
+
function formatDate(value) {
|
|
7
|
+
if (!value)
|
|
8
|
+
return null;
|
|
9
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
10
|
+
if (Number.isNaN(d.getTime()))
|
|
11
|
+
return null;
|
|
12
|
+
return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
13
|
+
}
|
|
14
|
+
export function SellerStandingPanel({ standing, tier = SELLER_TIER.SELLER, thresholds, showProgress = false, heading = "Trusted Seller", }) {
|
|
15
|
+
const rows = sellerStandingRows(standing ?? null, thresholds);
|
|
16
|
+
const metCount = rows.filter((r) => r.met).length;
|
|
17
|
+
const isTrusted = tier === SELLER_TIER.TRUSTED;
|
|
18
|
+
const computedAt = formatDate(standing?.computedAt);
|
|
19
|
+
const nextComputeAt = formatDate(standing?.nextComputeAt);
|
|
20
|
+
return (_jsxs(Stack, { gap: "md", surface: "card", padding: "lg", rounded: "xl", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", children: heading }), showProgress ? (_jsxs(Text, { size: "xs", color: "muted", children: [metCount, " of ", rows.length, " met"] })) : null] }), _jsx(Text, { size: "sm", color: "muted", children: isTrusted
|
|
21
|
+
? "Your store meets every criterion. Your badge is live, and your payouts start when you ship rather than on delivery."
|
|
22
|
+
: `Meet all ${rows.length} criteria in the same ${standing?.windowDays ?? thresholds?.windowDays ?? 90}-day window to earn the badge — and to be paid when you ship, rather than waiting for delivery.` })] }), showProgress ? _jsx(Progress, { value: metCount, max: rows.length, size: "sm", variant: "gradient" }) : null, _jsx(Stack, { gap: "sm", children: rows.map((row) => (_jsxs(Row, { gap: "sm", align: "start", children: [_jsx(Icon, { name: row.met ? "approve" : "confirm", size: "sm", tone: row.met ? "success" : "muted", "aria-hidden": true }), _jsxs(Stack, { gap: "xs", className: "min-w-0 flex-1", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: row.label }), _jsxs(Text, { size: "sm", color: row.met ? "inherit" : "muted", numeric: true, children: [row.actual === null
|
|
23
|
+
? "—"
|
|
24
|
+
: formatSellerStandingValue(row.actual, row.kind), " / ", formatSellerStandingValue(row.target, row.kind)] })] }), _jsx(Text, { size: "xs", color: "muted", children: row.why })] })] }, row.criterion))) }), _jsx(Div, { children: standing ? (_jsxs(Text, { size: "xs", color: "faint", children: ["Figures as of ", computedAt ?? "the last check", nextComputeAt ? ` · next update ${nextComputeAt}` : "", ". Updated once a month, on the last day."] })) : (_jsx(Text, { size: "xs", color: "faint", children: "Your figures are calculated on the last day of each month \u2014 nothing has been measured for your store yet." })) })] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seller-trust badges: Verified and Trusted Seller.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 Until 2026-10-24 NEITHER rendered anywhere a buyer could see.
|
|
5
|
+
* `isVerified` was in `PRIVATE_STORE_FIELDS`, so it was stripped from every
|
|
6
|
+
* public store payload — while `/sellers` filtered on it to offer a "verified
|
|
7
|
+
* sellers" view, `StoreCapabilitiesGuideView` told sellers in as many words
|
|
8
|
+
* that "the Verified badge appears on your store profile page and on every
|
|
9
|
+
* listing card", and the onboarding checklist offered "Request verified badge"
|
|
10
|
+
* as a step. Three surfaces describing a badge that could not exist.
|
|
11
|
+
*
|
|
12
|
+
* ## One component, every surface
|
|
13
|
+
*
|
|
14
|
+
* Store card, store header, the product page's "Sold by" block and the seller
|
|
15
|
+
* directory all render this. A per-surface copy is how the two badges would
|
|
16
|
+
* come to disagree about what "trusted" means — and one of them means we pay
|
|
17
|
+
* the seller before delivery, so that is not a drift worth risking.
|
|
18
|
+
*
|
|
19
|
+
* 🛑 NOT to be confused with `SellerTrustBadge` in `features/scams/`, which is
|
|
20
|
+
* the scam-registry lookup (is this store on the reported-scammer registry).
|
|
21
|
+
* Different question, different source, and both can show at once: a store can
|
|
22
|
+
* be verified by us and still have a report against it.
|
|
23
|
+
*
|
|
24
|
+
* ## Why no Tailwind colour utilities here
|
|
25
|
+
*
|
|
26
|
+
* `<Badge>` carries its own `appkit-badge--*` CSS, so this authors no colour
|
|
27
|
+
* classes at all — which sidesteps Root Cause #67 entirely rather than having
|
|
28
|
+
* to pick the right `-surface`/`-solid` pairing and get the ink to match.
|
|
29
|
+
*/
|
|
30
|
+
import React from "react";
|
|
31
|
+
import type { SellerTier } from "../../../_internal/shared/features/seller-standing/config";
|
|
32
|
+
export interface SellerTierBadgeProps {
|
|
33
|
+
/**
|
|
34
|
+
* 🛑 Takes the two FLAGS, not a store object.
|
|
35
|
+
*
|
|
36
|
+
* Every call site holds a different shape — `StoreListItem`, `StoreDetail`,
|
|
37
|
+
* a product page's denormalised seller block, an admin row — and a prop
|
|
38
|
+
* typed to one of them would force the others through a cast. A cast at a
|
|
39
|
+
* boundary is where Root Cause #98 lives.
|
|
40
|
+
*/
|
|
41
|
+
isVerified?: boolean;
|
|
42
|
+
sellerTier?: SellerTier;
|
|
43
|
+
size?: "xs" | "sm" | "md";
|
|
44
|
+
/**
|
|
45
|
+
* Where the badges explain themselves. Omit for no link — a card already
|
|
46
|
+
* links to the store, and nesting an anchor inside it is invalid HTML.
|
|
47
|
+
*/
|
|
48
|
+
href?: string;
|
|
49
|
+
/** Rendered as a link element supplied by the consumer (locale-aware). */
|
|
50
|
+
linkAs?: React.ElementType;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Renders nothing when a store has neither badge — and that is deliberate:
|
|
54
|
+
* an "unverified" chip would mark every ordinary seller as suspect, which is
|
|
55
|
+
* a claim we are not making about them.
|
|
56
|
+
*/
|
|
57
|
+
export declare function SellerTierBadge({ isVerified, sellerTier, size, href, linkAs, }: SellerTierBadgeProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge, Row } from "../../../ui/index.js";
|
|
3
|
+
import { Icon } from "../../../ui/components/Icon.js";
|
|
4
|
+
import { SELLER_TIER } from "../../../_internal/shared/features/seller-standing/config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Renders nothing when a store has neither badge — and that is deliberate:
|
|
7
|
+
* an "unverified" chip would mark every ordinary seller as suspect, which is
|
|
8
|
+
* a claim we are not making about them.
|
|
9
|
+
*/
|
|
10
|
+
export function SellerTierBadge({ isVerified = false, sellerTier = SELLER_TIER.SELLER, size = "sm", href, linkAs, }) {
|
|
11
|
+
const isTrusted = sellerTier === SELLER_TIER.TRUSTED;
|
|
12
|
+
if (!isVerified && !isTrusted)
|
|
13
|
+
return null;
|
|
14
|
+
const badges = (_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [isVerified ? (_jsx(Badge, { variant: "info", size: size, children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Icon, { name: "verify", size: size === "md" ? "md" : "sm", "aria-hidden": true }), "Verified"] }) })) : null, isTrusted ? (_jsx(Badge, { variant: "success", size: size, children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Icon, { name: "review", size: size === "md" ? "md" : "sm", "aria-hidden": true }), "Trusted Seller"] }) })) : null] }));
|
|
15
|
+
if (!href)
|
|
16
|
+
return badges;
|
|
17
|
+
const Link = (linkAs ?? "a");
|
|
18
|
+
return (_jsx(Link, { href: href, "aria-label": "What do these seller badges mean?", children: badges }));
|
|
19
|
+
}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ArrowLeft } from "lucide-react";
|
|
3
3
|
import { Alert, Code, Div, Heading, Li, Row, Section, Span, Stack, Table, Tbody, Td, Text, TextLink, Th, Thead, Tr, Ul } from "../../../ui/index.js";
|
|
4
4
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
5
|
+
import { SellerStandingPanel } from "./SellerStandingPanel.js";
|
|
5
6
|
const SUBHEADING = "mb-1 font-semibold text-[var(--appkit-color-text)]";
|
|
6
7
|
const LIST_DISC = "list-disc leading-relaxed";
|
|
7
8
|
const SECTION_BODY = "space-y-4 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]";
|
|
@@ -30,7 +31,24 @@ const CAPABILITY_GROUPS = [
|
|
|
30
31
|
{
|
|
31
32
|
group: "Trust & Visibility",
|
|
32
33
|
items: [
|
|
33
|
-
|
|
34
|
+
/*
|
|
35
|
+
* 🛑 CORRECTED 2026-10-24. This read "Shows a Verified badge on your
|
|
36
|
+
* store profile and listing cards", and that was false in two ways.
|
|
37
|
+
*
|
|
38
|
+
* (1) The badge is driven by `StoreDocument.isVerified`, not by this
|
|
39
|
+
* capability — `verified_seller` is read by NOTHING: no gate, no
|
|
40
|
+
* render, anywhere. Granting it has never done a thing.
|
|
41
|
+
* (2) No badge rendered at all, because `isVerified` sat in
|
|
42
|
+
* `PRIVATE_STORE_FIELDS` and was stripped from every public store
|
|
43
|
+
* payload. A seller could read this sentence, be granted the
|
|
44
|
+
* capability, and correctly conclude the platform was broken.
|
|
45
|
+
*
|
|
46
|
+
* The badge now renders (`SellerTierBadge`) off `isVerified`. The
|
|
47
|
+
* capability stays declared because existing store documents carry it
|
|
48
|
+
* and removing a union member would fail their next write — but it is
|
|
49
|
+
* described as what it is.
|
|
50
|
+
*/
|
|
51
|
+
{ cap: "verified_seller", unlocks: "Legacy flag — kept so existing stores stay valid. The Verified badge is granted by an admin on the store itself, not by this capability.", defaultOn: false },
|
|
34
52
|
{ cap: "featured_placement", unlocks: "Your store is eligible to appear in featured store sections on the homepage.", defaultOn: false },
|
|
35
53
|
{ cap: "promotional_banner", unlocks: "Your store banner can be used in carousel and promotional homepage sections.", defaultOn: false },
|
|
36
54
|
{ cap: "priority_support", unlocks: "Your support tickets go to a dedicated queue with faster response SLA.", defaultOn: false },
|
|
@@ -89,8 +107,20 @@ function LockedFeatureGuide() {
|
|
|
89
107
|
return (_jsx(Stack, { gap: "md", children: LOCKED_FEATURES.map(({ cap, missing, howToUnlock, contractRequired }) => (_jsxs(Stack, { className: "border border-[var(--appkit-color-border)]", gap: "sm", rounded: "lg", padding: "md", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Text, { className: "font-mono text-[var(--appkit-color-text)]", size: "sm", weight: "semibold", children: cap }), contractRequired && (_jsx(Span, { className: "inline-block bg-[var(--appkit-color-warning)]/10 text-[var(--appkit-color-warning)]", size: "xs", weight: "medium", rounded: "full", padding: "pill-xs", children: "Requires agreement" }))] }), _jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)] mb-1", size: "xs", weight: "semibold", children: "What you are missing:" }), _jsx(Ul, { spacing: "2xs", indent: "md", children: missing.map((item) => (_jsx(Li, { textSize: "sm", color: "muted", className: LIST_DISC, children: item }, item))) })] }), _jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text-muted)] mb-1", size: "xs", weight: "semibold", children: "How to unlock:" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] leading-relaxed", size: "sm", children: howToUnlock })] })] }, cap))) }));
|
|
90
108
|
}
|
|
91
109
|
// -- Section 4: Verified seller badge -----------------------------------------
|
|
110
|
+
/*
|
|
111
|
+
* 🛑 REWRITTEN 2026-10-24. This section used to state a fourth, invented set
|
|
112
|
+
* of criteria — "store active for at least 6 months", "average rating of 4.5
|
|
113
|
+
* or above", "at least 50 delivered orders" — none of which any code has ever
|
|
114
|
+
* read, alongside `AdminStoresGuideView`'s own different numbers (">=20
|
|
115
|
+
* delivered orders, >=4.0 rating, 3+ months"). Two guides, two sets of
|
|
116
|
+
* promises, zero enforcement, and a badge that could not render at all.
|
|
117
|
+
*
|
|
118
|
+
* The two badges are now described as what they are, and the earned one
|
|
119
|
+
* renders its criteria from `SELLER_STANDING_DEFAULTS` so this page cannot
|
|
120
|
+
* drift from the job that grants it.
|
|
121
|
+
*/
|
|
92
122
|
function VerifiedSellerSection() {
|
|
93
|
-
return (_jsxs(Div, { className: SECTION_BODY, children: [_jsxs(Div, { children: [_jsx(Text, { className: SUBHEADING, children: "
|
|
123
|
+
return (_jsxs(Div, { className: SECTION_BODY, children: [_jsxs(Div, { children: [_jsx(Text, { className: SUBHEADING, children: "Two different badges" }), _jsxs(Text, { className: "leading-relaxed", children: [_jsx(Span, { weight: "bold", children: "Verified" }), " means LetItRip has checked who you are. It is granted by an admin after an identity review, it is not earned by volume, and it does not expire on its own.", " ", _jsx(Span, { weight: "bold", children: "Trusted Seller" }), " is earned from your trading record and is recalculated every month \u2014 you can gain it and you can lose it. A store can hold either, both, or neither."] })] }), _jsxs(Div, { children: [_jsx(Text, { className: SUBHEADING, children: "Getting Verified" }), _jsx(Text, { className: "leading-relaxed", children: "Open a support ticket from your store dashboard with category \"Account\" and subject \"Verified Seller Application\", and attach a government-issued photo ID. An admin reviews it and sets the badge on your store." })] }), _jsxs(Div, { children: [_jsx(Text, { className: SUBHEADING, children: "Earning Trusted Seller" }), _jsx(Text, { className: "leading-relaxed", children: "Every criterion below is measured over the same rolling window, and all of them have to hold at the same time. Meeting them also changes when you are paid: your payout starts when you ship rather than when the parcel is delivered." })] }), _jsx(SellerStandingPanel, { heading: "The criteria" })] }));
|
|
94
124
|
}
|
|
95
125
|
// -- Section 5: Multiple stores -----------------------------------------------
|
|
96
126
|
function MultipleStoresSection() {
|