@mohasinac/appkit 4.63.5 → 4.64.1
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/index.d.ts +2 -0
- package/dist/index.js +21 -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,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seller standing — the ONE definition of what earns the Trusted Seller tier.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a single pure module
|
|
5
|
+
*
|
|
6
|
+
* Four surfaces must agree about the criteria: the monthly rollup job that
|
|
7
|
+
* grants and revokes, the seller's in-dashboard progress panel, the public
|
|
8
|
+
* `/seller-guide` criteria table, and the admin trust guide. Four hand-written
|
|
9
|
+
* copies of "25 delivered orders in 90 days" is Root Cause #73/#75's shape —
|
|
10
|
+
* the copy that gets fixed is never the copy the reader believes. So the
|
|
11
|
+
* thresholds, the predicate AND the display rows all come from here.
|
|
12
|
+
*
|
|
13
|
+
* 🛑 It had a fifth copy before this existed, and it was prose:
|
|
14
|
+
* `AdminStoresGuideView` told admins the verified badge was "awarded when the
|
|
15
|
+
* store has a consistent track record (>=20 delivered orders, >=4.0 rating, 3+
|
|
16
|
+
* months active)" — numbers no code has ever read, attached to a field nothing
|
|
17
|
+
* computes. That sentence is what the whole feature was remembered as being.
|
|
18
|
+
*
|
|
19
|
+
* ## Pure, and that is load-bearing
|
|
20
|
+
*
|
|
21
|
+
* No Firestore, no clock, no `Date.now()`. The job passes in metrics it has
|
|
22
|
+
* already measured and the UI passes in metrics it has already loaded, so the
|
|
23
|
+
* same input always produces the same verdict and the panel can never disagree
|
|
24
|
+
* with the grant.
|
|
25
|
+
*/
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Criteria
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* 🛑 A `Record<Criterion, true>`, not a string array.
|
|
31
|
+
*
|
|
32
|
+
* Adding a criterion then becomes a COMPILE error at every site that must
|
|
33
|
+
* display or evaluate it — the thresholds, the labels, the verdict's `met`
|
|
34
|
+
* map. An array lets a new criterion be evaluated while silently rendering
|
|
35
|
+
* nowhere, which is how ten hand-maintained enumerations of one union drifted
|
|
36
|
+
* in Root Cause #61.
|
|
37
|
+
*/
|
|
38
|
+
const SELLER_STANDING_CRITERION_MAP = {
|
|
39
|
+
deliveredOrders: true,
|
|
40
|
+
distinctBuyers: true,
|
|
41
|
+
distinctAddresses: true,
|
|
42
|
+
ratedOrders: true,
|
|
43
|
+
averageRating: true,
|
|
44
|
+
};
|
|
45
|
+
export const SELLER_STANDING_CRITERIA = Object.keys(SELLER_STANDING_CRITERION_MAP);
|
|
46
|
+
export const SELLER_TIER = {
|
|
47
|
+
SELLER: "seller",
|
|
48
|
+
TRUSTED: "trusted",
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The shipped defaults.
|
|
52
|
+
*
|
|
53
|
+
* 🛑 Every count is measured INSIDE `windowDays`, not lifetime. That is the
|
|
54
|
+
* strict reading and it was chosen deliberately: the tier grants early payout,
|
|
55
|
+
* so it has to describe a seller who is shipping reliably *now*, not one who
|
|
56
|
+
* did two years ago. The consequence worth knowing up front is that on a
|
|
57
|
+
* freshly seeded catalogue **nobody qualifies**, and that is the correct
|
|
58
|
+
* result rather than a broken job.
|
|
59
|
+
*
|
|
60
|
+
* Admin-overridable via `siteSettings.sellerStanding` so the bar can be tuned
|
|
61
|
+
* once real volume exists, without a deploy.
|
|
62
|
+
*/
|
|
63
|
+
export const SELLER_STANDING_DEFAULTS = {
|
|
64
|
+
windowDays: 90,
|
|
65
|
+
minDeliveredOrders: 25,
|
|
66
|
+
minDistinctBuyers: 25,
|
|
67
|
+
minDistinctAddresses: 25,
|
|
68
|
+
minRatedOrders: 25,
|
|
69
|
+
minAverageRating: 4,
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Does this seller currently meet the bar?
|
|
73
|
+
*
|
|
74
|
+
* 🛑 ALL criteria must hold. There is no scoring and no partial credit: the
|
|
75
|
+
* reward is being paid before delivery, so "mostly reliable" is not a state
|
|
76
|
+
* this can grant on.
|
|
77
|
+
*/
|
|
78
|
+
export function evaluateSellerStanding(metrics, thresholds = SELLER_STANDING_DEFAULTS) {
|
|
79
|
+
const met = {
|
|
80
|
+
deliveredOrders: metrics.deliveredOrders >= thresholds.minDeliveredOrders,
|
|
81
|
+
distinctBuyers: metrics.distinctBuyers >= thresholds.minDistinctBuyers,
|
|
82
|
+
distinctAddresses: metrics.distinctAddresses >= thresholds.minDistinctAddresses,
|
|
83
|
+
ratedOrders: metrics.ratedOrders >= thresholds.minRatedOrders,
|
|
84
|
+
averageRating: metrics.averageRating >= thresholds.minAverageRating,
|
|
85
|
+
};
|
|
86
|
+
const unmetCount = SELLER_STANDING_CRITERIA.filter((c) => !met[c]).length;
|
|
87
|
+
return {
|
|
88
|
+
tier: unmetCount === 0 ? SELLER_TIER.TRUSTED : SELLER_TIER.SELLER,
|
|
89
|
+
met,
|
|
90
|
+
unmetCount,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Display — so the panel, the public guide and the admin guide agree
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
export const SELLER_STANDING_CRITERION_LABELS = {
|
|
97
|
+
deliveredOrders: "Delivered orders",
|
|
98
|
+
distinctBuyers: "Different buyers",
|
|
99
|
+
distinctAddresses: "Different delivery addresses",
|
|
100
|
+
ratedOrders: "Rated orders",
|
|
101
|
+
averageRating: "Average rating",
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Why each criterion exists, in the seller's words. Rendered on the guide
|
|
105
|
+
* pages — a bar with no stated reason reads as arbitrary, and two of these
|
|
106
|
+
* exist specifically to make the tier hard to manufacture.
|
|
107
|
+
*/
|
|
108
|
+
export const SELLER_STANDING_CRITERION_WHY = {
|
|
109
|
+
deliveredOrders: "Enough completed sales to show a pattern, not a one-off.",
|
|
110
|
+
distinctBuyers: "Sales to real, different customers.",
|
|
111
|
+
distinctAddresses: "Orders going to different places, not the same one repeatedly.",
|
|
112
|
+
ratedOrders: "Enough ratings for the average below to mean something.",
|
|
113
|
+
averageRating: "Buyers are consistently happy with what arrives.",
|
|
114
|
+
};
|
|
115
|
+
function targetFor(criterion, t) {
|
|
116
|
+
switch (criterion) {
|
|
117
|
+
case "deliveredOrders":
|
|
118
|
+
return t.minDeliveredOrders;
|
|
119
|
+
case "distinctBuyers":
|
|
120
|
+
return t.minDistinctBuyers;
|
|
121
|
+
case "distinctAddresses":
|
|
122
|
+
return t.minDistinctAddresses;
|
|
123
|
+
case "ratedOrders":
|
|
124
|
+
return t.minRatedOrders;
|
|
125
|
+
case "averageRating":
|
|
126
|
+
return t.minAverageRating;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* One row per criterion, for rendering.
|
|
131
|
+
*
|
|
132
|
+
* `metrics` may be null — a store whose standing has never been computed shows
|
|
133
|
+
* every target with no figure beside it, which is honest. 🛑 It must NOT show
|
|
134
|
+
* zeros: a confident "0 of 25" on a store that simply has not been measured
|
|
135
|
+
* yet is the same lie as a counter that has never been written reading as 0
|
|
136
|
+
* (Root Cause #72).
|
|
137
|
+
*/
|
|
138
|
+
export function sellerStandingRows(metrics, thresholds = SELLER_STANDING_DEFAULTS) {
|
|
139
|
+
const verdict = metrics ? evaluateSellerStanding(metrics, thresholds) : null;
|
|
140
|
+
return SELLER_STANDING_CRITERIA.map((criterion) => ({
|
|
141
|
+
criterion,
|
|
142
|
+
label: SELLER_STANDING_CRITERION_LABELS[criterion],
|
|
143
|
+
why: SELLER_STANDING_CRITERION_WHY[criterion],
|
|
144
|
+
kind: criterion === "averageRating" ? "rating" : "count",
|
|
145
|
+
actual: metrics ? metrics[criterion] : null,
|
|
146
|
+
target: targetFor(criterion, thresholds),
|
|
147
|
+
met: verdict ? verdict.met[criterion] : false,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
/** `4` → `"4.0"`, `25` → `"25"`. Keeps rating and count formatting in one place. */
|
|
151
|
+
export function formatSellerStandingValue(value, kind) {
|
|
152
|
+
return kind === "rating" ? value.toFixed(1) : String(value);
|
|
153
|
+
}
|
|
@@ -438,6 +438,14 @@ export declare const EVENT_ENDPOINTS: {
|
|
|
438
438
|
readonly ENTRIES: (id: string) => string;
|
|
439
439
|
readonly LEADERBOARD: (id: string, limit?: number) => string;
|
|
440
440
|
readonly SPIN: (id: string) => string;
|
|
441
|
+
/**
|
|
442
|
+
* Likes. POST toggles the viewer's; GET pages the "liked by" list.
|
|
443
|
+
*
|
|
444
|
+
* `id` may be a slug — public event URLs link by slug — and the route
|
|
445
|
+
* resolves it to the canonical document id before keying anything.
|
|
446
|
+
*/
|
|
447
|
+
readonly LIKES: (id: string) => string;
|
|
448
|
+
readonly LIKERS: (id: string, offset: number) => string;
|
|
441
449
|
readonly LOTTERY_PULL: (id: string) => string;
|
|
442
450
|
readonly LOTTERY_ENTRIES: (id: string) => string;
|
|
443
451
|
};
|
|
@@ -1011,6 +1019,14 @@ export declare const API_ENDPOINTS: {
|
|
|
1011
1019
|
readonly ENTRIES: (id: string) => string;
|
|
1012
1020
|
readonly LEADERBOARD: (id: string, limit?: number) => string;
|
|
1013
1021
|
readonly SPIN: (id: string) => string;
|
|
1022
|
+
/**
|
|
1023
|
+
* Likes. POST toggles the viewer's; GET pages the "liked by" list.
|
|
1024
|
+
*
|
|
1025
|
+
* `id` may be a slug — public event URLs link by slug — and the route
|
|
1026
|
+
* resolves it to the canonical document id before keying anything.
|
|
1027
|
+
*/
|
|
1028
|
+
readonly LIKES: (id: string) => string;
|
|
1029
|
+
readonly LIKERS: (id: string, offset: number) => string;
|
|
1014
1030
|
readonly LOTTERY_PULL: (id: string) => string;
|
|
1015
1031
|
readonly LOTTERY_ENTRIES: (id: string) => string;
|
|
1016
1032
|
};
|
|
@@ -1602,6 +1618,14 @@ export declare const API_ROUTES: {
|
|
|
1602
1618
|
readonly ENTRIES: (id: string) => string;
|
|
1603
1619
|
readonly LEADERBOARD: (id: string, limit?: number) => string;
|
|
1604
1620
|
readonly SPIN: (id: string) => string;
|
|
1621
|
+
/**
|
|
1622
|
+
* Likes. POST toggles the viewer's; GET pages the "liked by" list.
|
|
1623
|
+
*
|
|
1624
|
+
* `id` may be a slug — public event URLs link by slug — and the route
|
|
1625
|
+
* resolves it to the canonical document id before keying anything.
|
|
1626
|
+
*/
|
|
1627
|
+
readonly LIKES: (id: string) => string;
|
|
1628
|
+
readonly LIKERS: (id: string, offset: number) => string;
|
|
1605
1629
|
readonly LOTTERY_PULL: (id: string) => string;
|
|
1606
1630
|
readonly LOTTERY_ENTRIES: (id: string) => string;
|
|
1607
1631
|
};
|
|
@@ -541,6 +541,14 @@ export const EVENT_ENDPOINTS = {
|
|
|
541
541
|
LEADERBOARD: (id, limit) => `/api/events/${id}/leaderboard${limit !== undefined ? `?limit=${limit}` : ""}`,
|
|
542
542
|
// SB9-E user spin assignment
|
|
543
543
|
SPIN: (id) => `/api/events/${id}/spin`,
|
|
544
|
+
/**
|
|
545
|
+
* Likes. POST toggles the viewer's; GET pages the "liked by" list.
|
|
546
|
+
*
|
|
547
|
+
* `id` may be a slug — public event URLs link by slug — and the route
|
|
548
|
+
* resolves it to the canonical document id before keying anything.
|
|
549
|
+
*/
|
|
550
|
+
LIKES: (id) => `/api/events/${id}/likes`,
|
|
551
|
+
LIKERS: (id, offset) => `/api/events/${id}/likes?offset=${offset}`,
|
|
544
552
|
// Lottery: user self-pull (draw happens immediately on submit)
|
|
545
553
|
LOTTERY_PULL: (id) => `/api/events/${id}/lottery-pull`,
|
|
546
554
|
LOTTERY_ENTRIES: (id) => `/api/events/${id}/lottery-entries`,
|
|
@@ -5,7 +5,7 @@ import { ProseHero } from "./ProseHero.js";
|
|
|
5
5
|
import { FLEX_ROW } from "../../../_internal/shared/styles/themed.js";
|
|
6
6
|
import { Alert, Div, Heading, Row, Section, Stack, Text } from "../../../ui/index.js";
|
|
7
7
|
import { TextLink } from "../../../ui/index.js";
|
|
8
|
-
import { BookOpen, MessageCircle, ShoppingBag, Gavel, PackageCheck, UserCheck, Store, ChevronRight, } from "lucide-react";
|
|
8
|
+
import { BookOpen, MessageCircle, ShoppingBag, Gavel, PackageCheck, UserCheck, Store, ShieldCheck, ChevronRight, } from "lucide-react";
|
|
9
9
|
const __P = {
|
|
10
10
|
p6: "p-[var(--appkit-space-6)]",
|
|
11
11
|
p8: "p-[var(--appkit-space-8)]",
|
|
@@ -48,6 +48,14 @@ export async function HelpPageView({} = {}) {
|
|
|
48
48
|
color: "bg-violet-50 border-violet-200 dark:bg-violet-900/20 dark:border-violet-700",
|
|
49
49
|
iconColor: "text-violet-600 dark:text-violet-400",
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
icon: ShieldCheck,
|
|
53
|
+
title: t("topicSellerBadges"),
|
|
54
|
+
desc: t("topicSellerBadgesDesc"),
|
|
55
|
+
href: String(ROUTES.PUBLIC.HELP_SELLER_BADGES),
|
|
56
|
+
color: "bg-teal-50 border-teal-200 dark:bg-teal-900/20 dark:border-teal-700",
|
|
57
|
+
iconColor: "text-teal-600 dark:text-teal-400",
|
|
58
|
+
},
|
|
51
59
|
{
|
|
52
60
|
icon: BookOpen,
|
|
53
61
|
title: t("topicFAQs"),
|
|
@@ -4,13 +4,25 @@ export interface BecomeSellerViewLabels {
|
|
|
4
4
|
applyButton?: string;
|
|
5
5
|
alreadySeller?: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* 🛑 `create-store` is a real state, not a variant of `already-seller`.
|
|
9
|
+
*
|
|
10
|
+
* Applying and owning a storefront are two different things, and the gap
|
|
11
|
+
* between them was invisible: a user who applied became `role: "seller"` with
|
|
12
|
+
* no store, and this shell's `already-seller` slot told them to "head over to
|
|
13
|
+
* your Store Dashboard" — a dashboard with no store behind it. There was no
|
|
14
|
+
* step anywhere in the product that created one. This state is that step.
|
|
15
|
+
*/
|
|
16
|
+
export type BecomeSellerState = "guide" | "success" | "create-store" | "already-seller";
|
|
7
17
|
export interface BecomeSellerViewProps {
|
|
8
18
|
labels?: BecomeSellerViewLabels;
|
|
9
19
|
renderGuide?: () => React.ReactNode;
|
|
10
20
|
renderSuccess?: () => React.ReactNode;
|
|
21
|
+
/** Seller with no store yet — renders the store-creation form. */
|
|
22
|
+
renderCreateStore?: () => React.ReactNode;
|
|
11
23
|
renderAlreadySeller?: () => React.ReactNode;
|
|
12
|
-
/** Which state to render. Shell exposes all
|
|
13
|
-
state?:
|
|
24
|
+
/** Which state to render. Shell exposes all four slots; consumer controls which is active. */
|
|
25
|
+
state?: BecomeSellerState;
|
|
14
26
|
className?: string;
|
|
15
27
|
}
|
|
16
|
-
export declare function BecomeSellerView({ labels, renderGuide, renderSuccess, renderAlreadySeller, state, className, }: BecomeSellerViewProps): React.JSX.Element;
|
|
28
|
+
export declare function BecomeSellerView({ labels, renderGuide, renderSuccess, renderCreateStore, renderAlreadySeller, state, className, }: BecomeSellerViewProps): React.JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Div, Heading } from "../../../ui/index.js";
|
|
3
|
-
export function BecomeSellerView({ labels = {}, renderGuide, renderSuccess, renderAlreadySeller, state = "guide", className = "", }) {
|
|
4
|
-
return (_jsxs(Div, { className: className, children: [labels.title && state === "guide" && (_jsx(Heading, { level: 1, className: "mb-6", size: "2xl", weight: "bold", children: labels.title })), state === "guide" && renderGuide?.(), state === "success" && renderSuccess?.(), state === "already-seller" && renderAlreadySeller?.()] }));
|
|
3
|
+
export function BecomeSellerView({ labels = {}, renderGuide, renderSuccess, renderCreateStore, renderAlreadySeller, state = "guide", className = "", }) {
|
|
4
|
+
return (_jsxs(Div, { className: className, children: [labels.title && state === "guide" && (_jsx(Heading, { level: 1, className: "mb-6", size: "2xl", weight: "bold", children: labels.title })), state === "guide" && renderGuide?.(), state === "success" && renderSuccess?.(), state === "create-store" && renderCreateStore?.(), state === "already-seller" && renderAlreadySeller?.()] }));
|
|
5
5
|
}
|
|
@@ -7,6 +7,17 @@ export interface AdminStoreEditorViewProps {
|
|
|
7
7
|
currentStatus?: string;
|
|
8
8
|
currentIsVerified?: boolean;
|
|
9
9
|
currentIsFeatured?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* 🛑 Both REQUIRED for the controls to show the stored value, and both are
|
|
12
|
+
* the Root Cause #38 trap: `AdminStoresView` seeds this editor from a LIST
|
|
13
|
+
* row, so a field the list serializer omits arrives `undefined`, the control
|
|
14
|
+
* defaults to off, and the save handler sends that wrong default back —
|
|
15
|
+
* silently un-trusting a trusted store while the admin was editing
|
|
16
|
+
* something else. The list route emits `trustedSeller` already; `sellerTier`
|
|
17
|
+
* had to be added to it in the same change.
|
|
18
|
+
*/
|
|
19
|
+
currentSellerTier?: "seller" | "trusted";
|
|
20
|
+
currentTrustedSeller?: boolean;
|
|
10
21
|
currentCapabilities?: string[];
|
|
11
22
|
/**
|
|
12
23
|
* 🛑 Required for the note to DISPLAY. Without it `adminNotes` was hardcoded
|
|
@@ -19,4 +30,4 @@ export interface AdminStoreEditorViewProps {
|
|
|
19
30
|
*/
|
|
20
31
|
currentAdminNotes?: string;
|
|
21
32
|
}
|
|
22
|
-
export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, currentAdminNotes, }: AdminStoreEditorViewProps): React.JSX.Element;
|
|
33
|
+
export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentSellerTier, currentTrustedSeller, currentCapabilities, currentAdminNotes, }: AdminStoreEditorViewProps): React.JSX.Element;
|
|
@@ -111,13 +111,15 @@ function CapabilityPicker({ selected, onToggle, }) {
|
|
|
111
111
|
return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Span, { size: "sm", weight: "medium", color: "muted", children: ["Capabilities", _jsxs(Span, { size: "xs", weight: "normal", className: "ml-2", color: "muted", children: ["(", selected.size, " active)"] })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)]", rounded: "xl", border: "default", children: CAPABILITY_GROUPS.map((group) => (_jsxs(Details, { className: "group", children: [_jsxs(Summary, { paddingX: "x-sm", paddingY: "y-xs", weight: "semibold", color: "muted", layout: "flex", align: "center", justify: "between", className: "uppercase tracking-wide hover:bg-[var(--appkit-color-bg)] transition-colors", children: [_jsx(Span, { size: "xs", children: group.label }), _jsxs(Span, { size: "xs", weight: "normal", className: "normal-case", color: "faint", children: [group.caps.filter((c) => selected.has(c.key)).length, "/", group.caps.length] })] }), _jsx(Div, { layout: "grid", paddingY: "y-xs-tall", className: "grid-cols-2 gap-x-2 gap-y-1.5", surface: "muted", padding: "x-sm", children: group.caps.map((cap) => (_jsx(Checkbox, { label: cap.label, checked: selected.has(cap.key), onChange: () => onToggle(cap.key), wrapperClassName: "flex items-center gap-[var(--appkit-space-2)] cursor-pointer text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)]" }, cap.key))) })] }, group.label))) })] }));
|
|
112
112
|
}
|
|
113
113
|
// --- Component ---------------------------------------------------------------
|
|
114
|
-
export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, currentAdminNotes, }) {
|
|
114
|
+
export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentSellerTier, currentTrustedSeller, currentCapabilities, currentAdminNotes, }) {
|
|
115
115
|
const queryClient = useQueryClient();
|
|
116
116
|
const { showToast } = useToast();
|
|
117
117
|
const [values, setValues] = React.useState(() => ({
|
|
118
118
|
storeStatus: currentStatus ?? "pending",
|
|
119
119
|
isVerified: currentIsVerified ?? false,
|
|
120
120
|
isFeatured: currentIsFeatured ?? false,
|
|
121
|
+
sellerTier: currentSellerTier ?? "seller",
|
|
122
|
+
trustedSeller: currentTrustedSeller ?? false,
|
|
121
123
|
suspensionReason: "",
|
|
122
124
|
capabilities: currentCapabilities ?? DEFAULT_CAPABILITIES,
|
|
123
125
|
adminNotes: currentAdminNotes ?? "",
|
|
@@ -132,11 +134,22 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
132
134
|
storeStatus: currentStatus ?? "pending",
|
|
133
135
|
isVerified: currentIsVerified ?? false,
|
|
134
136
|
isFeatured: currentIsFeatured ?? false,
|
|
137
|
+
sellerTier: currentSellerTier ?? "seller",
|
|
138
|
+
trustedSeller: currentTrustedSeller ?? false,
|
|
135
139
|
suspensionReason: "",
|
|
136
140
|
capabilities: currentCapabilities ?? DEFAULT_CAPABILITIES,
|
|
137
141
|
adminNotes: currentAdminNotes ?? "",
|
|
138
142
|
});
|
|
139
|
-
}, [
|
|
143
|
+
}, [
|
|
144
|
+
open,
|
|
145
|
+
currentStatus,
|
|
146
|
+
currentIsVerified,
|
|
147
|
+
currentIsFeatured,
|
|
148
|
+
currentSellerTier,
|
|
149
|
+
currentTrustedSeller,
|
|
150
|
+
currentCapabilities,
|
|
151
|
+
currentAdminNotes,
|
|
152
|
+
]);
|
|
140
153
|
const saveMutation = useApiMutation({
|
|
141
154
|
errorMessage: "Failed to update store.",
|
|
142
155
|
mutationFn: async () => {
|
|
@@ -158,6 +171,21 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
158
171
|
adminNotes: parsed.adminNotes ?? "",
|
|
159
172
|
isFeatured: parsed.isFeatured,
|
|
160
173
|
isVerified: parsed.isVerified,
|
|
174
|
+
/*
|
|
175
|
+
* 🛑 `sellerTier` is sent ONLY when the admin actually moved it away
|
|
176
|
+
* from the stored value, because the route treats any `sellerTier` in
|
|
177
|
+
* the body as a deliberate override and PINS it against the monthly
|
|
178
|
+
* recompute. Sending it unconditionally would lock every store an
|
|
179
|
+
* admin ever opened and saved — quietly freezing the automatic tier
|
|
180
|
+
* for the whole catalogue, one unrelated edit at a time.
|
|
181
|
+
*
|
|
182
|
+
* `trustedSeller` has no such side effect and is sent every time, so
|
|
183
|
+
* it can also be cleared.
|
|
184
|
+
*/
|
|
185
|
+
...(parsed.sellerTier !== undefined && parsed.sellerTier !== (currentSellerTier ?? "seller")
|
|
186
|
+
? { sellerTier: parsed.sellerTier }
|
|
187
|
+
: {}),
|
|
188
|
+
trustedSeller: parsed.trustedSeller ?? false,
|
|
161
189
|
suspensionReason: parsed.suspensionReason || undefined,
|
|
162
190
|
capabilities: parsed.capabilities ?? [],
|
|
163
191
|
});
|
|
@@ -6,7 +6,7 @@ export function AdminStoresGuideView() {
|
|
|
6
6
|
return (_jsxs(Stack, { className: "max-w-3xl mx-auto", padding: "b-2xl", gap: "xl", children: [_jsxs(Section, { children: [_jsxs(Row, { className: "mb-2", align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-10 h-10 [background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_100%)]", align: "center", justify: "center", rounded: "xl", children: _jsx(Store, { className: "w-5 h-5 text-white" }) }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] tracking-widest", size: "sm", weight: "semibold", transform: "uppercase", children: "Admin Guide" })] }), _jsx(Heading, { level: 1, className: "text-[var(--appkit-color-text)] mb-2", mdSize: "3xl", size: "2xl", weight: "bold", children: "Stores & Sellers" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", children: "Store lifecycle, identity architecture, capabilities, and suspension on LetItRip." })] }), [
|
|
7
7
|
{
|
|
8
8
|
Icon: Store, title: "Store Lifecycle",
|
|
9
|
-
content: (_jsxs(Ul, { className: GC.listMuted, children: [_jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Creation" }), ": Seller registers \u2192 completes onboarding wizard \u2192 admin reviews \u2192 store goes ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "active" }), "."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Status values" }), ": ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "active" }), " (visible and selling), ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "suspended" }), " (hidden, not selling), ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "
|
|
9
|
+
content: (_jsxs(Ul, { className: GC.listMuted, children: [_jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Creation" }), ": Seller registers \u2192 completes onboarding wizard \u2192 admin reviews \u2192 store goes ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "active" }), "."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Status values" }), ": ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "pending" }), " (awaiting approval, not public), ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "active" }), " (visible and selling), ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "suspended" }), " (hidden, not selling), ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "rejected" }), " (application refused). Only ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "active" }), " sets ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "isPublic" }), ", and leaving it clears any earned tier immediately."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "isVerified badge" }), ": An IDENTITY check, not a performance one \u2014 grant it after reviewing the seller's submitted ID. There is no order-count or rating threshold, and it does not expire. Toggle it in AdminStoreEditorView."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "sellerTier" }), ": The EARNED tier, computed by ", _jsx(Code, { size: "xs", children: "sellerStandingRollup" }), " on the last day of each month. Setting it by hand pins it (", _jsx(Code, { size: "xs", children: "tierLockedByAdmin" }), ") so the job stops moving it \u2014 and it carries early payout with it. See the Trust guide."] })] })),
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
Icon: GitBranch, title: "Store Identity Architecture",
|
|
@@ -37,6 +37,34 @@ export function AdminStoresView({ children, ...props }) {
|
|
|
37
37
|
void queryClient.invalidateQueries({ queryKey: ["admin", "stores", "listing"] });
|
|
38
38
|
},
|
|
39
39
|
});
|
|
40
|
+
/*
|
|
41
|
+
* 🛑 Approve/reject — the step that had no affordance anywhere.
|
|
42
|
+
*
|
|
43
|
+
* A store is born `pending` + `isPublic: false`, and this view offered only
|
|
44
|
+
* verify and suspend, so a real applicant's store could never be published.
|
|
45
|
+
* Both mutations go through the same STORE PATCH the rest of this view uses,
|
|
46
|
+
* which dispatches to `storeRepository.setStatus` and is therefore the only
|
|
47
|
+
* path that keeps `status` and `isPublic` in step. Do not reach for
|
|
48
|
+
* `adminUpdateStoreStatus`: it writes the user-level `storeStatus` mirror
|
|
49
|
+
* and never touches the store, so it would report success and publish
|
|
50
|
+
* nothing.
|
|
51
|
+
*/
|
|
52
|
+
const approveStore = useApiMutation({
|
|
53
|
+
errorMessage: "Failed to approve store.",
|
|
54
|
+
mutationFn: (storeId) => apiClient.patch(ADMIN_ENDPOINTS.STORE_BY_ID(storeId), { storeStatus: "active" }),
|
|
55
|
+
onSuccess: () => {
|
|
56
|
+
toast.showToast("Store approved — it is now public.", "success");
|
|
57
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "stores", "listing"] });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const rejectStore = useApiMutation({
|
|
61
|
+
errorMessage: "Failed to reject store.",
|
|
62
|
+
mutationFn: (storeId) => apiClient.patch(ADMIN_ENDPOINTS.STORE_BY_ID(storeId), { storeStatus: "rejected" }),
|
|
63
|
+
onSuccess: () => {
|
|
64
|
+
toast.showToast("Store application rejected.", "success");
|
|
65
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "stores", "listing"] });
|
|
66
|
+
},
|
|
67
|
+
});
|
|
40
68
|
if (React.Children.count(children) > 0) {
|
|
41
69
|
return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
|
|
42
70
|
}
|
|
@@ -108,7 +136,10 @@ export function AdminStoresView({ children, ...props }) {
|
|
|
108
136
|
},
|
|
109
137
|
})),
|
|
110
138
|
renderRowActions: (row) => {
|
|
111
|
-
const
|
|
139
|
+
const status = row.status?.toLowerCase();
|
|
140
|
+
const isSuspended = status === "suspended";
|
|
141
|
+
const isPending = status === "pending";
|
|
142
|
+
const isActive = status === "active";
|
|
112
143
|
const isVerified = Boolean(row._raw?.isVerified);
|
|
113
144
|
return (_jsx(RowActionMenu, { actions: [
|
|
114
145
|
{
|
|
@@ -119,6 +150,24 @@ export function AdminStoresView({ children, ...props }) {
|
|
|
119
150
|
label: "Open full page",
|
|
120
151
|
onClick: () => router.push(String(ROUTES.ADMIN.STORE_DETAIL(row.id))),
|
|
121
152
|
},
|
|
153
|
+
/*
|
|
154
|
+
* Approve is offered for anything not already active — that
|
|
155
|
+
* deliberately includes `rejected` and `suspended`, which is how a
|
|
156
|
+
* decision gets reversed without hunting for a second control.
|
|
157
|
+
* Reject is offered only while `pending`: rejecting a store that
|
|
158
|
+
* has been live and has orders is a suspension, not a rejection,
|
|
159
|
+
* and conflating them would lose that distinction in the audit log.
|
|
160
|
+
*/
|
|
161
|
+
{
|
|
162
|
+
label: ACTIONS.ADMIN["approve-store"].label,
|
|
163
|
+
onClick: () => approveStore.mutate(row.id),
|
|
164
|
+
disabled: isActive || approveStore.isPending,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
label: ACTIONS.ADMIN["reject-store"].label,
|
|
168
|
+
onClick: () => rejectStore.mutate(row.id),
|
|
169
|
+
disabled: !isPending || rejectStore.isPending,
|
|
170
|
+
},
|
|
122
171
|
{
|
|
123
172
|
label: ACTIONS.ADMIN["verify-store"].label,
|
|
124
173
|
onClick: () => verifyStore.mutate(row.id),
|
|
@@ -133,7 +182,15 @@ export function AdminStoresView({ children, ...props }) {
|
|
|
133
182
|
},
|
|
134
183
|
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_STORE_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) })),
|
|
135
184
|
};
|
|
136
|
-
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminStoreEditorView, { open: Boolean(editorRow), onClose: () => setEditorRow(null), storeId: editorRow?.id, storeName: editorRow?.primary, currentStatus: editorRow?.status?.toLowerCase(), currentIsVerified: Boolean(editorRow?._raw?.isVerified), currentIsFeatured: Boolean(editorRow?._raw?.isFeatured),
|
|
185
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminStoreEditorView, { open: Boolean(editorRow), onClose: () => setEditorRow(null), storeId: editorRow?.id, storeName: editorRow?.primary, currentStatus: editorRow?.status?.toLowerCase(), currentIsVerified: Boolean(editorRow?._raw?.isVerified), currentIsFeatured: Boolean(editorRow?._raw?.isFeatured),
|
|
186
|
+
/*
|
|
187
|
+
* Both now travel from the list route's serializer through to the
|
|
188
|
+
* editor. `trustedSeller` was ALREADY emitted by the route and simply
|
|
189
|
+
* never passed down — the API accepted the field, nothing could send
|
|
190
|
+
* it, and the control did not exist. Root Cause #38's shape at the
|
|
191
|
+
* prop, which is where it hides best.
|
|
192
|
+
*/
|
|
193
|
+
currentSellerTier: editorRow?._raw?.sellerTier === "trusted" ? "trusted" : "seller", currentTrustedSeller: Boolean(editorRow?._raw?.trustedSeller), currentCapabilities: Array.isArray(editorRow?._raw?.capabilities)
|
|
137
194
|
? editorRow._raw.capabilities
|
|
138
195
|
: undefined,
|
|
139
196
|
/*
|
|
@@ -3,8 +3,18 @@ import { Shield, Ban, AlertTriangle, Headphones, Flag, ClipboardList } from "luc
|
|
|
3
3
|
import { Alert, Code, Div, Heading, Li, Row, Section, Span, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, Ul } from "../../../ui/index.js";
|
|
4
4
|
import { GC } from "../../_guide-cls.js";
|
|
5
5
|
const STILL_ALLOWED = "✓ Still allowed";
|
|
6
|
+
/** Named because it is used on three rows in the seller-trust table. */
|
|
7
|
+
const ROW_BORDER = "border-b border-[var(--appkit-color-border)]";
|
|
6
8
|
export function AdminTrustGuideView() {
|
|
7
9
|
return (_jsxs(Stack, { className: "max-w-3xl mx-auto", padding: "b-2xl", gap: "xl", children: [_jsxs(Section, { children: [_jsxs(Row, { className: "mb-2", align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-10 h-10 [background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_100%)]", align: "center", justify: "center", rounded: "xl", children: _jsx(Shield, { className: "w-5 h-5 text-white" }) }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] tracking-widest", size: "sm", weight: "semibold", transform: "uppercase", children: "Admin Guide" })] }), _jsx(Heading, { level: 1, className: "text-[var(--appkit-color-text)] mb-2", mdSize: "3xl", size: "2xl", weight: "bold", children: "Trust & Safety" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", children: "Bans, scam registry, support tickets, moderation, and reports on LetItRip." })] }), [
|
|
10
|
+
{
|
|
11
|
+
Icon: Shield, title: "Seller trust: three fields, two of them visible",
|
|
12
|
+
content: (_jsxs(_Fragment, { children: [_jsx(Div, { overflow: "x-auto", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsx(Tr, { className: ROW_BORDER, children: ["Field", "Public?", "What it means", "Who sets it"].map((h) => (_jsx(Th, { align: "left", paddingSide: "pr-md", className: "text-[var(--appkit-color-text)]", padding: "xs-tall", weight: "semibold", children: h }, h))) }) }), _jsx(Tbody, { size: "sm", color: "muted", children: [
|
|
13
|
+
["isVerified", "Yes — badge", "We checked WHO they are (ID review). No volume threshold; does not expire.", "Admin, by hand"],
|
|
14
|
+
["sellerTier", "Yes — badge", "EARNED from the last 90 days of trading. Recomputed monthly.", "sellerStandingRollup, or an admin override that pins it"],
|
|
15
|
+
["trustedSeller", "NO — never", "We pay this store at SHIPPING, not at delivery. A risk transfer.", "The tier carries it; an admin can also set it alone"],
|
|
16
|
+
].map((row) => (_jsx(Tr, { className: ROW_BORDER, children: row.map((cell, i) => (_jsx(Td, { paddingSide: "pr-md", padding: "xs-tall", children: i === 0 ? _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: cell }) : cell }, i))) }, row[0]))) })] }) }), _jsxs(Alert, { variant: "warning", title: "trustedSeller moves money", children: ["It means the platform pays before the parcel lands, so we carry a lost parcel and a non-delivery dispute with the money already gone. It is never shown to buyers \u2014 a store does not get to display that we trust it. Every change writes a ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "STORE_TRUST_CHANGE" }), " ", "audit row naming who accepted the risk, including when the monthly job is the one that decided."] }), _jsxs(Ul, { spacing: "tight", indent: "lg", children: [_jsxs(Li, { className: GC.listDiscMuted, children: [_jsx(Span, { weight: "bold", children: "Setting sellerTier by hand PINS it." }), " It writes", " ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "tierLockedByAdmin" }), ", and the monthly job then skips that store entirely \u2014 it still refreshes their figures, it just stops moving the tier. Without the pin an override would silently revert at the next month-end."] }), _jsxs(Li, { className: GC.listDiscMuted, children: [_jsx(Span, { weight: "bold", children: "Suspending a store revokes trust immediately" }), ", not at month end. Monthly caching would otherwise keep paying a suspended store early for up to 31 days. Re-activating does NOT re-grant it; the next month-end decides."] }), _jsxs(Li, { className: GC.listDiscMuted, children: [_jsx(Span, { weight: "bold", children: "The criteria live in one place" }), " \u2014", " ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "SELLER_STANDING_DEFAULTS" }), ", admin-tunable under Site Settings. The seller's dashboard panel, the public seller guide and the buyer-facing badge page all render from it, so they cannot state different numbers from the job that grants it."] }), _jsxs(Li, { className: GC.listDiscMuted, children: [_jsx(Span, { weight: "bold", children: "Self-bought and test orders do not count" }), ", and both a distinct-buyer and a distinct-address floor must be cleared \u2014 the tier releases money early, so buying from yourself 25 times is the attack it has to survive."] })] })] })),
|
|
17
|
+
},
|
|
8
18
|
{
|
|
9
19
|
Icon: Ban, title: "Soft Ban vs Hard Ban",
|
|
10
20
|
content: (_jsxs(_Fragment, { children: [_jsx(Div, { overflow: "x-auto", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { children: _jsx(Tr, { className: "border-b border-[var(--appkit-color-border)]", children: ["", "Soft Ban", "Hard Ban"].map((h) => (_jsx(Th, { align: "left", paddingSide: "pr-md", className: "text-[var(--appkit-color-text)]", padding: "xs-tall", weight: "semibold", children: h }, h))) }) }), _jsx(Tbody, { size: "sm", color: "muted", children: [
|
|
@@ -11,6 +11,8 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
|
|
|
11
11
|
storeStatus: z.ZodEnum<[StoreStatus, ...StoreStatus[]]>;
|
|
12
12
|
isVerified: z.ZodOptional<z.ZodBoolean>;
|
|
13
13
|
isFeatured: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
sellerTier: z.ZodOptional<z.ZodEnum<["seller", "trusted"]>>;
|
|
15
|
+
trustedSeller: z.ZodOptional<z.ZodBoolean>;
|
|
14
16
|
suspensionReason: z.ZodOptional<z.ZodString>;
|
|
15
17
|
capabilities: z.ZodOptional<z.ZodArray<z.ZodEnum<[string, ...string[]]>, "many">>;
|
|
16
18
|
adminNotes: z.ZodOptional<z.ZodString>;
|
|
@@ -21,6 +23,8 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
|
|
|
21
23
|
adminNotes?: string | undefined;
|
|
22
24
|
suspensionReason?: string | undefined;
|
|
23
25
|
capabilities?: string[] | undefined;
|
|
26
|
+
sellerTier?: "seller" | "trusted" | undefined;
|
|
27
|
+
trustedSeller?: boolean | undefined;
|
|
24
28
|
}, {
|
|
25
29
|
storeStatus: StoreStatus;
|
|
26
30
|
isVerified?: boolean | undefined;
|
|
@@ -28,6 +32,8 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
|
|
|
28
32
|
adminNotes?: string | undefined;
|
|
29
33
|
suspensionReason?: string | undefined;
|
|
30
34
|
capabilities?: string[] | undefined;
|
|
35
|
+
sellerTier?: "seller" | "trusted" | undefined;
|
|
36
|
+
trustedSeller?: boolean | undefined;
|
|
31
37
|
}>, {
|
|
32
38
|
storeStatus: StoreStatus;
|
|
33
39
|
isVerified?: boolean | undefined;
|
|
@@ -35,6 +41,8 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
|
|
|
35
41
|
adminNotes?: string | undefined;
|
|
36
42
|
suspensionReason?: string | undefined;
|
|
37
43
|
capabilities?: string[] | undefined;
|
|
44
|
+
sellerTier?: "seller" | "trusted" | undefined;
|
|
45
|
+
trustedSeller?: boolean | undefined;
|
|
38
46
|
}, {
|
|
39
47
|
storeStatus: StoreStatus;
|
|
40
48
|
isVerified?: boolean | undefined;
|
|
@@ -42,6 +50,8 @@ export declare const adminStoreUpdateSchema: z.ZodEffects<z.ZodObject<{
|
|
|
42
50
|
adminNotes?: string | undefined;
|
|
43
51
|
suspensionReason?: string | undefined;
|
|
44
52
|
capabilities?: string[] | undefined;
|
|
53
|
+
sellerTier?: "seller" | "trusted" | undefined;
|
|
54
|
+
trustedSeller?: boolean | undefined;
|
|
45
55
|
}>;
|
|
46
56
|
export type AdminStoreUpdateValues = z.infer<typeof adminStoreUpdateSchema>;
|
|
47
57
|
/**
|
|
@@ -75,6 +75,40 @@ export const adminStoreUpdateSchema = z
|
|
|
75
75
|
isFeatured: annotate(z.boolean().optional(), {
|
|
76
76
|
section: "standing", order: 3, row: "quarter",
|
|
77
77
|
}),
|
|
78
|
+
/*
|
|
79
|
+
* The EARNED tier, overridable by hand.
|
|
80
|
+
*
|
|
81
|
+
* 🛑 Setting it here PINS it — the monthly `sellerStandingRollup` skips a
|
|
82
|
+
* store whose `standing.tierLockedByAdmin` is true. Without the pin an
|
|
83
|
+
* override would silently revert at the next month-end, which is worse
|
|
84
|
+
* than no override: an admin grants a tier, sees it work, and finds it
|
|
85
|
+
* undone weeks later with nothing saying why.
|
|
86
|
+
*
|
|
87
|
+
* 🛑 It carries EARLY PAYOUT with it (see `trustedSeller` below) — the
|
|
88
|
+
* owner's decision is that reaching the tier grants pay-at-shipping. This
|
|
89
|
+
* control therefore moves money, which is why the help text says so.
|
|
90
|
+
*/
|
|
91
|
+
sellerTier: annotate(z.enum(["seller", "trusted"]).optional(), {
|
|
92
|
+
section: "standing", order: 5, row: "pair", kind: "select",
|
|
93
|
+
help: "Normally earned automatically at each month-end. Setting it here pins it and also switches payouts to start at shipping.",
|
|
94
|
+
}),
|
|
95
|
+
/*
|
|
96
|
+
* 🛑 This field has existed and been accepted by the API since it was
|
|
97
|
+
* added, with NO control anywhere in the admin UI — so the only way to set
|
|
98
|
+
* it was a hand-written PATCH. A flag that moves money and cannot be
|
|
99
|
+
* operated is worse than one that does not exist, because the code reads
|
|
100
|
+
* as though an admin is in control of it.
|
|
101
|
+
*
|
|
102
|
+
* Separate from `sellerTier` on purpose: the tier is the public, earned
|
|
103
|
+
* thing, and this is the private risk posture. An admin occasionally needs
|
|
104
|
+
* one without the other — early payout for a high-volume store that has
|
|
105
|
+
* not met the rating bar, or a badge for the platform's own store without
|
|
106
|
+
* changing its payout timing.
|
|
107
|
+
*/
|
|
108
|
+
trustedSeller: annotate(z.boolean().optional(), {
|
|
109
|
+
section: "standing", order: 6, row: "quarter",
|
|
110
|
+
help: "Pay this store when it ships, not when the parcel is delivered. The platform carries a lost parcel with the money already gone. Never shown to buyers.",
|
|
111
|
+
}),
|
|
78
112
|
/*
|
|
79
113
|
* `when` mirrors the superRefine below, which is what stops the two from
|
|
80
114
|
* disagreeing. The page used to keep this textarea live at all times and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { blogRepository } from "../../../repositories/index.js";
|
|
3
|
-
import { Container,
|
|
3
|
+
import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
|
|
4
4
|
import { AdSlot } from "../../homepage/components/AdSlot.js";
|
|
5
5
|
import { BlogIndexListing } from "./BlogIndexListing.js";
|
|
6
6
|
import { BLOG_DEFAULT_PUBLIC_SORT } from "../schemas/firestore.js";
|
|
@@ -41,5 +41,5 @@ export async function BlogIndexPageView({ searchParams = {} }) {
|
|
|
41
41
|
},
|
|
42
42
|
}
|
|
43
43
|
: undefined;
|
|
44
|
-
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(
|
|
44
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "From the shop floor", title: "Blog", description: "Buying guides, release notes and what we have learned importing and grading Beyblades.", meta: initialData?.meta?.total ? `${initialData.meta.total} articles` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(BlogIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
45
45
|
}
|