@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store-creation form — step 2 of becoming a seller.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 Why this exists. `createStoreAction` has been in the codebase with **no
|
|
5
|
+
* caller and no API route**, there was no `/store/create` page, and
|
|
6
|
+
* `/user/become-seller` collected ZERO fields — it was marketing copy and one
|
|
7
|
+
* button that flipped `role` to `seller` and set `storeStatus: "pending"`. So a
|
|
8
|
+
* real applicant became a seller and then could not open a storefront by any
|
|
9
|
+
* route in the product. Only `role: "tester"` got a store, via the auto-approve
|
|
10
|
+
* branch in `becomeSeller`. This form is the missing step.
|
|
11
|
+
*
|
|
12
|
+
* 🛑 `<SectionForm>`, not a bare `<Form>`. `audit-form-sectionised` is
|
|
13
|
+
* strict-zero on multi-field forms that skip the section engine, and the engine
|
|
14
|
+
* is what supplies the pinned mobile action bar, the per-section error badges
|
|
15
|
+
* and the schema-driven field rendering every other form in the app has. The
|
|
16
|
+
* sections come from `storeCreateSchema`'s own annotations, so the fields, their
|
|
17
|
+
* labels and their validation have exactly one definition.
|
|
18
|
+
*
|
|
19
|
+
* 🛑 `<SectionForm>` does NOT mount a `FormShellContext` — it consumes whichever
|
|
20
|
+
* one the owning view provides and tolerates none, which means a view rendering
|
|
21
|
+
* only `<SectionForm>` gets section badges and no readable message anywhere.
|
|
22
|
+
* This component mounts the context AND renders `<FormErrorSummary>` itself.
|
|
23
|
+
*
|
|
24
|
+
* The submit handler is injected rather than imported: the server action lives
|
|
25
|
+
* in the consumer (`src/actions/seller.actions.ts`), and appkit must not reach
|
|
26
|
+
* into it. Same slot shape as `StoreCreateProductShell`'s `onSave`/`onPublish`.
|
|
27
|
+
*/
|
|
28
|
+
import React from "react";
|
|
29
|
+
import { type StoreCreateInput } from "../schemas/store-create";
|
|
30
|
+
export interface StoreCreateFormResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** Stable error CODE, never a server message — see the submit handler. */
|
|
33
|
+
code?: string;
|
|
34
|
+
/** Which field the failure belongs to, when the server can say. */
|
|
35
|
+
field?: keyof StoreCreateInput;
|
|
36
|
+
}
|
|
37
|
+
export interface StoreCreateFormProps {
|
|
38
|
+
/**
|
|
39
|
+
* Performs the create. Resolves `{ ok: true }` on success, or
|
|
40
|
+
* `{ ok: false, code }` with a mapped error code.
|
|
41
|
+
*/
|
|
42
|
+
onSubmit: (input: StoreCreateInput) => Promise<StoreCreateFormResult>;
|
|
43
|
+
/** Called after a successful create, for the consumer's redirect/toast. */
|
|
44
|
+
onCreated?: () => void;
|
|
45
|
+
}
|
|
46
|
+
export declare function StoreCreateForm({ onSubmit, onCreated }: StoreCreateFormProps): React.JSX.Element;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The store-creation form — step 2 of becoming a seller.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 Why this exists. `createStoreAction` has been in the codebase with **no
|
|
7
|
+
* caller and no API route**, there was no `/store/create` page, and
|
|
8
|
+
* `/user/become-seller` collected ZERO fields — it was marketing copy and one
|
|
9
|
+
* button that flipped `role` to `seller` and set `storeStatus: "pending"`. So a
|
|
10
|
+
* real applicant became a seller and then could not open a storefront by any
|
|
11
|
+
* route in the product. Only `role: "tester"` got a store, via the auto-approve
|
|
12
|
+
* branch in `becomeSeller`. This form is the missing step.
|
|
13
|
+
*
|
|
14
|
+
* 🛑 `<SectionForm>`, not a bare `<Form>`. `audit-form-sectionised` is
|
|
15
|
+
* strict-zero on multi-field forms that skip the section engine, and the engine
|
|
16
|
+
* is what supplies the pinned mobile action bar, the per-section error badges
|
|
17
|
+
* and the schema-driven field rendering every other form in the app has. The
|
|
18
|
+
* sections come from `storeCreateSchema`'s own annotations, so the fields, their
|
|
19
|
+
* labels and their validation have exactly one definition.
|
|
20
|
+
*
|
|
21
|
+
* 🛑 `<SectionForm>` does NOT mount a `FormShellContext` — it consumes whichever
|
|
22
|
+
* one the owning view provides and tolerates none, which means a view rendering
|
|
23
|
+
* only `<SectionForm>` gets section badges and no readable message anywhere.
|
|
24
|
+
* This component mounts the context AND renders `<FormErrorSummary>` itself.
|
|
25
|
+
*
|
|
26
|
+
* The submit handler is injected rather than imported: the server action lives
|
|
27
|
+
* in the consumer (`src/actions/seller.actions.ts`), and appkit must not reach
|
|
28
|
+
* into it. Same slot shape as `StoreCreateProductShell`'s `onSave`/`onPublish`.
|
|
29
|
+
*/
|
|
30
|
+
import React from "react";
|
|
31
|
+
import { Div, Stack, Text } from "../../../ui/index.js";
|
|
32
|
+
import { FormShellContext, FormErrorSummary, useFormShellState } from "../../../ui/forms/index.js";
|
|
33
|
+
import { SectionForm, buildSectionsFromSchema, useSectionFormNav } from "../../shell/index.js";
|
|
34
|
+
import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
|
|
35
|
+
import { loadCategoryOptions } from "../../categories/utils/load-category-options.js";
|
|
36
|
+
import { storeCreateSchema } from "../schemas/store-create.js";
|
|
37
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
38
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
39
|
+
import { useTranslations } from "next-intl";
|
|
40
|
+
export function StoreCreateForm({ onSubmit, onCreated }) {
|
|
41
|
+
const t = useTranslations();
|
|
42
|
+
const [values, setValues] = React.useState({
|
|
43
|
+
storeName: "",
|
|
44
|
+
storeDescription: "",
|
|
45
|
+
storeCategory: "",
|
|
46
|
+
});
|
|
47
|
+
const [isPending, setIsPending] = React.useState(false);
|
|
48
|
+
const { shellCtx, setFieldError, clearErrors, markSubmitAttempted } = useFormShellState(storeCreateSchema);
|
|
49
|
+
const handleChange = React.useCallback((partial) => {
|
|
50
|
+
setValues((prev) => Object.assign({}, prev, partial));
|
|
51
|
+
}, []);
|
|
52
|
+
/*
|
|
53
|
+
* 🛑 `PaginatedSelect`, not the inferred text input: there are over a
|
|
54
|
+
* thousand categories, so a flat list is unscannable and the >5-options rule
|
|
55
|
+
* applies. `loadCategoryOptions` passes `listingOnly=true`, without which
|
|
56
|
+
* this offers brands and bundles as categories.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 `value={… || null}` — it renders the placeholder only on `value == null`,
|
|
59
|
+
* so an empty string paints a BLANK trigger with no prompt and no hint that
|
|
60
|
+
* it is a control.
|
|
61
|
+
*/
|
|
62
|
+
const sections = React.useMemo(() => buildSectionsFromSchema(storeCreateSchema, {
|
|
63
|
+
renderers: {
|
|
64
|
+
storeCategory: ({ values: v, onChange }) => (_jsx(PaginatedSelect, { name: "storeCategory", value: v.storeCategory || null, onChange: (val) => onChange({ storeCategory: val ?? "" }), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026 (optional)", searchPlaceholder: "Type a category name\u2026", noResultsText: "No categories found", ariaLabel: "Main category" })),
|
|
65
|
+
},
|
|
66
|
+
}), []);
|
|
67
|
+
// Declared AFTER `sections`, because it takes them — it derives the ordered
|
|
68
|
+
// open/closed set from the sections that are currently visible.
|
|
69
|
+
const nav = useSectionFormNav(sections, values);
|
|
70
|
+
async function submit() {
|
|
71
|
+
clearErrors();
|
|
72
|
+
markSubmitAttempted();
|
|
73
|
+
const parsed = storeCreateSchema.safeParse(values);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
for (const issue of parsed.error.issues) {
|
|
76
|
+
setFieldError(String(issue.path[0]), issue.message);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setIsPending(true);
|
|
81
|
+
try {
|
|
82
|
+
const result = await onSubmit(parsed.data);
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
onCreated?.();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
* 🛑 `toUserMessage(code, t)`, never the server's own message. A
|
|
89
|
+
* raw-message fallback is how a Node MODULE_NOT_FOUND with a full
|
|
90
|
+
* require stack got rendered inside a buyer-facing modal (Root Cause
|
|
91
|
+
* #86). `toUserMessage` terminates at a generic string and never
|
|
92
|
+
* returns server text.
|
|
93
|
+
*/
|
|
94
|
+
setFieldError(result.field ?? "storeName", toUserMessage(result.code ?? "UNKNOWN", t));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Every `NormalizedError` variant carries a non-optional `code`, so
|
|
98
|
+
// there is nothing to default here.
|
|
99
|
+
const e = normalizeError(err);
|
|
100
|
+
setFieldError("storeName", toUserMessage(e.code, t));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
setIsPending(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Div, { children: [_jsxs(Stack, { gap: "xs", className: "mb-4", children: [_jsx(Text, { size: "lg", weight: "semibold", children: "Open your storefront" }), _jsx(Text, { size: "sm", color: "muted", children: "You can change all of this later. Your store stays private until we review it." })] }), _jsx(SectionForm, { sections: sections, values: values, onChange: handleChange, onSubmit: submit, schema: storeCreateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, isLoading: isPending, submitLabel: "Create my store" }), _jsx(FormErrorSummary, {})] }) }));
|
|
107
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Package, ShoppingBag, Wallet, Settings, Shield, Headphones, CheckCircle2, Circle, BookOpen, } from "lucide-react";
|
|
3
3
|
import { Div, Grid, Heading, Progress, Row, Section, Stack, Text, TextLink } from "../../../ui/index.js";
|
|
4
|
+
import { SellerStandingPanel } from "./SellerStandingPanel.js";
|
|
4
5
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
5
6
|
const CLS_CHECK_ICON = "w-5 h-5 flex-shrink-0 text-emerald-500";
|
|
6
7
|
const CLS_SUCCESS_BOX = "rounded-xl border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 px-[var(--appkit-space-5)] py-[var(--appkit-space-4)]";
|
|
@@ -65,10 +66,22 @@ function buildChecklist(store) {
|
|
|
65
66
|
done: (store?.shippingConfig?.providers?.length ?? 0) > 0,
|
|
66
67
|
href: String(ROUTES.STORE.SHIPPING_CONFIGS),
|
|
67
68
|
},
|
|
69
|
+
/*
|
|
70
|
+
* 🛑 Was "Request verified badge" → a SUPPORT TICKET, and the cast beside
|
|
71
|
+
* it (`store as StoreDocument & { isVerified?: boolean }`) was the tell
|
|
72
|
+
* that `isVerified` was not even on the type. Two things were wrong: the
|
|
73
|
+
* badge was not something to request (an admin grants it after checking
|
|
74
|
+
* identity, and no ticket category asked for it), and the step it pointed
|
|
75
|
+
* at could not complete itself.
|
|
76
|
+
*
|
|
77
|
+
* The earned tier is a real step with a real destination — the
|
|
78
|
+
* capabilities guide, where the criteria and the seller's own figures are
|
|
79
|
+
* both rendered from `SELLER_STANDING_DEFAULTS`.
|
|
80
|
+
*/
|
|
68
81
|
{
|
|
69
|
-
label: "
|
|
70
|
-
done:
|
|
71
|
-
href: String(ROUTES.
|
|
82
|
+
label: "Earn Trusted Seller status",
|
|
83
|
+
done: store?.sellerTier === "trusted",
|
|
84
|
+
href: String(ROUTES.STORE.GUIDE_CAPABILITIES),
|
|
72
85
|
},
|
|
73
86
|
];
|
|
74
87
|
}
|
|
@@ -77,7 +90,7 @@ export function StoreGuideHubView({ store }) {
|
|
|
77
90
|
const storeName = store?.storeName ?? "Your Store";
|
|
78
91
|
const steps = buildChecklist(store);
|
|
79
92
|
const completedCount = steps.filter((s) => s.done).length;
|
|
80
|
-
return (_jsxs(Stack, { gap: "xl", padding: "b-2xl", children: [_jsx(Section, { overflow: "hidden", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", rounded: "2xl", shadow: "sm", children: _jsxs(Div, { paddingX: "x-md-xl", paddingY: "y-xl", className: "[background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_60%,var(--appkit-color-secondary-400)_100%)]", children: [_jsxs(Row, { className: "mb-3", align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-10 h-10", surface: "default", align: "center", justify: "center", rounded: "xl", children: _jsx(BookOpen, { className: "w-5 h-5 text-white" }) }), _jsx(Text, { color: "inverse-muted", className: "tracking-widest", size: "sm", weight: "semibold", transform: "uppercase", children: "Seller Guide" })] }), _jsx(Heading, { color: "inverse", level: 1, className: "mb-2", mdSize: "3xl", size: "2xl", weight: "bold", children: storeName }), _jsx(Text, { color: "inverse-muted", size: "base", children: "Everything you need to know about selling on LetItRip." })] }) }), _jsxs(Div, { layout: "grid", gap: "8", className: "grid-cols-1 lg:grid-cols-3", children: [_jsxs(Stack, { className: "lg:col-span-2", gap: "md", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text)]", size: "lg", weight: "semibold", children: "Guides" }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: GUIDE_CARDS.map(({ Icon, title, description, href }) => (_jsxs(TextLink, { variant: "bare", href: href, rounded: "xl", layout: "flex-col", gap: "md", paddingX: "lg", paddingY: "lg", shadow: "hover-md", className: "group border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] hover:border-[var(--appkit-color-primary)] transition-all", children: [_jsxs(Row, { align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-9 h-9 [background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_100%)]", align: "center", justify: "center", rounded: "lg", children: _jsx(Icon, { className: "w-4 h-4 text-white" }) }), _jsx(Text, { className: "text-[var(--appkit-color-text)] group-hover:text-[var(--appkit-color-primary)] transition-colors", weight: "semibold", children: title })] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] leading-relaxed", size: "sm", children: description }), _jsx(Text, { className: "text-[var(--appkit-color-primary)] mt-auto", size: "xs", weight: "medium", children: "Read guide \u2192" })] }, title))) })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text)]", size: "lg", weight: "semibold", children: "Getting started" }), _jsxs(Div, { overflow: "hidden", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", rounded: "xl", children: [_jsxs(Div, { className: "px-[1.25rem] pt-[1.25rem] border-b border-[var(--appkit-color-border)]", padding: "b-md", children: [_jsxs(Row, { className: "mb-2", align: "center", justify: "between", children: [_jsxs(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: [completedCount, " of ", steps.length, " complete"] }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: [Math.round((completedCount / steps.length) * 100), "%"] })] }), _jsx(Progress, { value: completedCount, max: steps.length, size: "sm", variant: "gradient" })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)]", children: steps.map(({ label, done, href }) => (_jsxs(TextLink, { variant: "bare", href: done ? "#" : href, "aria-disabled": done, layout: "flex", align: "center", gap: "sm", paddingX: "lg", paddingY: "sm-tall", size: "sm", className: `transition-colors ${done
|
|
93
|
+
return (_jsxs(Stack, { gap: "xl", padding: "b-2xl", children: [_jsx(Section, { overflow: "hidden", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", rounded: "2xl", shadow: "sm", children: _jsxs(Div, { paddingX: "x-md-xl", paddingY: "y-xl", className: "[background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_60%,var(--appkit-color-secondary-400)_100%)]", children: [_jsxs(Row, { className: "mb-3", align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-10 h-10", surface: "default", align: "center", justify: "center", rounded: "xl", children: _jsx(BookOpen, { className: "w-5 h-5 text-white" }) }), _jsx(Text, { color: "inverse-muted", className: "tracking-widest", size: "sm", weight: "semibold", transform: "uppercase", children: "Seller Guide" })] }), _jsx(Heading, { color: "inverse", level: 1, className: "mb-2", mdSize: "3xl", size: "2xl", weight: "bold", children: storeName }), _jsx(Text, { color: "inverse-muted", size: "base", children: "Everything you need to know about selling on LetItRip." })] }) }), _jsxs(Div, { layout: "grid", gap: "8", className: "grid-cols-1 lg:grid-cols-3", children: [_jsxs(Stack, { className: "lg:col-span-2", gap: "md", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text)]", size: "lg", weight: "semibold", children: "Guides" }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: GUIDE_CARDS.map(({ Icon, title, description, href }) => (_jsxs(TextLink, { variant: "bare", href: href, rounded: "xl", layout: "flex-col", gap: "md", paddingX: "lg", paddingY: "lg", shadow: "hover-md", className: "group border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] hover:border-[var(--appkit-color-primary)] transition-all", children: [_jsxs(Row, { align: "center", gap: "3", children: [_jsx(Row, { className: "flex-shrink-0 w-9 h-9 [background:linear-gradient(135deg,var(--appkit-color-primary-700)_0%,var(--appkit-color-cobalt)_100%)]", align: "center", justify: "center", rounded: "lg", children: _jsx(Icon, { className: "w-4 h-4 text-white" }) }), _jsx(Text, { className: "text-[var(--appkit-color-text)] group-hover:text-[var(--appkit-color-primary)] transition-colors", weight: "semibold", children: title })] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] leading-relaxed", size: "sm", children: description }), _jsx(Text, { className: "text-[var(--appkit-color-primary)] mt-auto", size: "xs", weight: "medium", children: "Read guide \u2192" })] }, title))) }), _jsx(SellerStandingPanel, { standing: store?.standing, tier: store?.sellerTier, showProgress: true, heading: "Your Trusted Seller standing" })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text)]", size: "lg", weight: "semibold", children: "Getting started" }), _jsxs(Div, { overflow: "hidden", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", rounded: "xl", children: [_jsxs(Div, { className: "px-[1.25rem] pt-[1.25rem] border-b border-[var(--appkit-color-border)]", padding: "b-md", children: [_jsxs(Row, { className: "mb-2", align: "center", justify: "between", children: [_jsxs(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: [completedCount, " of ", steps.length, " complete"] }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: [Math.round((completedCount / steps.length) * 100), "%"] })] }), _jsx(Progress, { value: completedCount, max: steps.length, size: "sm", variant: "gradient" })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)]", children: steps.map(({ label, done, href }) => (_jsxs(TextLink, { variant: "bare", href: done ? "#" : href, "aria-disabled": done, layout: "flex", align: "center", gap: "sm", paddingX: "lg", paddingY: "sm-tall", size: "sm", className: `transition-colors ${done
|
|
81
94
|
? "cursor-default"
|
|
82
95
|
: "hover:bg-[var(--appkit-color-border)]/30"}`, children: [done ? (_jsx(CheckCircle2, { className: CLS_CHECK_ICON })) : (_jsx(Circle, { className: "w-5 h-5 flex-shrink-0 text-[var(--appkit-color-text-muted)]" })), _jsx(Text, { className: `leading-snug ${done
|
|
83
96
|
? "line-through text-[var(--appkit-color-text-muted)]"
|
|
@@ -4,6 +4,7 @@ import { MediaImage } from "../../media/MediaImage.js";
|
|
|
4
4
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
5
5
|
import { ShareButton } from "../../products/components/ShareButton.js";
|
|
6
6
|
import { SellerTrustBadge } from "../../scams/components/SellerTrustBadge.js";
|
|
7
|
+
import { SellerTierBadge } from "./SellerTierBadge.js";
|
|
7
8
|
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
8
9
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
9
10
|
const __O = {
|
|
@@ -14,7 +15,7 @@ const CLS_STARS = "inline-flex items-center gap-[var(--appkit-space-1)] text-war
|
|
|
14
15
|
const CLS_FOLLOW_BTN = "rounded-lg border border-warning px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-warning hover:bg-warning-surface transition-colors";
|
|
15
16
|
const CLS_WARN_BANNER = "mt-3 rounded-lg bg-warning-surface dark:bg-warning-surface border border-warning dark:border-warning px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-warning dark:text-warning";
|
|
16
17
|
export function StoreHeader({ store, labels = {}, onFollow, className = "", trust, }) {
|
|
17
|
-
return (_jsxs(Section, { surface: "default", border: "bottom", className: className, children: [store.storeBannerURL && (_jsx(Div, { className: `relative h-40 md:h-56 ${__O.hidden} bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)]`, children: _jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner" }) })), _jsxs(Div, { paddingX: "x-page", className: "max-w-7xl mx-auto", padding: "y-md", children: [_jsxs(Row, { align: "end", gap: "md", children: [store.storeLogoURL ? (_jsx(Div, { className: "relative -mt-8 h-16 w-16 border-2 border-white", rounded: "xl", shadow: "sm", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "avatar" }) })) : (_jsx(Div, { className: CLS_AVATAR, children: store.storeName[0]?.toUpperCase() })), _jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-0.5", align: "center", gap: "sm", wrap: true, children: [_jsx(Heading, { level: 1, className: "text-[var(--appkit-color-text)]", size: "xl", weight: "bold", children: store.storeName }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Span, { size: "sm", weight: "medium", className: CLS_STARS, children: ["\u2605 ", store.averageRating.toFixed(1)] })), trust && _jsx(SellerTrustBadge, { trust: trust })] }), _jsx(OwnerQuickActions, { resource: "STORE", placement: "detail", editHref: String(ROUTES.ADMIN.STORE_DETAIL(store.storeSlug)), recordLabel: store.storeName }), _jsxs(Row, { textSize: "xs", className: "text-[var(--appkit-color-text-muted)] mb-0.5", gap: "3", children: [store.category && _jsx(Span, { transform: "capitalize", children: store.category }), store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { children: [store.totalProducts, " ", labels.products ?? "products"] })), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { children: [store.totalReviews, " ", labels.reviews ?? "reviews"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { children: [store.itemsSold, " ", labels.sold ?? "sold"] }))] }), store.storeDescription && (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), copyableCode: true, className: "mt-0.5" }))] }), _jsxs(Row, { gap: "sm", align: "center", className: "shrink-0", children: [_jsx(ShareButton, { title: store.storeName }), onFollow && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onFollow(store.storeSlug), className: CLS_FOLLOW_BTN, children: labels.follow ?? "Follow" }))] })] }), store.isVacationMode && (_jsx(Text, { className: CLS_WARN_BANNER, children: store.vacationMessage ??
|
|
18
|
+
return (_jsxs(Section, { surface: "default", border: "bottom", className: className, children: [store.storeBannerURL && (_jsx(Div, { className: `relative h-40 md:h-56 ${__O.hidden} bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)]`, children: _jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner" }) })), _jsxs(Div, { paddingX: "x-page", className: "max-w-7xl mx-auto", padding: "y-md", children: [_jsxs(Row, { align: "end", gap: "md", children: [store.storeLogoURL ? (_jsx(Div, { className: "relative -mt-8 h-16 w-16 border-2 border-white", rounded: "xl", shadow: "sm", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "avatar" }) })) : (_jsx(Div, { className: CLS_AVATAR, children: store.storeName[0]?.toUpperCase() })), _jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-0.5", align: "center", gap: "sm", wrap: true, children: [_jsx(Heading, { level: 1, className: "text-[var(--appkit-color-text)]", size: "xl", weight: "bold", children: store.storeName }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Span, { size: "sm", weight: "medium", className: CLS_STARS, children: ["\u2605 ", store.averageRating.toFixed(1)] })), _jsx(SellerTierBadge, { isVerified: store.isVerified, sellerTier: store.sellerTier, href: String(ROUTES.PUBLIC.HELP_SELLER_BADGES) }), trust && _jsx(SellerTrustBadge, { trust: trust })] }), _jsx(OwnerQuickActions, { resource: "STORE", placement: "detail", editHref: String(ROUTES.ADMIN.STORE_DETAIL(store.storeSlug)), recordLabel: store.storeName }), _jsxs(Row, { textSize: "xs", className: "text-[var(--appkit-color-text-muted)] mb-0.5", gap: "3", children: [store.category && _jsx(Span, { transform: "capitalize", children: store.category }), store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { children: [store.totalProducts, " ", labels.products ?? "products"] })), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { children: [store.totalReviews, " ", labels.reviews ?? "reviews"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { children: [store.itemsSold, " ", labels.sold ?? "sold"] }))] }), store.storeDescription && (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), copyableCode: true, className: "mt-0.5" }))] }), _jsxs(Row, { gap: "sm", align: "center", className: "shrink-0", children: [_jsx(ShareButton, { title: store.storeName }), onFollow && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onFollow(store.storeSlug), className: CLS_FOLLOW_BTN, children: labels.follow ?? "Follow" }))] })] }), store.isVacationMode && (_jsx(Text, { className: CLS_WARN_BANNER, children: store.vacationMessage ??
|
|
18
19
|
labels.vacationMode ??
|
|
19
20
|
"Store is on vacation mode" }))] })] }));
|
|
20
21
|
}
|
|
@@ -149,6 +149,29 @@ export class StoreRepository extends BaseRepository {
|
|
|
149
149
|
// Auto-set isPublic when approved
|
|
150
150
|
...(status === StoreStatusValues.ACTIVE && { isPublic: true }),
|
|
151
151
|
...(status !== StoreStatusValues.ACTIVE && { isPublic: false }),
|
|
152
|
+
/*
|
|
153
|
+
* 🛑 LEAVING `active` REVOKES TRUST IMMEDIATELY — and this cannot
|
|
154
|
+
* wait for the monthly recompute.
|
|
155
|
+
*
|
|
156
|
+
* `trustedSeller` means the store is paid at SHIPPING rather than at
|
|
157
|
+
* delivery, so a suspended store that kept the flag would go on
|
|
158
|
+
* being paid before parcels land for up to 31 days, on a decision
|
|
159
|
+
* the suspension has just reversed. `sellerStandingRollup` caches
|
|
160
|
+
* its verdict monthly; this is the one place that cache must not be
|
|
161
|
+
* trusted.
|
|
162
|
+
*
|
|
163
|
+
* Both fields are tracked, so the clear earns its own timeline
|
|
164
|
+
* entry alongside the status change. Re-granting is NOT automatic on
|
|
165
|
+
* re-activation: the next month-end run decides, which is the
|
|
166
|
+
* correct direction for a flag that moves money.
|
|
167
|
+
*
|
|
168
|
+
* `extra` is spread AFTER, so an admin explicitly setting a tier in
|
|
169
|
+
* the same request still wins — the override stays authoritative.
|
|
170
|
+
*/
|
|
171
|
+
...(status !== StoreStatusValues.ACTIVE && {
|
|
172
|
+
trustedSeller: false,
|
|
173
|
+
sellerTier: "seller",
|
|
174
|
+
}),
|
|
152
175
|
...extra,
|
|
153
176
|
updatedAt: new Date(),
|
|
154
177
|
}, ctx, "setStatus"));
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and helpers for the stores feature.
|
|
6
6
|
*/
|
|
7
7
|
import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
|
|
8
|
+
import type { SellerTier, SellerStandingCriterion } from "../../../_internal/shared/features/seller-standing/config";
|
|
8
9
|
import type { StoreCapability } from "../../auth/permissions/constants";
|
|
9
10
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
10
11
|
export type ShippingProviderType = "self-courier" | "store-pickup" | "custom";
|
|
@@ -141,6 +142,61 @@ export interface StoreDocument extends BaseDocument {
|
|
|
141
142
|
* payout.
|
|
142
143
|
*/
|
|
143
144
|
trustedSeller?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* The EARNED, PUBLIC tier.
|
|
147
|
+
*
|
|
148
|
+
* 🛑 Deliberately a second field rather than reusing `trustedSeller`, which
|
|
149
|
+
* means "paid at shipping" and is private by design (see directly above —
|
|
150
|
+
* "A store does not get to display that we trust it"). The monthly rollup
|
|
151
|
+
* writes BOTH together on a transition: `sellerTier` is what a buyer sees,
|
|
152
|
+
* `trustedSeller` is what the payout sweep reads. Collapsing them into one
|
|
153
|
+
* field would either leak the risk posture into the public projection or
|
|
154
|
+
* make the badge unrenderable.
|
|
155
|
+
*
|
|
156
|
+
* Absent means `"seller"`. There is no `"pending"` — a store that has not
|
|
157
|
+
* earned the tier simply has the base one.
|
|
158
|
+
*/
|
|
159
|
+
sellerTier?: SellerTier;
|
|
160
|
+
/**
|
|
161
|
+
* The cached standing computation behind `sellerTier`.
|
|
162
|
+
*
|
|
163
|
+
* 🛑 This IS the cache. It is recomputed by `sellerStandingRollup` on the
|
|
164
|
+
* last day of each month and read straight off the store document by the
|
|
165
|
+
* seller's progress panel, the badge and the admin view — none of which
|
|
166
|
+
* recompute anything. `computedAt` / `nextComputeAt` are rendered to the
|
|
167
|
+
* seller, because without them a seller who shipped twenty orders this week
|
|
168
|
+
* will read an unchanged panel as broken rather than as monthly.
|
|
169
|
+
*
|
|
170
|
+
* Metrics only — no buyer uids, no addresses, nothing that could make this
|
|
171
|
+
* a PII store. The address criterion is counted from HMAC fingerprints and
|
|
172
|
+
* only the COUNT is kept.
|
|
173
|
+
*/
|
|
174
|
+
standing?: {
|
|
175
|
+
computedAt: Date | string;
|
|
176
|
+
nextComputeAt?: Date | string;
|
|
177
|
+
windowDays: number;
|
|
178
|
+
deliveredOrders: number;
|
|
179
|
+
distinctBuyers: number;
|
|
180
|
+
distinctAddresses: number;
|
|
181
|
+
ratedOrders: number;
|
|
182
|
+
averageRating: number;
|
|
183
|
+
met: Record<SellerStandingCriterion, boolean>;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* An admin set `sellerTier` by hand; the monthly rollup must not move it.
|
|
187
|
+
*
|
|
188
|
+
* 🛑 TOP-LEVEL, not a key inside `standing`, and the distinction is the
|
|
189
|
+
* point: `standing` is a MEASUREMENT — every field in it is recomputed from
|
|
190
|
+
* scratch each month — while this is a DECISION that must outlive the
|
|
191
|
+
* measurement. Nesting it meant a store could not be pinned without also
|
|
192
|
+
* fabricating a metrics block, which is why the platform store's seed could
|
|
193
|
+
* not express "trusted, don't touch" without inventing figures nobody had
|
|
194
|
+
* measured.
|
|
195
|
+
*
|
|
196
|
+
* PRIVATE. It tells a buyer nothing about the goods or the seller; it is a
|
|
197
|
+
* note about how we administer this record.
|
|
198
|
+
*/
|
|
199
|
+
tierLockedByAdmin?: boolean;
|
|
144
200
|
/** Reason recorded when `status` is set to "suspended". */
|
|
145
201
|
suspensionReason?: string;
|
|
146
202
|
stats?: {
|
|
@@ -381,7 +437,7 @@ export interface StoreDocument extends BaseDocument {
|
|
|
381
437
|
* the store owner or the public, and `statusHistory` rides on the store
|
|
382
438
|
* document the owner can read.
|
|
383
439
|
*/
|
|
384
|
-
export declare const STORE_TRACKED_FIELDS: readonly ["status", "isPublic", "isVerified", "isFeatured", "suspensionReason", "capabilities"];
|
|
440
|
+
export declare const STORE_TRACKED_FIELDS: readonly ["status", "isPublic", "isVerified", "isFeatured", "suspensionReason", "capabilities", "sellerTier", "trustedSeller"];
|
|
385
441
|
/**
|
|
386
442
|
* PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
|
|
387
443
|
* is a live Meta credential; `mapDoc` decrypts it on EVERY read, so it is
|
|
@@ -418,6 +474,9 @@ export declare const STORE_FIELDS: {
|
|
|
418
474
|
readonly IS_FEATURED: "isFeatured";
|
|
419
475
|
readonly IS_VERIFIED: "isVerified";
|
|
420
476
|
readonly TRUSTED_SELLER: "trustedSeller";
|
|
477
|
+
readonly SELLER_TIER: "sellerTier";
|
|
478
|
+
readonly STANDING: "standing";
|
|
479
|
+
readonly TIER_LOCKED_BY_ADMIN: "tierLockedByAdmin";
|
|
421
480
|
readonly SUSPENSION_REASON: "suspensionReason";
|
|
422
481
|
readonly CAPABILITIES: "capabilities";
|
|
423
482
|
readonly STATUS_VALUES: {
|
|
@@ -31,6 +31,21 @@ export const STORE_TRACKED_FIELDS = [
|
|
|
31
31
|
"isFeatured",
|
|
32
32
|
"suspensionReason",
|
|
33
33
|
"capabilities",
|
|
34
|
+
/*
|
|
35
|
+
* Both halves of the trust decision are tracked, and both matter for
|
|
36
|
+
* different reasons. `sellerTier` is what the seller and every buyer saw, so
|
|
37
|
+
* "when did I lose the badge" has an answer. `trustedSeller` moves MONEY —
|
|
38
|
+
* it is the flag that pays this store before delivery — so the timeline is
|
|
39
|
+
* the record of when the platform started and stopped carrying that risk,
|
|
40
|
+
* alongside the `STORE_TRUST_CHANGE` audit row that names who decided it.
|
|
41
|
+
*
|
|
42
|
+
* 🛑 `standing` is deliberately NOT tracked. It is recomputed in full every
|
|
43
|
+
* month, so every field inside it changes on every run; tracking it would
|
|
44
|
+
* write a diff of five numbers monthly and exhaust the 50-entry FIFO cap
|
|
45
|
+
* within four years while burying the two transitions anyone reads.
|
|
46
|
+
*/
|
|
47
|
+
"sellerTier",
|
|
48
|
+
"trustedSeller",
|
|
34
49
|
];
|
|
35
50
|
/**
|
|
36
51
|
* PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
|
|
@@ -72,6 +87,9 @@ export const STORE_FIELDS = {
|
|
|
72
87
|
IS_FEATURED: "isFeatured",
|
|
73
88
|
IS_VERIFIED: "isVerified",
|
|
74
89
|
TRUSTED_SELLER: "trustedSeller",
|
|
90
|
+
SELLER_TIER: "sellerTier",
|
|
91
|
+
STANDING: "standing",
|
|
92
|
+
TIER_LOCKED_BY_ADMIN: "tierLockedByAdmin",
|
|
75
93
|
SUSPENSION_REASON: "suspensionReason",
|
|
76
94
|
CAPABILITIES: "capabilities",
|
|
77
95
|
STATUS_VALUES: {
|
|
@@ -616,10 +616,10 @@ export declare const storeListItemSchema: z.ZodObject<{
|
|
|
616
616
|
storeDescription?: string | undefined;
|
|
617
617
|
storeLogoURL?: string | undefined;
|
|
618
618
|
storeBannerURL?: string | undefined;
|
|
619
|
+
averageRating?: number | undefined;
|
|
619
620
|
totalProducts?: number | undefined;
|
|
620
621
|
itemsSold?: number | undefined;
|
|
621
622
|
totalReviews?: number | undefined;
|
|
622
|
-
averageRating?: number | undefined;
|
|
623
623
|
}, {
|
|
624
624
|
status: string;
|
|
625
625
|
id: string;
|
|
@@ -632,10 +632,10 @@ export declare const storeListItemSchema: z.ZodObject<{
|
|
|
632
632
|
storeDescription?: string | undefined;
|
|
633
633
|
storeLogoURL?: string | undefined;
|
|
634
634
|
storeBannerURL?: string | undefined;
|
|
635
|
+
averageRating?: number | undefined;
|
|
635
636
|
totalProducts?: number | undefined;
|
|
636
637
|
itemsSold?: number | undefined;
|
|
637
638
|
totalReviews?: number | undefined;
|
|
638
|
-
averageRating?: number | undefined;
|
|
639
639
|
}>;
|
|
640
640
|
export declare const storeListParamsSchema: z.ZodObject<{
|
|
641
641
|
category: z.ZodOptional<z.ZodString>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store-creation input contract — ONE definition, shared by the form and
|
|
3
|
+
* the server action that writes it.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Why this file exists at all. `createStoreAction` carried its own inline
|
|
6
|
+
* `z.object({ storeName, storeDescription, storeCategory })` while nothing
|
|
7
|
+
* rendered a form, so there was exactly one copy and no drift. The moment a
|
|
8
|
+
* form exists there are two places that must agree about "2–80 characters",
|
|
9
|
+
* and a client that validates more loosely than the server produces a form
|
|
10
|
+
* that submits and then fails with a message pointing at no field; a client
|
|
11
|
+
* that validates more strictly refuses input the server would have accepted.
|
|
12
|
+
* Both are Root Cause #75's shape. The action imports this; it does not
|
|
13
|
+
* restate it.
|
|
14
|
+
*
|
|
15
|
+
* The bounds are not new — they are lifted verbatim from the action's own
|
|
16
|
+
* schema so this is a move, not a retune.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
/**
|
|
20
|
+
* 🛑 `.or(z.literal(""))` on both optional fields, matching the action.
|
|
21
|
+
*
|
|
22
|
+
* An untouched text input submits `""`, not `undefined`. A bare
|
|
23
|
+
* `.max(80).optional()` rejects it, so a seller who filled in only the
|
|
24
|
+
* required name would be refused for leaving an optional field alone — which
|
|
25
|
+
* reads as a broken form, because the field they are being blocked on is one
|
|
26
|
+
* they were told was optional.
|
|
27
|
+
*/
|
|
28
|
+
export declare const storeCreateSchema: z.ZodObject<{
|
|
29
|
+
storeName: z.ZodString;
|
|
30
|
+
storeDescription: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
31
|
+
storeCategory: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
storeName: string;
|
|
34
|
+
storeCategory?: string | undefined;
|
|
35
|
+
storeDescription?: string | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
storeName: string;
|
|
38
|
+
storeCategory?: string | undefined;
|
|
39
|
+
storeDescription?: string | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
export type StoreCreateInput = z.infer<typeof storeCreateSchema>;
|
|
42
|
+
/** Field names, so a form and an error path cannot disagree by typo. */
|
|
43
|
+
export declare const STORE_CREATE_FIELDS: {
|
|
44
|
+
readonly STORE_NAME: "storeName";
|
|
45
|
+
readonly STORE_DESCRIPTION: "storeDescription";
|
|
46
|
+
readonly STORE_CATEGORY: "storeCategory";
|
|
47
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store-creation input contract — ONE definition, shared by the form and
|
|
3
|
+
* the server action that writes it.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Why this file exists at all. `createStoreAction` carried its own inline
|
|
6
|
+
* `z.object({ storeName, storeDescription, storeCategory })` while nothing
|
|
7
|
+
* rendered a form, so there was exactly one copy and no drift. The moment a
|
|
8
|
+
* form exists there are two places that must agree about "2–80 characters",
|
|
9
|
+
* and a client that validates more loosely than the server produces a form
|
|
10
|
+
* that submits and then fails with a message pointing at no field; a client
|
|
11
|
+
* that validates more strictly refuses input the server would have accepted.
|
|
12
|
+
* Both are Root Cause #75's shape. The action imports this; it does not
|
|
13
|
+
* restate it.
|
|
14
|
+
*
|
|
15
|
+
* The bounds are not new — they are lifted verbatim from the action's own
|
|
16
|
+
* schema so this is a move, not a retune.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { annotate } from "../../shell/field-ui-meta.js";
|
|
20
|
+
/**
|
|
21
|
+
* 🛑 `.or(z.literal(""))` on both optional fields, matching the action.
|
|
22
|
+
*
|
|
23
|
+
* An untouched text input submits `""`, not `undefined`. A bare
|
|
24
|
+
* `.max(80).optional()` rejects it, so a seller who filled in only the
|
|
25
|
+
* required name would be refused for leaving an optional field alone — which
|
|
26
|
+
* reads as a broken form, because the field they are being blocked on is one
|
|
27
|
+
* they were told was optional.
|
|
28
|
+
*/
|
|
29
|
+
/*
|
|
30
|
+
* 🛑 ANNOTATED, and the annotations do not change what this parses.
|
|
31
|
+
*
|
|
32
|
+
* `annotate()` returns the same schema and registers its UI metadata in a side
|
|
33
|
+
* registry, so the server action validates exactly what it validated before.
|
|
34
|
+
* That is what lets ONE schema drive both the form's sections
|
|
35
|
+
* (`buildSectionsFromSchema`) and the server-side parse — the alternative is a
|
|
36
|
+
* second form-only schema, which is the drift this file exists to prevent.
|
|
37
|
+
*
|
|
38
|
+
* One section, because there is one: a store has a name, what it sells, and a
|
|
39
|
+
* category. `audit-form-sectionised` requires the fields to live in a section
|
|
40
|
+
* engine rather than a bare `<Form>`, which is also what gets this step the
|
|
41
|
+
* pinned mobile action bar every other form in the app has.
|
|
42
|
+
*/
|
|
43
|
+
export const storeCreateSchema = z.object({
|
|
44
|
+
storeName: annotate(z
|
|
45
|
+
.string()
|
|
46
|
+
.min(2, "Store name must be at least 2 characters")
|
|
47
|
+
.max(80, "Store name must be 80 characters or fewer"), {
|
|
48
|
+
section: "store",
|
|
49
|
+
sectionLabel: "Your store",
|
|
50
|
+
sectionRequired: true,
|
|
51
|
+
quick: true,
|
|
52
|
+
order: 1,
|
|
53
|
+
row: "full",
|
|
54
|
+
label: "Store name",
|
|
55
|
+
help: "Buyers see this. It also forms your store's web address.",
|
|
56
|
+
}),
|
|
57
|
+
storeDescription: annotate(z
|
|
58
|
+
.string()
|
|
59
|
+
.max(10000, "Description must be 10,000 characters or fewer")
|
|
60
|
+
.optional()
|
|
61
|
+
.or(z.literal("")), {
|
|
62
|
+
section: "store",
|
|
63
|
+
order: 2,
|
|
64
|
+
row: "full",
|
|
65
|
+
kind: "textarea",
|
|
66
|
+
label: "What do you sell?",
|
|
67
|
+
help: "Optional. A short introduction for your storefront page.",
|
|
68
|
+
}),
|
|
69
|
+
storeCategory: annotate(z
|
|
70
|
+
.string()
|
|
71
|
+
.max(80, "Category must be 80 characters or fewer")
|
|
72
|
+
.optional()
|
|
73
|
+
.or(z.literal("")), {
|
|
74
|
+
section: "store",
|
|
75
|
+
order: 3,
|
|
76
|
+
row: "full",
|
|
77
|
+
label: "Main category",
|
|
78
|
+
help: "Optional. Helps buyers browsing that category find your store.",
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
/** Field names, so a form and an error path cannot disagree by typo. */
|
|
82
|
+
export const STORE_CREATE_FIELDS = {
|
|
83
|
+
STORE_NAME: "storeName",
|
|
84
|
+
STORE_DESCRIPTION: "storeDescription",
|
|
85
|
+
STORE_CATEGORY: "storeCategory",
|
|
86
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ListingType } from "../../products/types";
|
|
2
|
+
import type { SellerTier, SellerStandingCriterion } from "../../../_internal/shared/features/seller-standing/config";
|
|
2
3
|
export interface StoreListItem {
|
|
3
4
|
id: string;
|
|
4
5
|
storeSlug: string;
|
|
@@ -43,8 +44,49 @@ export interface StoreListItem {
|
|
|
43
44
|
*/
|
|
44
45
|
sharedPurchaseHandlingFee?: number;
|
|
45
46
|
sharedPurchaseExtraShippingPerDestination?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Seller trust, as a buyer sees it.
|
|
49
|
+
*
|
|
50
|
+
* 🛑 Neither is optional, for the same reason as the flags above: the
|
|
51
|
+
* projection resolves absence (`?? false` / `?? "seller"`), so no client has
|
|
52
|
+
* to decide what `undefined` means. A badge component testing `!== "seller"`
|
|
53
|
+
* on an undefined value would render a TRUSTED badge on a store that has
|
|
54
|
+
* earned nothing.
|
|
55
|
+
*
|
|
56
|
+
* 🛑 There is deliberately no `trustedSeller` here and there must never be.
|
|
57
|
+
* That field means "we pay this store before delivery" — our risk posture,
|
|
58
|
+
* not an endorsement — and it is in `PRIVATE_STORE_FIELDS` for that reason.
|
|
59
|
+
*/
|
|
60
|
+
isVerified: boolean;
|
|
61
|
+
sellerTier: SellerTier;
|
|
62
|
+
/**
|
|
63
|
+
* The cached standing metrics behind `sellerTier`, when they have ever been
|
|
64
|
+
* computed.
|
|
65
|
+
*
|
|
66
|
+
* OPTIONAL, unlike the two above, and that asymmetry is the point: absent
|
|
67
|
+
* means "never measured", which the seller's panel must render differently
|
|
68
|
+
* from a measured zero. Defaulting it would turn "we have not looked yet"
|
|
69
|
+
* into a confident "0 of 25".
|
|
70
|
+
*/
|
|
71
|
+
standing?: SellerStandingSnapshot;
|
|
46
72
|
createdAt?: string;
|
|
47
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The public half of `StoreDocument.standing` — counts and a mean, nothing
|
|
76
|
+
* that identifies a buyer. The address criterion is counted from HMAC
|
|
77
|
+
* fingerprints and only the count is kept.
|
|
78
|
+
*/
|
|
79
|
+
export interface SellerStandingSnapshot {
|
|
80
|
+
computedAt: Date | string;
|
|
81
|
+
nextComputeAt?: Date | string;
|
|
82
|
+
windowDays: number;
|
|
83
|
+
deliveredOrders: number;
|
|
84
|
+
distinctBuyers: number;
|
|
85
|
+
distinctAddresses: number;
|
|
86
|
+
ratedOrders: number;
|
|
87
|
+
averageRating: number;
|
|
88
|
+
met: Record<SellerStandingCriterion, boolean>;
|
|
89
|
+
}
|
|
48
90
|
export interface StoreDetail extends StoreListItem {
|
|
49
91
|
bio?: string;
|
|
50
92
|
location?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -2852,6 +2852,8 @@ export { TAX_CODES_COLLECTION, TAX_CODE_PREFIX, TAX_CODE_FIELDS, TAX_CODE_SIEVE_
|
|
|
2852
2852
|
export type { TaxCodeDocument, TaxCodeCreateInput, TaxCodeUpdateInput, GstRate, HsnChapter, } from "./features/tax-codes/schemas/firestore";
|
|
2853
2853
|
export { taxCodeFormSchema, GST_RATE_OPTIONS, HSN_CHAPTER_OPTIONS } from "./features/tax-codes/schemas/tax-code-form";
|
|
2854
2854
|
export type { TaxCodeFormValues } from "./features/tax-codes/schemas/tax-code-form";
|
|
2855
|
+
export { EVENT_LIKES_COLLECTION } from "./features/events/schemas/likes";
|
|
2856
|
+
export { DISPUTE_REASON_COLLECTION } from "./features/dispute-reasons/schemas/firestore";
|
|
2855
2857
|
export { taxCodeCreateSchema, taxCodeUpdateSchema, } from "./features/tax-codes/schemas/tax-codes.validators";
|
|
2856
2858
|
export type { TaxCodeCreatePayload, TaxCodeUpdatePayload, } from "./features/tax-codes/schemas/tax-codes.validators";
|
|
2857
2859
|
export type { ProductFeatureDocument, ProductFeatureCreateInput, ProductFeatureUpdateInput, ProductFeatureScope, ProductFeatureCategory, ProductFeatureProductType, ProductFeatureIcon, } from "./features/products/schemas/product-features";
|