@mohasinac/appkit 4.42.18 → 4.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
- package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
- package/dist/_internal/server/features/categories/adapters.js +235 -0
- package/dist/_internal/server/features/checkout/actions.js +161 -22
- package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/products/list-public.d.ts +24 -0
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/features/refunds/actions.js +1 -1
- package/dist/_internal/server/features/seo/sitemap.js +54 -15
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +6 -0
- package/dist/_internal/server/functions/scheduled.d.ts +4 -2
- package/dist/_internal/server/functions/scheduled.js +64 -6
- package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
- package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
- package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
- package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
- package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
- package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
- package/dist/_internal/server/jobs/handlers/index.js +3 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
- package/dist/_internal/server/jobs/handlers/messages.js +5 -2
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
- package/dist/_internal/shared/actions/action-registry.js +34 -0
- package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
- package/dist/_internal/shared/checkout/order-math.js +194 -7
- package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
- package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
- package/dist/_internal/shared/fees/calculator.d.ts +105 -0
- package/dist/_internal/shared/fees/calculator.js +143 -0
- package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
- package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
- package/dist/_internal/shared/templating/placeholders.js +73 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/admin/actions/admin-actions.d.ts +1 -1
- package/dist/features/admin/actions/admin-actions.js +1 -1
- package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
- package/dist/features/admin/components/AdminProductEditorView.js +8 -1
- package/dist/features/admin/components/AdminProductsView.js +78 -7
- package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
- package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
- package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
- package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
- package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
- package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
- package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
- package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
- package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
- package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
- package/dist/features/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/api/route.js +16 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- package/dist/features/categories/actions/category-actions.d.ts +2 -2
- package/dist/features/categories/actions/category-actions.js +22 -10
- package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
- package/dist/features/categories/api/[id]/description-template/route.js +252 -0
- package/dist/features/categories/api/route.js +156 -11
- package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
- package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
- package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
- package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
- package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
- package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
- package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
- package/dist/features/categories/constants/listing-categories.d.ts +17 -0
- package/dist/features/categories/constants/listing-categories.js +53 -0
- package/dist/features/categories/hooks/useCategoryTree.js +28 -2
- package/dist/features/categories/repository/categories.repository.d.ts +73 -12
- package/dist/features/categories/repository/categories.repository.js +203 -42
- package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
- package/dist/features/categories/repository/price-index.repository.js +85 -0
- package/dist/features/categories/schemas/category-content.d.ts +206 -0
- package/dist/features/categories/schemas/category-content.js +28 -0
- package/dist/features/categories/schemas/firestore.d.ts +74 -5
- package/dist/features/categories/schemas/firestore.js +74 -3
- package/dist/features/categories/schemas/index.d.ts +1047 -0
- package/dist/features/categories/schemas/index.js +96 -0
- package/dist/features/categories/server.d.ts +1 -0
- package/dist/features/categories/server.js +7 -0
- package/dist/features/categories/types/index.d.ts +9 -0
- package/dist/features/events/schemas/firestore.d.ts +2 -2
- package/dist/features/faq/schemas/firestore.d.ts +2 -1
- package/dist/features/faq/schemas/firestore.js +20 -8
- package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
- package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
- package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
- package/dist/features/homepage/lib/live-stats.js +18 -8
- package/dist/features/media/MediaImage.js +19 -2
- package/dist/features/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -0
- package/dist/features/products/actions/product-actions.d.ts +0 -9
- package/dist/features/products/actions/product-actions.js +25 -0
- package/dist/features/products/columns/index.d.ts +0 -1
- package/dist/features/products/columns/index.js +5 -1
- package/dist/features/products/components/ArtStickersListView.js +7 -1
- package/dist/features/products/components/PrizeDrawsSection.js +5 -0
- package/dist/features/products/components/ProductFilters.d.ts +0 -9
- package/dist/features/products/components/ProductForm.d.ts +27 -1
- package/dist/features/products/components/ProductForm.js +26 -12
- package/dist/features/products/components/ProductsIndexPageView.js +1 -1
- package/dist/features/products/constants/sieve.d.ts +0 -6
- package/dist/features/products/constants/sieve.js +35 -2
- package/dist/features/products/repository/products.repository.d.ts +115 -1
- package/dist/features/products/repository/products.repository.js +491 -39
- package/dist/features/products/schemas/firestore.d.ts +65 -5
- package/dist/features/products/schemas/firestore.js +9 -0
- package/dist/features/products/schemas/product-features.d.ts +140 -2
- package/dist/features/products/schemas/product-features.js +62 -1
- package/dist/features/promotions/repository/coupons.repository.js +6 -1
- package/dist/features/promotions/schemas/firestore.d.ts +44 -1
- package/dist/features/promotions/schemas/firestore.js +76 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
- package/dist/features/reviews/repository/reviews.repository.js +35 -0
- package/dist/features/seller/actions/seller-actions.js +17 -2
- package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
- package/dist/features/seller/components/PrintCenterView.js +3 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
- package/dist/features/seller/components/SellerOrdersView.js +6 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
- package/dist/features/seller/components/SellerProductShell.js +101 -9
- package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
- package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +108 -3
- package/dist/features/seller/types/index.d.ts +17 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
- package/dist/features/seller/utils/product-draft-mapping.js +17 -0
- package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
- package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
- package/dist/features/stores/schemas/firestore.d.ts +31 -2
- package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
- package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
- package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
- package/dist/features/tax-codes/schemas/firestore.js +82 -0
- package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
- package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
- package/dist/features/tester/actions/index.d.ts +0 -1
- package/dist/features/tester/actions/index.js +0 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
- package/dist/features/tester/components/index.d.ts +0 -4
- package/dist/features/tester/components/index.js +0 -2
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
- package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
- package/dist/features/tester/schemas/firestore.d.ts +0 -6
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
- package/dist/features/tester/seed-data/authored/index.js +0 -2
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
- package/dist/index.d.ts +21 -7
- package/dist/index.js +53 -7
- package/dist/next/routing/route-map.d.ts +2 -4
- package/dist/next/routing/route-map.js +1 -2
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +1 -0
- package/dist/schemas/registry.d.ts +506 -0
- package/dist/seed/_helpers/brand-rows.d.ts +35 -0
- package/dist/seed/_helpers/brand-rows.js +576 -0
- package/dist/seed/_helpers/category-content.d.ts +39 -0
- package/dist/seed/_helpers/category-content.js +634 -0
- package/dist/seed/_helpers/category-forest.d.ts +2 -0
- package/dist/seed/_helpers/category-forest.js +585 -243
- package/dist/seed/_helpers/category-models.d.ts +5 -0
- package/dist/seed/_helpers/category-models.js +1364 -0
- package/dist/seed/_helpers/category-tree.js +17 -1
- package/dist/seed/_helpers/character-features.d.ts +10 -0
- package/dist/seed/_helpers/character-features.js +118 -0
- package/dist/seed/_helpers/media.d.ts +1 -1
- package/dist/seed/_helpers/media.js +44 -8
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
- package/dist/seed/categories-seed-data.js +147 -169
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/manifest.d.ts +1 -1
- package/dist/seed/manifest.js +5 -0
- package/dist/seed/orders-seed-data.js +5 -3
- package/dist/seed/product-features-seed-data.d.ts +1 -8
- package/dist/seed/product-features-seed-data.js +358 -154
- package/dist/seed/products-art-seed-data.js +16 -4
- package/dist/seed/products-auctions-seed-data.js +6 -6
- package/dist/seed/products-classifieds-seed-data.js +17 -7
- package/dist/seed/products-digital-codes-seed-data.js +12 -12
- package/dist/seed/products-preorders-seed-data.js +8 -8
- package/dist/seed/products-prize-draws-seed-data.js +25 -8
- package/dist/seed/products-standard-seed-data.js +10 -60
- package/dist/seed/products-stickers-seed-data.js +6 -6
- package/dist/seed/site-settings-seed-data.js +24 -0
- package/dist/seed/store-extensions-seed-data.js +10 -4
- package/dist/seed/tax-codes-seed-data.d.ts +34 -0
- package/dist/seed/tax-codes-seed-data.js +100 -0
- package/dist/seed/test-data-ttl.d.ts +29 -0
- package/dist/seed/test-data-ttl.js +31 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +6 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +11 -2
- package/dist/styles.css +40 -0
- package/dist/ui/components/PaginatedSelect.d.ts +14 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/PaginatedSelect.style.css +40 -0
- package/dist/utils/media-url.d.ts +26 -0
- package/dist/utils/media-url.js +144 -0
- package/dist/utils/physical-location.d.ts +23 -0
- package/dist/utils/physical-location.js +52 -0
- package/dist/utils/search-txt-builders.d.ts +20 -0
- package/dist/utils/search-txt-builders.js +36 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -1
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
- package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
- package/dist/features/tester/actions/leaderboard-actions.js +0 -5
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
- package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
- package/dist/features/tester/components/TesterHubView.d.ts +0 -5
- package/dist/features/tester/components/TesterHubView.js +0 -110
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
- package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
- package/dist/features/tester/seed-data/tester-ttl.js +0 -6
|
@@ -4,12 +4,88 @@ import { useApiMutation } from "@mohasinac/appkit/client";
|
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { Card, CardBody, ConfirmDeleteModal, Div, Input, PaginatedSelect, Stack, StackedViewShell, Text, Toggle, useToast, Show, StickyToolbar } from "../../../ui";
|
|
7
|
+
import { Button, Card, CardBody, ConfirmDeleteModal, Div, Input, PaginatedSelect, Select, Span, Stack, StackedViewShell, Text, Textarea, Toggle, useToast, Show, StickyToolbar } from "../../../ui";
|
|
8
|
+
import { usedPlaceholders } from "../../../_internal/shared/templating/placeholders";
|
|
8
9
|
import { apiClient } from "../../../http";
|
|
9
10
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
10
11
|
import { CategoryQuickCreateForm } from "./CategoryQuickCreateForm";
|
|
11
12
|
import { FieldInput, FormErrorSummary, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
12
13
|
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
14
|
+
/*
|
|
15
|
+
* ── Content & Templates (C2c) ────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* 🛑 `highlights` and `faqs` have had NO editor since they shipped, and B3
|
|
18
|
+
* added `contentBody`, `descriptionTemplates` and `productDefaults` with none
|
|
19
|
+
* either. All five are in `CATEGORIES_UPDATABLE_FIELDS`, so the repository has
|
|
20
|
+
* always been willing — the only thing missing was somewhere to type them.
|
|
21
|
+
*
|
|
22
|
+
* ## Why string arrays are newline-separated textareas
|
|
23
|
+
*
|
|
24
|
+
* `highlights`, `inTheBox` and `seoKeywords` are flat `string[]`. A drag-handle
|
|
25
|
+
* repeater for each would be three widgets to build and three to maintain, and
|
|
26
|
+
* an admin pasting six bullets from a competitor's page wants to paste six
|
|
27
|
+
* lines. The textarea IS the better tool here, not a shortcut — order is
|
|
28
|
+
* preserved, reordering is a cut-and-paste, and a blank line is dropped rather
|
|
29
|
+
* than stored as an empty bullet.
|
|
30
|
+
*
|
|
31
|
+
* Repeaters are reserved for the shapes that genuinely need per-row fields:
|
|
32
|
+
* FAQs (question + answer), specifications (name + value + unit) and templates
|
|
33
|
+
* (label + variant + body).
|
|
34
|
+
*/
|
|
35
|
+
const linesToList = (raw) => raw
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
const listToLines = (list) => (list ?? []).join("\n");
|
|
40
|
+
const TEMPLATE_VARIANT_OPTIONS = [
|
|
41
|
+
{ value: "default", label: "Default — any condition" },
|
|
42
|
+
{ value: "new_in_box", label: "New in box" },
|
|
43
|
+
{ value: "new_in_packet", label: "New in packet" },
|
|
44
|
+
{ value: "pre_owned", label: "Pre-owned / used" },
|
|
45
|
+
{ value: "replica", label: "Reproduction" },
|
|
46
|
+
];
|
|
47
|
+
const AUTHENTICITY_OPTIONS = [
|
|
48
|
+
{ value: "", label: "Not set — the seller chooses" },
|
|
49
|
+
{ value: "original", label: "Original" },
|
|
50
|
+
{ value: "reproduction", label: "Reproduction (midfake)" },
|
|
51
|
+
{ value: "unverified", label: "Unverified" },
|
|
52
|
+
];
|
|
53
|
+
const CONDITION_DEFAULT_OPTIONS = [
|
|
54
|
+
{ value: "", label: "Not set — the seller chooses" },
|
|
55
|
+
{ value: "new", label: "New" },
|
|
56
|
+
{ value: "like_new", label: "Like New" },
|
|
57
|
+
{ value: "good", label: "Good" },
|
|
58
|
+
{ value: "fair", label: "Fair" },
|
|
59
|
+
{ value: "used", label: "Used" },
|
|
60
|
+
{ value: "refurbished", label: "Refurbished" },
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* A minimal add/remove repeater.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately NOT a new appkit primitive: three local shapes with three
|
|
66
|
+
* different row renderers is not yet the Rule of Three on a *component* — it
|
|
67
|
+
* is one container used three times with a render prop, which is what this is.
|
|
68
|
+
* Promote it when a fourth caller appears outside this file.
|
|
69
|
+
*/
|
|
70
|
+
function Repeater({ rows, onChange, blank, addLabel, renderRow, max, }) {
|
|
71
|
+
return (_jsxs(Stack, { gap: "sm", children: [rows.map((row, i) => (_jsx(Div, { rounded: "lg", border: "default", padding: "sm", children: _jsxs(Stack, { gap: "xs", children: [renderRow(row, (patch) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)))), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => onChange(rows.filter((_, j) => j !== i)), children: "Remove" })] }) }, i))), rows.length < max ? (_jsx(Button, { variant: "secondary", size: "sm", onClick: () => onChange([...rows, blank()]), children: addLabel })) : (_jsxs(Text, { size: "xs", color: "muted", children: ["Maximum of ", max, " reached."] }))] }));
|
|
72
|
+
}
|
|
73
|
+
async function loadTaxCodeOptions(query, page) {
|
|
74
|
+
const params = new URLSearchParams({ page: String(page), pageSize: "25" });
|
|
75
|
+
if (query)
|
|
76
|
+
params.set("q", query);
|
|
77
|
+
const res = await apiClient.get(`${ADMIN_ENDPOINTS.TAX_CODES}?${params.toString()}`);
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
const payload = res?.data ?? res;
|
|
80
|
+
const items = payload?.items ?? payload?.data ?? [];
|
|
81
|
+
return {
|
|
82
|
+
items: items.map((t) => ({
|
|
83
|
+
value: t.id,
|
|
84
|
+
label: t.label ?? `${t.hsnCode ?? t.id} — ${t.gstRate ?? "?"}%`,
|
|
85
|
+
})),
|
|
86
|
+
hasMore: payload?.hasMore ?? false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
13
89
|
/**
|
|
14
90
|
* Covers every field the form actually edits.
|
|
15
91
|
*
|
|
@@ -33,6 +109,32 @@ const categoryFormSchema = z.object({
|
|
|
33
109
|
.or(z.literal("")),
|
|
34
110
|
isActive: z.boolean().optional(),
|
|
35
111
|
showInMenu: z.boolean().optional(),
|
|
112
|
+
// ── Content & Templates ──────────────────────────────────────────────────
|
|
113
|
+
contentBody: z
|
|
114
|
+
.string()
|
|
115
|
+
.max(12000, "Keep the body under 12,000 characters")
|
|
116
|
+
.optional()
|
|
117
|
+
.or(z.literal("")),
|
|
118
|
+
highlightsText: z.string().optional().or(z.literal("")),
|
|
119
|
+
taxCodeId: z.string().optional().or(z.literal("")),
|
|
120
|
+
defaultCondition: z.string().optional().or(z.literal("")),
|
|
121
|
+
defaultAuthenticity: z.string().optional().or(z.literal("")),
|
|
122
|
+
inTheBoxText: z.string().optional().or(z.literal("")),
|
|
123
|
+
seoTitleTemplate: z.string().max(200).optional().or(z.literal("")),
|
|
124
|
+
seoDescriptionTemplate: z.string().max(400).optional().or(z.literal("")),
|
|
125
|
+
seoKeywordsText: z.string().optional().or(z.literal("")),
|
|
126
|
+
/*
|
|
127
|
+
* Typed in a text input and sent through `Number()`, so validated as a
|
|
128
|
+
* numeric STRING here rather than coerced — the same treatment `order`
|
|
129
|
+
* already gets two fields up. A category default is a PACKED weight (C5a),
|
|
130
|
+
* which is why the helper text says so: it drives the shipping estimate, and
|
|
131
|
+
* seeding an item weight there under-estimates every booking.
|
|
132
|
+
*/
|
|
133
|
+
weightG: z.string().regex(/^\d*$/, "Weight must be a whole number of grams").optional().or(z.literal("")),
|
|
134
|
+
dimL: z.string().regex(/^\d*\.?\d*$/, "Length must be a number").optional().or(z.literal("")),
|
|
135
|
+
dimW: z.string().regex(/^\d*\.?\d*$/, "Width must be a number").optional().or(z.literal("")),
|
|
136
|
+
dimH: z.string().regex(/^\d*\.?\d*$/, "Height must be a number").optional().or(z.literal("")),
|
|
137
|
+
fragile: z.boolean().optional(),
|
|
36
138
|
});
|
|
37
139
|
const EMPTY_VALUES = {
|
|
38
140
|
name: "",
|
|
@@ -42,6 +144,20 @@ const EMPTY_VALUES = {
|
|
|
42
144
|
order: "",
|
|
43
145
|
isActive: true,
|
|
44
146
|
showInMenu: true,
|
|
147
|
+
contentBody: "",
|
|
148
|
+
highlightsText: "",
|
|
149
|
+
taxCodeId: "",
|
|
150
|
+
defaultCondition: "",
|
|
151
|
+
defaultAuthenticity: "",
|
|
152
|
+
inTheBoxText: "",
|
|
153
|
+
seoTitleTemplate: "",
|
|
154
|
+
seoDescriptionTemplate: "",
|
|
155
|
+
seoKeywordsText: "",
|
|
156
|
+
weightG: "",
|
|
157
|
+
dimL: "",
|
|
158
|
+
dimW: "",
|
|
159
|
+
dimH: "",
|
|
160
|
+
fragile: false,
|
|
45
161
|
};
|
|
46
162
|
const __P = {
|
|
47
163
|
p4: "p-[var(--appkit-space-4)]",
|
|
@@ -62,6 +178,80 @@ async function loadCategoryOptions(query, page) {
|
|
|
62
178
|
hasMore: payload?.hasMore ?? false,
|
|
63
179
|
};
|
|
64
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The content half of the PUT payload.
|
|
183
|
+
*
|
|
184
|
+
* 🛑 Every field is sent on EVERY save, including as an empty array or an
|
|
185
|
+
* omitted key — because the route spreads this straight into
|
|
186
|
+
* `categoriesRepository.update()`, and an admin who DELETES all five
|
|
187
|
+
* highlights must end up with no highlights rather than with the old five.
|
|
188
|
+
* Only sending non-empty values is how a removal silently fails, which is the
|
|
189
|
+
* defect the whole C2b-fix commit was about one layer down.
|
|
190
|
+
*
|
|
191
|
+
* `productDefaults` is rebuilt whole rather than merged, for the same reason —
|
|
192
|
+
* clearing the tax code has to mean clearing it. The advisory
|
|
193
|
+
* `priceGuidance` half is CARRIED THROUGH from the loaded document and never
|
|
194
|
+
* edited here: it is seed-owned market data plus C1's rollup output, and an
|
|
195
|
+
* editor that round-tripped it would let an admin overwrite a measured band by
|
|
196
|
+
* hand with nothing recording that they had.
|
|
197
|
+
*/
|
|
198
|
+
function buildContentPayload(v, faqs, specs, templates, carriedPriceGuidance) {
|
|
199
|
+
const num = (s) => {
|
|
200
|
+
const t = s.trim();
|
|
201
|
+
if (t === "")
|
|
202
|
+
return undefined;
|
|
203
|
+
const n = Number(t);
|
|
204
|
+
return Number.isFinite(n) ? n : undefined;
|
|
205
|
+
};
|
|
206
|
+
const str = (s) => {
|
|
207
|
+
const t = String(s ?? "").trim();
|
|
208
|
+
return t === "" ? undefined : t;
|
|
209
|
+
};
|
|
210
|
+
const list = (s) => {
|
|
211
|
+
const out = linesToList(String(s ?? ""));
|
|
212
|
+
return out.length === 0 ? undefined : out;
|
|
213
|
+
};
|
|
214
|
+
const l = num(String(v.dimL ?? ""));
|
|
215
|
+
const w = num(String(v.dimW ?? ""));
|
|
216
|
+
const h = num(String(v.dimH ?? ""));
|
|
217
|
+
const keptSpecs = specs.filter((s) => s.name.trim() !== "" && s.value.trim() !== "");
|
|
218
|
+
const authenticity = str(v.defaultAuthenticity);
|
|
219
|
+
/*
|
|
220
|
+
* Assembled field by field rather than through a `Record<string, unknown>`
|
|
221
|
+
* accumulator. The accumulator version was shorter and `audit-unknown-leakage`
|
|
222
|
+
* was right to reject it: `unknown` in a payload builder is how a wrong key
|
|
223
|
+
* or a wrong shape reaches Firestore with nothing to catch it, and this
|
|
224
|
+
* object's whole job is to be the right shape.
|
|
225
|
+
*/
|
|
226
|
+
const defaults = {
|
|
227
|
+
...(str(v.taxCodeId) ? { taxCodeId: str(v.taxCodeId) } : {}),
|
|
228
|
+
...(str(v.defaultCondition) ? { defaultCondition: str(v.defaultCondition) } : {}),
|
|
229
|
+
...(authenticity === "original" || authenticity === "reproduction" || authenticity === "unverified"
|
|
230
|
+
? { defaultAuthenticity: authenticity }
|
|
231
|
+
: {}),
|
|
232
|
+
...(list(v.inTheBoxText) ? { inTheBox: list(v.inTheBoxText) } : {}),
|
|
233
|
+
...(str(v.seoTitleTemplate) ? { seoTitleTemplate: str(v.seoTitleTemplate) } : {}),
|
|
234
|
+
...(str(v.seoDescriptionTemplate)
|
|
235
|
+
? { seoDescriptionTemplate: str(v.seoDescriptionTemplate) }
|
|
236
|
+
: {}),
|
|
237
|
+
...(list(v.seoKeywordsText) ? { seoKeywords: list(v.seoKeywordsText) } : {}),
|
|
238
|
+
...(num(String(v.weightG ?? "")) != null ? { weightG: num(String(v.weightG ?? "")) } : {}),
|
|
239
|
+
...(keptSpecs.length > 0 ? { specifications: keptSpecs } : {}),
|
|
240
|
+
// All three or none — a box with a length and no width is not a dimension.
|
|
241
|
+
...(l != null && w != null && h != null ? { dimensionsCm: { l, w, h } } : {}),
|
|
242
|
+
...(v.fragile === true ? { fragile: true } : {}),
|
|
243
|
+
...(carriedPriceGuidance !== undefined ? { priceGuidance: carriedPriceGuidance } : {}),
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
contentBody: String(v.contentBody ?? ""),
|
|
247
|
+
highlights: linesToList(String(v.highlightsText ?? "")),
|
|
248
|
+
faqs: faqs.filter((f) => f.question.trim() !== "" && f.answer.trim() !== ""),
|
|
249
|
+
descriptionTemplates: templates
|
|
250
|
+
.filter((t) => t.label.trim() !== "" && t.body.trim() !== "")
|
|
251
|
+
.map((t) => ({ ...t, id: t.id || toCategorySlug(t.label) || "template" })),
|
|
252
|
+
productDefaults: defaults,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
65
255
|
function toCategorySlug(str) {
|
|
66
256
|
return str
|
|
67
257
|
.toLowerCase()
|
|
@@ -73,6 +263,19 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
73
263
|
const isEdit = Boolean(categoryId);
|
|
74
264
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
|
75
265
|
const [values, setValues] = React.useState(EMPTY_VALUES);
|
|
266
|
+
/*
|
|
267
|
+
* The three repeater arrays live OUTSIDE `values`.
|
|
268
|
+
*
|
|
269
|
+
* `CategoryFormValues` carries an index signature of `JsonValue` so
|
|
270
|
+
* `SectionForm` can map a Zod path back to the panel that owns it, and an
|
|
271
|
+
* array of objects satisfies that only after a cast at every read. Keeping
|
|
272
|
+
* them as their own typed state costs one extra dependency on the sections
|
|
273
|
+
* memo and buys real types at every use — the alternative is a `as any` in a
|
|
274
|
+
* render loop, which is where this codebase's worst defects live.
|
|
275
|
+
*/
|
|
276
|
+
const [faqs, setFaqs] = React.useState([]);
|
|
277
|
+
const [specs, setSpecs] = React.useState([]);
|
|
278
|
+
const [templates, setTemplates] = React.useState([]);
|
|
76
279
|
/* UI state, not a form value — whether the user has taken the slug over. */
|
|
77
280
|
const [slugManual, setSlugManual] = React.useState(false);
|
|
78
281
|
const { showToast } = useToast();
|
|
@@ -112,7 +315,29 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
112
315
|
order: cat.order !== undefined ? String(cat.order) : "",
|
|
113
316
|
isActive: cat.isActive ?? true,
|
|
114
317
|
showInMenu: cat.display?.showInMenu ?? true,
|
|
318
|
+
contentBody: cat.contentBody ?? "",
|
|
319
|
+
highlightsText: listToLines(cat.highlights),
|
|
320
|
+
taxCodeId: cat.productDefaults?.taxCodeId ?? "",
|
|
321
|
+
defaultCondition: cat.productDefaults?.defaultCondition ?? "",
|
|
322
|
+
defaultAuthenticity: cat.productDefaults?.defaultAuthenticity ?? "",
|
|
323
|
+
inTheBoxText: listToLines(cat.productDefaults?.inTheBox),
|
|
324
|
+
seoTitleTemplate: cat.productDefaults?.seoTitleTemplate ?? "",
|
|
325
|
+
seoDescriptionTemplate: cat.productDefaults?.seoDescriptionTemplate ?? "",
|
|
326
|
+
seoKeywordsText: listToLines(cat.productDefaults?.seoKeywords),
|
|
327
|
+
weightG: cat.productDefaults?.weightG != null ? String(cat.productDefaults.weightG) : "",
|
|
328
|
+
dimL: cat.productDefaults?.dimensionsCm?.l != null ? String(cat.productDefaults.dimensionsCm.l) : "",
|
|
329
|
+
dimW: cat.productDefaults?.dimensionsCm?.w != null ? String(cat.productDefaults.dimensionsCm.w) : "",
|
|
330
|
+
dimH: cat.productDefaults?.dimensionsCm?.h != null ? String(cat.productDefaults.dimensionsCm.h) : "",
|
|
331
|
+
fragile: cat.productDefaults?.fragile ?? false,
|
|
115
332
|
});
|
|
333
|
+
setFaqs((cat.faqs ?? []).map((f) => ({ ...f })));
|
|
334
|
+
setSpecs((cat.productDefaults?.specifications ?? []).map((s) => ({ ...s })));
|
|
335
|
+
setTemplates((cat.descriptionTemplates ?? []).map((t) => ({
|
|
336
|
+
id: t.id,
|
|
337
|
+
label: t.label,
|
|
338
|
+
variant: t.variant,
|
|
339
|
+
body: t.body,
|
|
340
|
+
})));
|
|
116
341
|
}, [categoryQuery.data]);
|
|
117
342
|
const saveMutation = useApiMutation({
|
|
118
343
|
errorMessage: "Failed to save category.",
|
|
@@ -128,7 +353,25 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
128
353
|
display: { showInMenu: values.showInMenu ?? true },
|
|
129
354
|
};
|
|
130
355
|
if (isEdit) {
|
|
131
|
-
|
|
356
|
+
/*
|
|
357
|
+
* 🛑 The content fields go on the PUT only, and that is not a
|
|
358
|
+
* limitation to work around.
|
|
359
|
+
*
|
|
360
|
+
* `POST /api/admin/categories`' schema is a `z.object()` naming nine
|
|
361
|
+
* keys, and Zod STRIPS unknown ones — so sending a description
|
|
362
|
+
* template on create would return 200, show a success toast and write
|
|
363
|
+
* nothing (Root Cause #76b, the admin grouped-listings shape). The
|
|
364
|
+
* section renders only in edit mode so the form never shows a field it
|
|
365
|
+
* is about to discard.
|
|
366
|
+
*
|
|
367
|
+
* It is also the right workflow: creating a category is naming it and
|
|
368
|
+
* placing it in the tree. Writing its SEO body and its five
|
|
369
|
+
* description templates is a separate sitting.
|
|
370
|
+
*/
|
|
371
|
+
return apiClient.put(ADMIN_ENDPOINTS.CATEGORY_BY_ID(categoryId), {
|
|
372
|
+
...payload,
|
|
373
|
+
...buildContentPayload(values, faqs, specs, templates, categoryQuery.data?.productDefaults?.["priceGuidance"]),
|
|
374
|
+
});
|
|
132
375
|
}
|
|
133
376
|
return apiClient.post(ADMIN_ENDPOINTS.CATEGORIES, payload);
|
|
134
377
|
},
|
|
@@ -178,7 +421,30 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
178
421
|
fields: ["order", "isActive", "showInMenu"],
|
|
179
422
|
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Input, { label: "Display order", value: String(v.order ?? ""), onChange: (e) => onChange({ order: e.target.value }), type: "number", min: 0, placeholder: "0", error: errors.order }), _jsx(Toggle, { label: "Active", checked: v.isActive ?? true, onChange: (checked) => onChange({ isActive: checked }) }), _jsx(Toggle, { label: "Show in menu", checked: v.showInMenu ?? true, onChange: (checked) => onChange({ showInMenu: checked }) })] })),
|
|
180
423
|
},
|
|
181
|
-
|
|
424
|
+
/*
|
|
425
|
+
* 🛑 EDIT ONLY. The create route's `z.object()` would strip every one of
|
|
426
|
+
* these and answer 200, so the form must not offer them yet. See
|
|
427
|
+
* buildContentPayload's header.
|
|
428
|
+
*/
|
|
429
|
+
...(isEdit
|
|
430
|
+
? [
|
|
431
|
+
{
|
|
432
|
+
id: "content",
|
|
433
|
+
label: "Content & Templates",
|
|
434
|
+
fields: ["contentBody", "highlightsText"],
|
|
435
|
+
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Category page body" }), _jsx(Textarea, { value: String(v.contentBody ?? ""), onChange: (e) => onChange({ contentBody: e.target.value }), rows: 8, placeholder: "400\u2013900 characters of keyword-rich HTML with links to sibling and parent categories\u2026" }), errors.contentBody ? (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: errors.contentBody })) : (_jsx(Text, { size: "xs", color: "muted", children: "Rendered on the category page. Distinct from the short Description above, which also feeds the SEO meta and every card. 36 of worldhobbyshop's 46 categories carry one of these and it is their actual ranking mechanism; no Shopify competitor in the crawl has any." }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Highlights \u2014 one per line" }), _jsx(Textarea, { value: String(v.highlightsText ?? ""), onChange: (e) => onChange({ highlightsText: e.target.value }), rows: 4, placeholder: "Genuine Takara Tomy stock\nChecked and photographed before listing" }), _jsx(Text, { size: "xs", color: "muted", children: "Short \"why shop this\" bullets, shown above the FAQs. Blank lines are dropped. Up to 12." })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Category FAQs" }), _jsx(Text, { size: "xs", color: "muted", children: "Rendered as an accordion on the category page and emitted as FAQPage structured data. Keep platform-wide answers in the global FAQ collection instead \u2014 two copies of one answer means the one that gets fixed is never the one being read." }), _jsx(Repeater, { rows: faqs, onChange: setFaqs, blank: () => ({ question: "", answer: "" }), addLabel: "Add an FAQ", max: 20, renderRow: (row, set) => (_jsxs(_Fragment, { children: [_jsx(Input, { label: "Question", value: row.question, onChange: (e) => set({ question: e.target.value }), placeholder: "Are these compatible with Burst launchers?" }), _jsx(Textarea, { value: row.answer, onChange: (e) => set({ answer: e.target.value }), rows: 3, placeholder: "Answer\u2026" })] })) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Description templates" }), _jsxs(Text, { size: "xs", color: "muted", children: ["What the seller form offers when a listing is filed here. One per condition band \u2014 worldhobbyshop reuses exactly two across 1,195 listings. Slots like", " ", _jsx(Span, { family: "mono", children: "{{title}}" }), ",", " ", _jsx(Span, { family: "mono", children: "{{brand}}" }), ",", " ", _jsx(Span, { family: "mono", children: "{{condition}}" }), " and", " ", _jsx(Span, { family: "mono", children: "{{series}}" }), " are filled from the listing; an unresolved one becomes empty and is reported to the seller, never left as a literal brace pair."] }), _jsx(Repeater, { rows: templates, onChange: setTemplates, blank: () => ({ id: "", label: "", variant: "default", body: "" }), addLabel: "Add a template", max: 8, renderRow: (row, set) => (_jsxs(_Fragment, { children: [_jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Input, { label: "Label", value: row.label, onChange: (e) => set({ label: e.target.value }), placeholder: "Pre-owned \u2014 condition as pictured" }), _jsx(Select, { label: "Condition band", value: row.variant, onChange: (e) => set({ variant: e.target.value }), options: TEMPLATE_VARIANT_OPTIONS })] }), _jsx(Textarea, { value: row.body, onChange: (e) => set({ body: e.target.value }), rows: 6, placeholder: "Important things to know about {{title}} \u2014 HTML permitted\u2026" }), _jsx(Text, { size: "xs", color: "muted", children: usedPlaceholders(row.body).length > 0
|
|
436
|
+
? `Slots used: ${usedPlaceholders(row.body).map((p) => `{{${p}}}`).join(", ")}`
|
|
437
|
+
: "No slots used — this body will read identically on every listing." })] })) })] })] })),
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "defaults",
|
|
441
|
+
label: "Product defaults",
|
|
442
|
+
fields: ["taxCodeId", "weightG", "dimL", "dimW", "dimH", "seoTitleTemplate", "seoDescriptionTemplate"],
|
|
443
|
+
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: "What a listing filed under this category inherits. Written server-side when the product omits the field, and resolved PER FIELD up the ancestor chain \u2014 so a named-model leaf can supply its own features while the tax code and spec rows come from the line above it." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Tax code" }), _jsx(PaginatedSelect, { value: String(v.taxCodeId ?? "") || null, onChange: (val) => onChange({ taxCodeId: val ?? "" }), loadOptions: loadTaxCodeOptions, placeholder: "Search tax codes\u2026", searchPlaceholder: "HSN or label\u2026", noResultsText: "No tax codes found", ariaLabel: "Tax code" }), _jsx(Text, { size: "xs", color: "muted", children: "\uD83D\uDED1 This is the GST mechanism, not a nicety. A listing whose category resolves no tax code falls back to a default rate and is flagged for triage \u2014 and the measure of success is that the fallback fires for nothing." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Select, { label: "Default condition", value: String(v.defaultCondition ?? ""), onChange: (e) => onChange({ defaultCondition: e.target.value }), options: CONDITION_DEFAULT_OPTIONS }), _jsx(Select, { label: "Default authenticity", value: String(v.defaultAuthenticity ?? ""), onChange: (e) => onChange({ defaultAuthenticity: e.target.value }), options: AUTHENTICITY_OPTIONS })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Specification rows" }), _jsx(Repeater, { rows: specs, onChange: setSpecs, blank: () => ({ name: "", value: "", unit: "" }), addLabel: "Add a specification", max: 20, renderRow: (row, set) => (_jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-3", children: [_jsx(Input, { label: "Name", value: row.name, onChange: (e) => set({ name: e.target.value }), placeholder: "System" }), _jsx(Input, { label: "Value", value: row.value, onChange: (e) => set({ value: e.target.value }), placeholder: "Magne Core" }), _jsx(Input, { label: "Unit", value: row.unit ?? "", onChange: (e) => set({ unit: e.target.value }), placeholder: "g" })] })) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "What's in the box \u2014 one per line" }), _jsx(Textarea, { value: String(v.inTheBoxText ?? ""), onChange: (e) => onChange({ inTheBoxText: e.target.value }), rows: 4, placeholder: "Beyblade top\nLauncher\nRipcord\nManual" }), _jsx(Text, { size: "xs", color: "muted", children: "Read by the description-template endpoint to fill the new-in-box body's list. Deliberately NOT written to the product's `features` field, which holds feature ids \u2014 putting prose there would break every facet on the listing." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Input, { label: "SEO title template", value: String(v.seoTitleTemplate ?? ""), onChange: (e) => onChange({ seoTitleTemplate: e.target.value }), placeholder: "{{title}} \u2014 {{brand}} | {{siteName}}", error: errors.seoTitleTemplate }), _jsx(Input, { label: "SEO description template", value: String(v.seoDescriptionTemplate ?? ""), onChange: (e) => onChange({ seoDescriptionTemplate: e.target.value }), placeholder: "Buy {{title}} in India\u2026", error: errors.seoDescriptionTemplate })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "SEO keywords \u2014 one per line" }), _jsx(Textarea, { value: String(v.seoKeywordsText ?? ""), onChange: (e) => onChange({ seoKeywordsText: e.target.value }), rows: 3 })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-4", children: [_jsx(Input, { label: "Packed weight (g)", value: String(v.weightG ?? ""), onChange: (e) => onChange({ weightG: e.target.value }), placeholder: "300", error: errors.weightG, helperText: "Boxed, not the bare item." }), _jsx(Input, { label: "Length (cm)", value: String(v.dimL ?? ""), onChange: (e) => onChange({ dimL: e.target.value }), error: errors.dimL }), _jsx(Input, { label: "Width (cm)", value: String(v.dimW ?? ""), onChange: (e) => onChange({ dimW: e.target.value }), error: errors.dimW }), _jsx(Input, { label: "Height (cm)", value: String(v.dimH ?? ""), onChange: (e) => onChange({ dimH: e.target.value }), error: errors.dimH })] }), _jsx(Text, { size: "xs", color: "muted", children: "\uD83D\uDED1 The weight is a PACKED weight \u2014 a bey is ~30 g and ~300 g boxed. This figure drives the shipping estimate the buyer pays, and the seller's booking is capped at what was collected, so an item weight here under-estimates every booking and the difference is absorbed silently. All three dimensions or none." }), _jsx(Toggle, { label: "Fragile by default", checked: v.fragile === true, onChange: (checked) => onChange({ fragile: checked }) })] })),
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
: []),
|
|
447
|
+
], [isEdit, faqs, specs, templates]);
|
|
182
448
|
const nav = useSectionFormNav(sections, values);
|
|
183
449
|
const { shellCtx } = useFormShellState(categoryFormSchema, {
|
|
184
450
|
sections: nav.sectionMeta,
|
|
@@ -205,7 +205,14 @@ export function AdminProductEditorView({ productId, onSaved, onDeleted, embedded
|
|
|
205
205
|
label: "Details",
|
|
206
206
|
required: true,
|
|
207
207
|
fields: ["title", "price", "storeId", "categorySlug", "brandSlug"],
|
|
208
|
-
render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct,
|
|
208
|
+
render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct,
|
|
209
|
+
/*
|
|
210
|
+
* The ONLY place the Tax & GST section is shown. LetItRip
|
|
211
|
+
* invoices under its own GSTIN, so the rate is an admin
|
|
212
|
+
* determination — the seller forms deliberately omit it and
|
|
213
|
+
* let `deriveTaxonomy` resolve it from the category.
|
|
214
|
+
*/
|
|
215
|
+
canEditTax: true, renderGroupSettings: isEdit && productId
|
|
209
216
|
? (p) => (_jsx(GroupSettingsPanel, { productId: productId, productSlug: p.slug ?? productId, groupId: p.groupId, isGroupParent: p.isGroupParent, groupParentSlug: p.groupParentSlug, groupChildSlugs: p.groupChildSlugs, groupTitle: p.groupTitle, isAuction: modeFromProduct(p) === "auction", storeProductsEndpoint: ADMIN_ENDPOINTS.PRODUCTS, onGroupChanged: () => productQuery.refetch() }))
|
|
210
217
|
: undefined, renderGroupJoinField: ({ label, value, onChange, disabled }) => (_jsx(GroupInlineSelect, { scope: "admin", value: value ?? "", onChange: (id) => onChange(id || undefined), disabled: disabled, label: label, placeholder: "None (standalone product)", allowCreate: false })), renderCategorySelector: ({ label, value, onChange, disabled }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: label }), _jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: label, disabled: disabled, createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })), renderBrandSelector: (args) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: args.label }), _jsx(PaginatedSelect, { value: args.multi ? null : (args.value || null), onChange: (v) => args.onValueChange(v ?? ""), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type brand name\u2026", noResultsText: "No brands found", ariaLabel: args.label, disabled: args.disabled, createLabel: "Brand", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(BrandQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })) })] })] }) })),
|
|
211
218
|
},
|
|
@@ -72,6 +72,60 @@ export function AdminProductsView({ children, ...props }) {
|
|
|
72
72
|
showToast(err?.message ?? "Failed to update product.", "error");
|
|
73
73
|
}
|
|
74
74
|
}, [showToast, overrides]);
|
|
75
|
+
/** Flip one flag across a selection, optimistically, in chunks. */
|
|
76
|
+
const setFlagOverrides = useCallback((ids, field, value) => {
|
|
77
|
+
setOverrides((o) => {
|
|
78
|
+
const next = { ...o };
|
|
79
|
+
for (const id of ids)
|
|
80
|
+
next[id] = { ...next[id], [field]: value };
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}, []);
|
|
84
|
+
const handleBulkToggle = useCallback(async (ids, field, value) => {
|
|
85
|
+
setFlagOverrides(ids, field, value);
|
|
86
|
+
/*
|
|
87
|
+
* Chunked at the route's own bound.
|
|
88
|
+
*
|
|
89
|
+
* Select-all takes the CURRENT PAGE (`setSelectedIds(rows.map(...))` in
|
|
90
|
+
* DataListingView) and `pageSize` is user-settable from the pagination
|
|
91
|
+
* control, so a selection larger than the route's `max(50)` is
|
|
92
|
+
* reachable — sending it whole would 400 the entire batch on a Zod
|
|
93
|
+
* error. Chunking keeps the server's bound honest without making the UI
|
|
94
|
+
* enforce a number it does not own.
|
|
95
|
+
*
|
|
96
|
+
* Still a collapse, not a fan-out: 200 rows is 4 sequential requests
|
|
97
|
+
* rather than the 200 concurrent ones this replaced.
|
|
98
|
+
*/
|
|
99
|
+
const CHUNK = 50;
|
|
100
|
+
let updated = 0;
|
|
101
|
+
let failedCount = 0;
|
|
102
|
+
const failedIds = [];
|
|
103
|
+
try {
|
|
104
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
105
|
+
const part = await apiClient.post(ADMIN_ENDPOINTS.PRODUCTS_BULK, { field, value, ids: ids.slice(i, i + CHUNK) });
|
|
106
|
+
updated += part?.summary?.updated ?? 0;
|
|
107
|
+
failedCount += part?.summary?.failed ?? 0;
|
|
108
|
+
for (const f of part?.failed ?? [])
|
|
109
|
+
failedIds.push(f.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
void normalizeError(err);
|
|
114
|
+
// The request itself failed — roll every row back, including any
|
|
115
|
+
// earlier chunk, because the admin cannot tell which landed.
|
|
116
|
+
setFlagOverrides(ids, field, !value);
|
|
117
|
+
showToast(err?.message ?? "Bulk update failed.", "error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (failedIds.length > 0) {
|
|
121
|
+
// Roll back ONLY the rows the server named, so the ones that
|
|
122
|
+
// succeeded keep their new state.
|
|
123
|
+
setFlagOverrides(failedIds, field, !value);
|
|
124
|
+
showToast(`Updated ${updated} of ${ids.length}; ${failedCount} failed.`, "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
showToast(`Updated ${updated} product${updated === 1 ? "" : "s"}.`, "success");
|
|
128
|
+
}, [setFlagOverrides, showToast]);
|
|
75
129
|
const handleQuickEdit = useCallback(async (id, values) => {
|
|
76
130
|
const prev = overrides[id];
|
|
77
131
|
setOverrides((o) => ({ ...o, [id]: { ...o[id], ...values } }));
|
|
@@ -202,13 +256,30 @@ export function AdminProductsView({ children, ...props }) {
|
|
|
202
256
|
[ROW_ACTION_ID.PROMOTE]: "isPromoted",
|
|
203
257
|
[ROW_ACTION_ID.SALE]: "isOnSale",
|
|
204
258
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
259
|
+
/*
|
|
260
|
+
* 🛑 ONE request, not one per row.
|
|
261
|
+
*
|
|
262
|
+
* This was `selectedIds.forEach((rowId) => toggleField(rowId, field))`
|
|
263
|
+
* — a fire-and-forget `forEach` of single-row PATCHes. Selecting 200
|
|
264
|
+
* products and clicking Feature fired **200 concurrent requests**, each
|
|
265
|
+
* its own function invocation, with `clearSelection()` running
|
|
266
|
+
* immediately so nothing reported what actually happened. A row that
|
|
267
|
+
* failed raised its own toast, up to 200 of them.
|
|
268
|
+
*
|
|
269
|
+
* The bulk route returns `{updated, failed, summary}`, so a partial
|
|
270
|
+
* failure is now a single message naming the count instead of silence.
|
|
271
|
+
* Selection is cleared only AFTER the request resolves — clearing first
|
|
272
|
+
* is what made it look finished the instant it was clicked.
|
|
273
|
+
*
|
|
274
|
+
* The optimistic `overrides` are still applied per row so the switches
|
|
275
|
+
* move immediately; a failure rolls back only the ids the server names.
|
|
276
|
+
*/
|
|
277
|
+
const bulkToggle = (field) => async () => {
|
|
278
|
+
const ids = [...selection.selectedIds];
|
|
279
|
+
if (ids.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
const value = !selection.rows.find((r) => r.id === ids[0])?.[field];
|
|
282
|
+
await handleBulkToggle(ids, field, value);
|
|
212
283
|
selection.clearSelection();
|
|
213
284
|
};
|
|
214
285
|
return ADMIN_BULK_ACTIONS.products.map((id) => ({
|
|
@@ -15,6 +15,7 @@ import { SITE_SETTINGS_TABS, isSiteSettingsTabId, } from "../constants/site-sett
|
|
|
15
15
|
import { ThemeManagerView, } from "../../site-settings/components/ThemeManagerView";
|
|
16
16
|
import { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from "../../../tokens/themes";
|
|
17
17
|
import { DEFAULT_AUCTION_BID_INCREMENT_TIERS, } from "../../../_internal/shared/features/auctions/config";
|
|
18
|
+
import { validateGstEnablement } from "../../../_internal/shared/features/site-settings/gst-registration";
|
|
18
19
|
import { siteSettingsFormSchema } from "../schemas/site-settings-form";
|
|
19
20
|
import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
|
|
20
21
|
import { SectionForm } from "../../shell";
|
|
@@ -187,6 +188,24 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
187
188
|
const [gstin, setGstin] = React.useState("");
|
|
188
189
|
const [gstLegalName, setGstLegalName] = React.useState("");
|
|
189
190
|
const [gstAddress, setGstAddress] = React.useState("");
|
|
191
|
+
/*
|
|
192
|
+
* Shown inline, live, and ONLY while the toggle is on — the same rule the
|
|
193
|
+
* server enforces. `/api/admin/site` rejects the save, so without this the
|
|
194
|
+
* admin would get a generic "save failed" with no indication of which of the
|
|
195
|
+
* four fields it meant.
|
|
196
|
+
*
|
|
197
|
+
* Live rather than on-submit because the toggle is the action: an admin who
|
|
198
|
+
* flips it on sees immediately what else is needed, instead of discovering
|
|
199
|
+
* it after a round trip. Empty when the toggle is off, so an admin editing
|
|
200
|
+
* their address with GST disabled is never shown errors about it
|
|
201
|
+
* (Root Cause #74 — a summary that accuses before anything was attempted).
|
|
202
|
+
*/
|
|
203
|
+
const gstProblems = React.useMemo(() => validateGstEnablement({
|
|
204
|
+
enabled: gstEnabled,
|
|
205
|
+
gstin,
|
|
206
|
+
legalName: gstLegalName,
|
|
207
|
+
address: gstAddress,
|
|
208
|
+
}), [gstEnabled, gstin, gstLegalName, gstAddress]);
|
|
190
209
|
const [platformFeePercent, setPlatformFeePercent] = React.useState(5);
|
|
191
210
|
const [gstPercent, setGstPercent] = React.useState(18);
|
|
192
211
|
const [minimumTransactionFee, setMinimumTransactionFee] = React.useState(0);
|
|
@@ -783,7 +802,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
783
802
|
{ label: "Top right", value: "top-right" },
|
|
784
803
|
{ label: "Bottom left", value: "bottom-left" },
|
|
785
804
|
{ label: "Bottom right", value: "bottom-right" },
|
|
786
|
-
], value: watermarkPosition, onValueChange: (v) => setWatermarkPosition(v), disabled: watermarkUseCustomOffset }), _jsx(Toggle, { checked: watermarkUseCustomOffset, onChange: setWatermarkUseCustomOffset, label: "Use a custom X/Y offset instead" }), watermarkUseCustomOffset && (_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "X offset (%) \u2014 + right / \u2212 left of center", value: String(watermarkOffsetX), onChange: (e) => setWatermarkOffsetX(Math.max(-45, Math.min(45, parseInt(e.target.value) || 0))), type: "number", min: -45, max: 45 }), _jsx(Input, { label: "Y offset (%) \u2014 + down / \u2212 up from center", value: String(watermarkOffsetY), onChange: (e) => setWatermarkOffsetY(Math.max(-45, Math.min(45, parseInt(e.target.value) || 0))), type: "number", min: -45, max: 45 })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: "Preview (text watermark only)" }), _jsx(Row, { surface: "default", justify: "end", align: "end", className: `relative h-32 ${__O.hidden}`, rounded: "default", children: _jsx(Span, { ref: watermarkPreviewRef, weight: "medium", color: "faint", padding: "1", className: "select-none", children: watermarkText }) })] })] }) }), _jsx(TabsContent, { value: "fees", children: _jsxs(SettingsTabForm, { id: "fees", label: "Fees", children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Platform fee \u2014 our cut (%)", helperText: "% charged on order value. Buyer pays this.", value: String(platformFeePercent), onChange: (e) => setPlatformFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "GST on platform fee (%)", helperText: "Applied to our fee only (not full order). Usually 18%.", value: String(gstPercent), onChange: (e) => setGstPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "PhonePe gateway cost (%)", helperText: "Gateway's own fee \u2014 absorbed by platform, not passed through.", value: String(gatewayFeePercent), onChange: (e) => setGatewayFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 10, step: 0.01 }), _jsx(Input, { label: "Minimum transaction fee (\u20B9)", helperText: "Per-transaction floor. Total charge will never be below base + this.", value: String(minimumTransactionFee), onChange: (e) => setMinimumTransactionFee(parseFloat(e.target.value) || 0), type: "number", min: 0, step: 0.01 }), _jsx(Input, { label: "Maximum platform fee (\u20B9)", helperText: "Ceiling on the buyer's platform commission, charged once per checkout on every payment method. GST is calculated on the capped amount.", value: String(platformFeeMax), onChange: (e) => setPlatformFeeMax(parseFloat(e.target.value) || 0), type: "number", min: 0, step: 0.01 }), _jsx(Input, { label: "Seller payout hold (days)", value: String(payoutHoldDays), onChange: (e) => setPayoutHoldDays(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Minimum payout amount (\u20B9)", value: String(minPayoutAmount), onChange: (e) => setMinPayoutAmount(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Auction listing fee (\u20B9)", value: String(auctionListingFee), onChange: (e) => setAuctionListingFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Pre-order listing fee (\u20B9)", value: String(preOrderListingFee), onChange: (e) => setPreOrderListingFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Featured slot fee (\u20B9)", value: String(featuredSlotFee), onChange: (e) => setFeaturedSlotFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Promoted slot fee (\u20B9)", value: String(promotedSlotFee), onChange: (e) => setPromotedSlotFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "COD deposit (%)", helperText: "% of order collected upfront on COD orders; remainder paid on delivery.", value: String(codDepositPercent), onChange: (e) => setCodDepositPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 })] }), _jsx(Toggle, { checked: whatsappNotifyFeeEnabled, onChange: setWhatsappNotifyFeeEnabled, label: "Offer the WhatsApp order-updates addon at checkout" }), _jsx(Grid, { cols: 2, gap: "md", children: _jsx(Input, { label: "WhatsApp addon fee (\u20B9)", helperText: "Flat fee charged to the buyer when they opt in. Requires WhatsApp credentials + at least one approved template \u2014 see the WhatsApp tab.", value: String(whatsappNotifyFee), onChange: (e) => setWhatsappNotifyFee(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !whatsappNotifyFeeEnabled }) }), _jsx(Toggle, { checked: giftWrapFeeEnabled, onChange: setGiftWrapFeeEnabled, label: "Offer the gift-wrap addon at checkout" }), _jsx(Grid, { cols: 2, gap: "md", children: _jsx(Input, { label: "Gift wrap fee (\u20B9)", helperText: "Flat fee charged to the buyer when they opt in.", value: String(giftWrapFee), onChange: (e) => setGiftWrapFee(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !giftWrapFeeEnabled }) }), _jsx(Toggle, { checked: shipmentProtectionFeeEnabled, onChange: setShipmentProtectionFeeEnabled, label: "Offer the shipment-protection addon at checkout" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Shipment protection (%)", helperText: "% of order subtotal, charged when the buyer opts in.", value: String(shipmentProtectionFeePercent), onChange: (e) => setShipmentProtectionFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1, disabled: !shipmentProtectionFeeEnabled }), _jsx(Input, { label: "Shipment protection minimum (\u20B9)", helperText: "Floor for the shipment-protection fee.", value: String(shipmentProtectionFeeMin), onChange: (e) => setShipmentProtectionFeeMin(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !shipmentProtectionFeeEnabled })] })] }) }), _jsx(TabsContent, { value: "procurement", children: _jsxs(SettingsTabForm, { id: "procurement", label: "Procurement", children: [_jsx(Text, { size: "xs", color: "muted", children: "Used to compute each procurement shipment's labor cost (hours spent \u00D7 hourly rate) and its estimated processing time (hours spent \u00F7 max hours/day)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Hourly rate (\u20B9)", helperText: "Your effective hourly wage for sorting/categorizing/listing a shipment.", value: String(laborHourlyRate), onChange: (e) => setLaborHourlyRate(Math.round((parseFloat(e.target.value) || 0) * 100) / 100), type: "number", min: 0 }), _jsx(Input, { label: "Max hours per day", helperText: "Used only to project how many days a shipment's tracked hours will take.", value: String(laborMaxHoursPerDay), onChange: (e) => setLaborMaxHoursPerDay(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 24 })] })] }) }), _jsx(TabsContent, { value: "emi", children: _jsxs(SettingsTabForm, { id: "emi", label: "Emi", children: [_jsx(Text, { size: "xs", color: "muted", children: "A seller must also opt in via their own Payout Settings for EMI to appear at checkout on their items. See the \"How EMI Works\" public page for buyer-facing copy." }), _jsx(Toggle, { checked: emiEnabled, onChange: setEmiEnabled, label: "Enable EMI platform-wide" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Minimum order value (\u20B9)", helperText: "A seller's cart subtotal must exceed this for EMI to appear as an option.", value: String(emiMinOrderValue), onChange: (e) => setEmiMinOrderValue(Math.round((parseFloat(e.target.value) || 0) * 100) / 100), type: "number", min: 0 }), _jsx(Input, { label: "Tenure options (months, comma-separated)", helperText: "e.g. 2,3,4,5,6", value: emiTenureOptionsText, onChange: (e) => setEmiTenureOptionsText(e.target.value) }), _jsx(Input, { label: "Token / down-payment (%)", helperText: "Collected upfront at checkout.", value: String(emiTokenPercent), onChange: (e) => setEmiTokenPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100 }), _jsx(Input, { label: "Billing day (1\u201310)", helperText: "Day of month each installment is due.", value: String(emiBillingDay), onChange: (e) => setEmiBillingDay(parseFloat(e.target.value) || 1), type: "number", min: 1, max: 10 }), _jsx(Input, { label: "Surcharge (% of principal per month)", helperText: "The 'excess EMI fee' the buyer pays for spreading payment.", value: String(emiSurchargePercentPerMonth), onChange: (e) => setEmiSurchargePercentPerMonth(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Seller's share of surcharge (%)", helperText: "The rest of the surcharge goes to the platform.", value: String(emiSurchargeSellerSharePercent), onChange: (e) => setEmiSurchargeSellerSharePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100 })] })] }) }), _jsx(TabsContent, { value: "gst", children: _jsxs(SettingsTabForm, { id: "gst", label: "Gst", children: [_jsx(Text, { size: "xs", color: "muted", children: "Required before enabling GST-inclusive checkout (P-8). Also set a GST rate + HSN code on each taxable product for the tax breakdown to appear." }), _jsx(Toggle, { checked: gstEnabled, onChange: setGstEnabled, label: "Enable GST on checkout" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "GSTIN", helperText: "15-character GST Identification Number.", value: gstin, onChange: (e) => setGstin(e.target.value), placeholder: "29AAAAA0000A1Z5", maxLength: 15 }), _jsx(Input, { label: "Legal name", helperText: "Registered business name for invoices.", value: gstLegalName, onChange: (e) => setGstLegalName(e.target.value), placeholder: "LetItRip Collectibles Pvt Ltd" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Registered address" }), _jsx(Textarea, { value: gstAddress, onChange: (e) => setGstAddress(e.target.value), placeholder: "Registered business address for GST invoices\u2026", rows: 3 })] })] }) }), _jsx(TabsContent, { value: "listings", children: _jsxs(SettingsTabForm, { id: "listings", label: "Listings", children: [_jsx(Text, { size: "xs", color: "muted", children: "Which listing and category types the marketplace offers. A disabled type is hidden from every browse surface and rejected on create and add-to-cart \u2014 existing data is untouched, it just becomes unreachable until re-enabled." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Listing types" }), _jsx(Text, { size: "xs", color: "muted", children: "Disabled types are hidden from listings and reject create/add-to-cart." }), _jsx(Grid, { cols: 2, gap: "sm", children: LISTING_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: listingTypeFlags[key] ?? true, onChange: (v) => setListingTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Category types" }), _jsx(Grid, { cols: 2, gap: "sm", children: CATEGORY_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: categoryTypeFlags[key] ?? true, onChange: (v) => setCategoryTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] })] }) }), _jsx(TabsContent, { value: "integrations", children: _jsxs(SettingsTabForm, { id: "integrations", label: "Integrations", children: [_jsx(Text, { size: "xs", color: "muted", children: "Keys are masked in transit and stored encrypted. Click Reveal to view." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "PhonePe" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "PhonePe Client ID", value: phonepeClientId, onChange: setPhonepeClientId, placeholder: "MERCHANTID_\u2026" }), _jsx(MaskedInput, { label: "PhonePe Client Secret", value: phonepeClientSecret, onChange: setPhonepeClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "PhonePe Client Version", value: phonepeClientVersion, onChange: (e) => setPhonepeClientVersion(e.target.value), placeholder: "1" }), _jsx(Select, { label: "PhonePe Environment", options: PHONEPE_ENVIRONMENT_OPTIONS, value: phonepeEnvironment, onValueChange: setPhonepeEnvironment }), _jsx(MaskedInput, { label: "PhonePe Webhook Username", value: phonepeWebhookUsername, onChange: setPhonepeWebhookUsername, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "PhonePe Webhook Password", value: phonepeWebhookPassword, onChange: setPhonepeWebhookPassword, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "SMTP / Email" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "SMTP host", value: smtpHost, onChange: (e) => setSmtpHost(e.target.value), placeholder: "smtp.sendgrid.net" }), _jsx(Input, { label: "SMTP port", value: smtpPort, onChange: (e) => setSmtpPort(e.target.value), type: "number", placeholder: "587" }), _jsx(Input, { label: "SMTP user", value: smtpUser, onChange: (e) => setSmtpUser(e.target.value), placeholder: "apikey" })] }), _jsx(Input, { label: "From address", value: smtpFrom, onChange: (e) => setSmtpFrom(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Text, { size: "xs", color: "muted", children: "Transactional email is delivered through Resend \u2014 set the Resend API key below. These SMTP fields are stored but not currently used for delivery." })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Google Places (store reviews)" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Google Maps API key", value: googleMapsApiKey, onChange: setGoogleMapsApiKey, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "Default Google Place ID", value: googlePlaceId, onChange: (e) => setGooglePlaceId(e.target.value), placeholder: "ChIJ\u2026" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Analytics & Tracking" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Google Analytics ID", value: gaMeasurementId, onChange: (e) => setGaMeasurementId(e.target.value), placeholder: "G-XXXXXXXXXX" }), _jsx(Input, { label: "Facebook Pixel ID", value: fbPixelId, onChange: (e) => setFbPixelId(e.target.value), placeholder: "XXXXXXXXXXXXXXXX" }), _jsx(Input, { label: "GTM Container ID", value: gtmContainerId, onChange: (e) => setGtmContainerId(e.target.value), placeholder: "GTM-XXXXXXX" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Meta (Instagram & Facebook Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Used by the Social Feed section to fetch Instagram and Facebook posts via Meta Graph API v19." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Page Access Token", value: metaPageAccessToken, onChange: setMetaPageAccessToken, placeholder: "EAAxxxxxxx\u2026" }), _jsx(Input, { label: "Page ID (or handle)", value: metaPageId, onChange: (e) => setMetaPageId(e.target.value), placeholder: "letitrip" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "TikTok for Developers (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials + long-lived access token from TikTok for Developers. Used to list your account's public videos." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client Key", value: tiktokClientKey, onChange: setTiktokClientKey, placeholder: "aw\u2026" }), _jsx(MaskedInput, { label: "Client Secret", value: tiktokClientSecret, onChange: setTiktokClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Access Token (long-lived)", value: tiktokAccessToken, onChange: setTiktokAccessToken, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "DeviantArt OAuth2 (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials for DeviantArt gallery fetching (client-credentials OAuth2 flow \u2014 no user login required)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client ID", value: deviantartClientId, onChange: setDeviantartClientId, placeholder: "1234" }), _jsx(MaskedInput, { label: "Client Secret", value: deviantartClientSecret, onChange: setDeviantartClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] })] }) }), _jsx(TabsContent, { value: "shipping", children: _jsxs(SettingsTabForm, { id: "shipping", label: "Shipping", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Payment methods" }), _jsx(Div, { id: "setting-phonepe-enabled", children: _jsx(Toggle, { label: "PhonePe (online card/UPI) enabled \u2014 disabled by default, manual payment is the default", checked: phonepeEnabled, onChange: setPhonepeEnabled }) }), _jsx(Div, { id: "setting-manual-payment-enabled", children: _jsx(Toggle, { label: "Manual UPI/bank transfer enabled", checked: upiManualEnabled, onChange: setUpiManualEnabled }) }), _jsx(Div, { id: "setting-cod-enabled", children: _jsx(Toggle, { label: "Cash on delivery (COD) enabled", checked: codEnabled, onChange: setCodEnabled }) }), _jsx(Input, { label: "Free shipping threshold (\u20B9)", value: String(freeShippingThreshold), onChange: (e) => setFreeShippingThreshold(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Orders above this amount get free shipping." }), _jsx(Select, { label: "Default carrier", options: CARRIER_OPTIONS, value: defaultCarrier, onValueChange: setDefaultCarrier }), _jsx(Input, { label: "Max delivery radius (km, 0 = no limit)", value: String(maxDeliveryRadius), onChange: (e) => setMaxDeliveryRadius(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "High-value checkout OTP threshold (\u20B9)", helperText: "Carts at or above this subtotal require an extra OTP verification step before payment (0 = disabled).", value: String(otpCheckoutThreshold), onChange: (e) => setOtpCheckoutThreshold(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", className: "pt-[var(--appkit-space-2)]", children: "Seller shipping charge defaults" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Seller shipping fixed (\u20B9)", helperText: "Flat shipping charge collected on the seller's behalf.", value: String(sellerShippingFixed), onChange: (e) => setSellerShippingFixed(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Platform shipping (%)", helperText: "Platform's own shipping markup, as a % of order value.", value: String(platformShippingPercent), onChange: (e) => setPlatformShippingPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "Platform shipping minimum (\u20B9)", helperText: "Floor for the platform shipping markup.", value: String(platformShippingFixedMin), onChange: (e) => setPlatformShippingFixedMin(parseFloat(e.target.value) || 0), type: "number", min: 0 })] })] }) }), _jsx(TabsContent, { value: "auction", children: _jsxs(SettingsTabForm, { id: "auction", label: "Auction", children: [_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Bid increment tiers" }), _jsx(Text, { size: "xs", color: "muted", children: "The minimum jump a new bid must clear, based on the current bid amount. A seller's per-listing override can require more than the matching tier, but can never undercut it." }), bidIncrementTiers.map((tier, i) => {
|
|
805
|
+
], value: watermarkPosition, onValueChange: (v) => setWatermarkPosition(v), disabled: watermarkUseCustomOffset }), _jsx(Toggle, { checked: watermarkUseCustomOffset, onChange: setWatermarkUseCustomOffset, label: "Use a custom X/Y offset instead" }), watermarkUseCustomOffset && (_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "X offset (%) \u2014 + right / \u2212 left of center", value: String(watermarkOffsetX), onChange: (e) => setWatermarkOffsetX(Math.max(-45, Math.min(45, parseInt(e.target.value) || 0))), type: "number", min: -45, max: 45 }), _jsx(Input, { label: "Y offset (%) \u2014 + down / \u2212 up from center", value: String(watermarkOffsetY), onChange: (e) => setWatermarkOffsetY(Math.max(-45, Math.min(45, parseInt(e.target.value) || 0))), type: "number", min: -45, max: 45 })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: "Preview (text watermark only)" }), _jsx(Row, { surface: "default", justify: "end", align: "end", className: `relative h-32 ${__O.hidden}`, rounded: "default", children: _jsx(Span, { ref: watermarkPreviewRef, weight: "medium", color: "faint", padding: "1", className: "select-none", children: watermarkText }) })] })] }) }), _jsx(TabsContent, { value: "fees", children: _jsxs(SettingsTabForm, { id: "fees", label: "Fees", children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Platform fee \u2014 our cut (%)", helperText: "% charged on order value. Buyer pays this.", value: String(platformFeePercent), onChange: (e) => setPlatformFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "GST on platform fee (%)", helperText: "Applied to our fee only (not full order). Usually 18%.", value: String(gstPercent), onChange: (e) => setGstPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "PhonePe gateway cost (%)", helperText: "Gateway's own fee \u2014 absorbed by platform, not passed through.", value: String(gatewayFeePercent), onChange: (e) => setGatewayFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 10, step: 0.01 }), _jsx(Input, { label: "Minimum transaction fee (\u20B9)", helperText: "Per-transaction floor. Total charge will never be below base + this.", value: String(minimumTransactionFee), onChange: (e) => setMinimumTransactionFee(parseFloat(e.target.value) || 0), type: "number", min: 0, step: 0.01 }), _jsx(Input, { label: "Maximum platform fee (\u20B9)", helperText: "Ceiling on the buyer's platform commission, charged once per checkout on every payment method. GST is calculated on the capped amount.", value: String(platformFeeMax), onChange: (e) => setPlatformFeeMax(parseFloat(e.target.value) || 0), type: "number", min: 0, step: 0.01 }), _jsx(Input, { label: "Seller payout hold (days)", value: String(payoutHoldDays), onChange: (e) => setPayoutHoldDays(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Minimum payout amount (\u20B9)", value: String(minPayoutAmount), onChange: (e) => setMinPayoutAmount(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Auction listing fee (\u20B9)", value: String(auctionListingFee), onChange: (e) => setAuctionListingFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Pre-order listing fee (\u20B9)", value: String(preOrderListingFee), onChange: (e) => setPreOrderListingFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Featured slot fee (\u20B9)", value: String(featuredSlotFee), onChange: (e) => setFeaturedSlotFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Promoted slot fee (\u20B9)", value: String(promotedSlotFee), onChange: (e) => setPromotedSlotFee(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "COD deposit (%)", helperText: "% of order collected upfront on COD orders; remainder paid on delivery.", value: String(codDepositPercent), onChange: (e) => setCodDepositPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 })] }), _jsx(Toggle, { checked: whatsappNotifyFeeEnabled, onChange: setWhatsappNotifyFeeEnabled, label: "Offer the WhatsApp order-updates addon at checkout" }), _jsx(Grid, { cols: 2, gap: "md", children: _jsx(Input, { label: "WhatsApp addon fee (\u20B9)", helperText: "Flat fee charged to the buyer when they opt in. Requires WhatsApp credentials + at least one approved template \u2014 see the WhatsApp tab.", value: String(whatsappNotifyFee), onChange: (e) => setWhatsappNotifyFee(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !whatsappNotifyFeeEnabled }) }), _jsx(Toggle, { checked: giftWrapFeeEnabled, onChange: setGiftWrapFeeEnabled, label: "Offer the gift-wrap addon at checkout" }), _jsx(Grid, { cols: 2, gap: "md", children: _jsx(Input, { label: "Gift wrap fee (\u20B9)", helperText: "Flat fee charged to the buyer when they opt in.", value: String(giftWrapFee), onChange: (e) => setGiftWrapFee(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !giftWrapFeeEnabled }) }), _jsx(Toggle, { checked: shipmentProtectionFeeEnabled, onChange: setShipmentProtectionFeeEnabled, label: "Offer the shipment-protection addon at checkout" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Shipment protection (%)", helperText: "% of order subtotal, charged when the buyer opts in.", value: String(shipmentProtectionFeePercent), onChange: (e) => setShipmentProtectionFeePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1, disabled: !shipmentProtectionFeeEnabled }), _jsx(Input, { label: "Shipment protection minimum (\u20B9)", helperText: "Floor for the shipment-protection fee.", value: String(shipmentProtectionFeeMin), onChange: (e) => setShipmentProtectionFeeMin(parseFloat(e.target.value) || 0), type: "number", min: 0, disabled: !shipmentProtectionFeeEnabled })] })] }) }), _jsx(TabsContent, { value: "procurement", children: _jsxs(SettingsTabForm, { id: "procurement", label: "Procurement", children: [_jsx(Text, { size: "xs", color: "muted", children: "Used to compute each procurement shipment's labor cost (hours spent \u00D7 hourly rate) and its estimated processing time (hours spent \u00F7 max hours/day)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Hourly rate (\u20B9)", helperText: "Your effective hourly wage for sorting/categorizing/listing a shipment.", value: String(laborHourlyRate), onChange: (e) => setLaborHourlyRate(Math.round((parseFloat(e.target.value) || 0) * 100) / 100), type: "number", min: 0 }), _jsx(Input, { label: "Max hours per day", helperText: "Used only to project how many days a shipment's tracked hours will take.", value: String(laborMaxHoursPerDay), onChange: (e) => setLaborMaxHoursPerDay(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 24 })] })] }) }), _jsx(TabsContent, { value: "emi", children: _jsxs(SettingsTabForm, { id: "emi", label: "Emi", children: [_jsx(Text, { size: "xs", color: "muted", children: "A seller must also opt in via their own Payout Settings for EMI to appear at checkout on their items. See the \"How EMI Works\" public page for buyer-facing copy." }), _jsx(Toggle, { checked: emiEnabled, onChange: setEmiEnabled, label: "Enable EMI platform-wide" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Minimum order value (\u20B9)", helperText: "A seller's cart subtotal must exceed this for EMI to appear as an option.", value: String(emiMinOrderValue), onChange: (e) => setEmiMinOrderValue(Math.round((parseFloat(e.target.value) || 0) * 100) / 100), type: "number", min: 0 }), _jsx(Input, { label: "Tenure options (months, comma-separated)", helperText: "e.g. 2,3,4,5,6", value: emiTenureOptionsText, onChange: (e) => setEmiTenureOptionsText(e.target.value) }), _jsx(Input, { label: "Token / down-payment (%)", helperText: "Collected upfront at checkout.", value: String(emiTokenPercent), onChange: (e) => setEmiTokenPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100 }), _jsx(Input, { label: "Billing day (1\u201310)", helperText: "Day of month each installment is due.", value: String(emiBillingDay), onChange: (e) => setEmiBillingDay(parseFloat(e.target.value) || 1), type: "number", min: 1, max: 10 }), _jsx(Input, { label: "Surcharge (% of principal per month)", helperText: "The 'excess EMI fee' the buyer pays for spreading payment.", value: String(emiSurchargePercentPerMonth), onChange: (e) => setEmiSurchargePercentPerMonth(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Seller's share of surcharge (%)", helperText: "The rest of the surcharge goes to the platform.", value: String(emiSurchargeSellerSharePercent), onChange: (e) => setEmiSurchargeSellerSharePercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100 })] })] }) }), _jsx(TabsContent, { value: "gst", children: _jsxs(SettingsTabForm, { id: "gst", label: "Gst", children: [_jsx(Text, { size: "xs", color: "muted", children: "Required before enabling GST-inclusive checkout (P-8). Also set a GST rate + HSN code on each taxable product for the tax breakdown to appear." }), _jsx(Toggle, { checked: gstEnabled, onChange: setGstEnabled, label: "Enable GST on checkout" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "GSTIN", helperText: "15-character GST Identification Number.", error: gstProblems.gstin, value: gstin, onChange: (e) => setGstin(e.target.value), placeholder: "29AAAAA0000A1Z5", maxLength: 15 }), _jsx(Input, { label: "Legal name", helperText: "Registered business name for invoices.", error: gstProblems.legalName, value: gstLegalName, onChange: (e) => setGstLegalName(e.target.value), placeholder: "LetItRip Collectibles Pvt Ltd" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Registered address" }), _jsx(Textarea, { value: gstAddress, onChange: (e) => setGstAddress(e.target.value), placeholder: "Registered business address for GST invoices\u2026", rows: 3 }), gstProblems.address ? (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: gstProblems.address })) : null] })] }) }), _jsx(TabsContent, { value: "listings", children: _jsxs(SettingsTabForm, { id: "listings", label: "Listings", children: [_jsx(Text, { size: "xs", color: "muted", children: "Which listing and category types the marketplace offers. A disabled type is hidden from every browse surface and rejected on create and add-to-cart \u2014 existing data is untouched, it just becomes unreachable until re-enabled." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Listing types" }), _jsx(Text, { size: "xs", color: "muted", children: "Disabled types are hidden from listings and reject create/add-to-cart." }), _jsx(Grid, { cols: 2, gap: "sm", children: LISTING_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: listingTypeFlags[key] ?? true, onChange: (v) => setListingTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Category types" }), _jsx(Grid, { cols: 2, gap: "sm", children: CATEGORY_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: categoryTypeFlags[key] ?? true, onChange: (v) => setCategoryTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] })] }) }), _jsx(TabsContent, { value: "integrations", children: _jsxs(SettingsTabForm, { id: "integrations", label: "Integrations", children: [_jsx(Text, { size: "xs", color: "muted", children: "Keys are masked in transit and stored encrypted. Click Reveal to view." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "PhonePe" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "PhonePe Client ID", value: phonepeClientId, onChange: setPhonepeClientId, placeholder: "MERCHANTID_\u2026" }), _jsx(MaskedInput, { label: "PhonePe Client Secret", value: phonepeClientSecret, onChange: setPhonepeClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "PhonePe Client Version", value: phonepeClientVersion, onChange: (e) => setPhonepeClientVersion(e.target.value), placeholder: "1" }), _jsx(Select, { label: "PhonePe Environment", options: PHONEPE_ENVIRONMENT_OPTIONS, value: phonepeEnvironment, onValueChange: setPhonepeEnvironment }), _jsx(MaskedInput, { label: "PhonePe Webhook Username", value: phonepeWebhookUsername, onChange: setPhonepeWebhookUsername, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "PhonePe Webhook Password", value: phonepeWebhookPassword, onChange: setPhonepeWebhookPassword, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "SMTP / Email" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "SMTP host", value: smtpHost, onChange: (e) => setSmtpHost(e.target.value), placeholder: "smtp.sendgrid.net" }), _jsx(Input, { label: "SMTP port", value: smtpPort, onChange: (e) => setSmtpPort(e.target.value), type: "number", placeholder: "587" }), _jsx(Input, { label: "SMTP user", value: smtpUser, onChange: (e) => setSmtpUser(e.target.value), placeholder: "apikey" })] }), _jsx(Input, { label: "From address", value: smtpFrom, onChange: (e) => setSmtpFrom(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Text, { size: "xs", color: "muted", children: "Transactional email is delivered through Resend \u2014 set the Resend API key below. These SMTP fields are stored but not currently used for delivery." })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Google Places (store reviews)" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Google Maps API key", value: googleMapsApiKey, onChange: setGoogleMapsApiKey, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "Default Google Place ID", value: googlePlaceId, onChange: (e) => setGooglePlaceId(e.target.value), placeholder: "ChIJ\u2026" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Analytics & Tracking" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Google Analytics ID", value: gaMeasurementId, onChange: (e) => setGaMeasurementId(e.target.value), placeholder: "G-XXXXXXXXXX" }), _jsx(Input, { label: "Facebook Pixel ID", value: fbPixelId, onChange: (e) => setFbPixelId(e.target.value), placeholder: "XXXXXXXXXXXXXXXX" }), _jsx(Input, { label: "GTM Container ID", value: gtmContainerId, onChange: (e) => setGtmContainerId(e.target.value), placeholder: "GTM-XXXXXXX" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Meta (Instagram & Facebook Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Used by the Social Feed section to fetch Instagram and Facebook posts via Meta Graph API v19." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Page Access Token", value: metaPageAccessToken, onChange: setMetaPageAccessToken, placeholder: "EAAxxxxxxx\u2026" }), _jsx(Input, { label: "Page ID (or handle)", value: metaPageId, onChange: (e) => setMetaPageId(e.target.value), placeholder: "letitrip" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "TikTok for Developers (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials + long-lived access token from TikTok for Developers. Used to list your account's public videos." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client Key", value: tiktokClientKey, onChange: setTiktokClientKey, placeholder: "aw\u2026" }), _jsx(MaskedInput, { label: "Client Secret", value: tiktokClientSecret, onChange: setTiktokClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Access Token (long-lived)", value: tiktokAccessToken, onChange: setTiktokAccessToken, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "DeviantArt OAuth2 (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials for DeviantArt gallery fetching (client-credentials OAuth2 flow \u2014 no user login required)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client ID", value: deviantartClientId, onChange: setDeviantartClientId, placeholder: "1234" }), _jsx(MaskedInput, { label: "Client Secret", value: deviantartClientSecret, onChange: setDeviantartClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] })] }) }), _jsx(TabsContent, { value: "shipping", children: _jsxs(SettingsTabForm, { id: "shipping", label: "Shipping", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Payment methods" }), _jsx(Div, { id: "setting-phonepe-enabled", children: _jsx(Toggle, { label: "PhonePe (online card/UPI) enabled \u2014 disabled by default, manual payment is the default", checked: phonepeEnabled, onChange: setPhonepeEnabled }) }), _jsx(Div, { id: "setting-manual-payment-enabled", children: _jsx(Toggle, { label: "Manual UPI/bank transfer enabled", checked: upiManualEnabled, onChange: setUpiManualEnabled }) }), _jsx(Div, { id: "setting-cod-enabled", children: _jsx(Toggle, { label: "Cash on delivery (COD) enabled", checked: codEnabled, onChange: setCodEnabled }) }), _jsx(Input, { label: "Free shipping threshold (\u20B9)", value: String(freeShippingThreshold), onChange: (e) => setFreeShippingThreshold(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Orders above this amount get free shipping." }), _jsx(Select, { label: "Default carrier", options: CARRIER_OPTIONS, value: defaultCarrier, onValueChange: setDefaultCarrier }), _jsx(Input, { label: "Max delivery radius (km, 0 = no limit)", value: String(maxDeliveryRadius), onChange: (e) => setMaxDeliveryRadius(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "High-value checkout OTP threshold (\u20B9)", helperText: "Carts at or above this subtotal require an extra OTP verification step before payment (0 = disabled).", value: String(otpCheckoutThreshold), onChange: (e) => setOtpCheckoutThreshold(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", className: "pt-[var(--appkit-space-2)]", children: "Seller shipping charge defaults" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Seller shipping fixed (\u20B9)", helperText: "Flat shipping charge collected on the seller's behalf.", value: String(sellerShippingFixed), onChange: (e) => setSellerShippingFixed(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Platform shipping (%)", helperText: "Platform's own shipping markup, as a % of order value.", value: String(platformShippingPercent), onChange: (e) => setPlatformShippingPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "Platform shipping minimum (\u20B9)", helperText: "Floor for the platform shipping markup.", value: String(platformShippingFixedMin), onChange: (e) => setPlatformShippingFixedMin(parseFloat(e.target.value) || 0), type: "number", min: 0 })] })] }) }), _jsx(TabsContent, { value: "auction", children: _jsxs(SettingsTabForm, { id: "auction", label: "Auction", children: [_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Bid increment tiers" }), _jsx(Text, { size: "xs", color: "muted", children: "The minimum jump a new bid must clear, based on the current bid amount. A seller's per-listing override can require more than the matching tier, but can never undercut it." }), bidIncrementTiers.map((tier, i) => {
|
|
787
806
|
const isLast = i === bidIncrementTiers.length - 1;
|
|
788
807
|
return (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: isLast ? (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Up to (\u20B9)" }), _jsx(Text, { size: "sm", color: "muted", children: "and above" })] })) : (_jsx(Input, { label: "Up to (\u20B9)", type: "number", min: 1, value: String(tier.upTo ?? ""), onChange: (e) => setBidIncrementTiers(bidIncrementTiers.map((t, j) => j === i ? { ...t, upTo: parseInt(e.target.value) || 0 } : t)) })) }), _jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Increment (\u20B9)", type: "number", min: 1, value: String(tier.increment), onChange: (e) => setBidIncrementTiers(bidIncrementTiers.map((t, j) => j === i ? { ...t, increment: parseInt(e.target.value) || 0 } : t)) }) }), _jsx(Row, { centered: true, className: "col-span-2", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", disabled: bidIncrementTiers.length <= 1, onClick: () => setBidIncrementTiers(bidIncrementTiers.filter((_, j) => j !== i)), "aria-label": "Remove tier", children: "\u00D7" }) })] }, i));
|
|
789
808
|
}), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setBidIncrementTiers([
|