@mohasinac/appkit 4.37.1 → 4.38.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.
Files changed (126) hide show
  1. package/dist/_internal/server/features/bundles/og.d.ts +0 -1
  2. package/dist/_internal/server/features/bundles/og.js +2 -18
  3. package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
  4. package/dist/_internal/server/features/catalogue/og.js +1 -9
  5. package/dist/_internal/server/features/classified/og.d.ts +0 -1
  6. package/dist/_internal/server/features/classified/og.js +1 -9
  7. package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
  8. package/dist/_internal/server/features/digital-code/og.js +1 -9
  9. package/dist/_internal/server/features/grouped/og.d.ts +7 -4
  10. package/dist/_internal/server/features/grouped/og.js +7 -6
  11. package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
  12. package/dist/_internal/server/features/item-requests/og.js +2 -18
  13. package/dist/_internal/server/features/live/og.d.ts +0 -1
  14. package/dist/_internal/server/features/live/og.js +1 -9
  15. package/dist/_internal/server/features/prize-draws/og.js +5 -10
  16. package/dist/_internal/server/features/products/og.js +5 -2
  17. package/dist/_internal/server/features/seo/sitemap.js +10 -2
  18. package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
  19. package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
  20. package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
  21. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
  22. package/dist/_internal/shared/listing-types/art/config.js +2 -0
  23. package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
  24. package/dist/_internal/shared/listing-types/auction/config.js +3 -0
  25. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
  26. package/dist/_internal/shared/listing-types/classified/config.js +3 -0
  27. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
  28. package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
  29. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
  30. package/dist/_internal/shared/listing-types/live/config.js +3 -0
  31. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
  32. package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
  33. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
  34. package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
  35. package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
  36. package/dist/_internal/shared/listing-types/standard/config.js +2 -0
  37. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
  38. package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
  39. package/dist/client/observability/reportClientError.js +11 -1
  40. package/dist/client.d.ts +4 -0
  41. package/dist/client.js +4 -0
  42. package/dist/core/hooks/useSyncManager.js +11 -1
  43. package/dist/features/account/hooks/useNotifications.js +25 -5
  44. package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
  45. package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
  46. package/dist/features/auctions/components/LiveBidPrice.js +2 -2
  47. package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
  48. package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
  49. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
  50. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  51. package/dist/features/auctions/hooks/useAuctions.js +12 -1
  52. package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
  53. package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
  54. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
  55. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
  56. package/dist/features/categories/components/BundleCollage.js +10 -5
  57. package/dist/features/categories/components/BundleDetailView.js +7 -3
  58. package/dist/features/categories/components/CategoryProductsListing.js +4 -1
  59. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  60. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  61. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
  62. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  63. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  64. package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
  65. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
  66. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
  67. package/dist/features/products/actions/product-actions.d.ts +9 -0
  68. package/dist/features/products/actions/product-actions.js +11 -0
  69. package/dist/features/products/columns/productTableColumns.js +2 -2
  70. package/dist/features/products/components/AuctionsIndexListing.js +4 -1
  71. package/dist/features/products/components/BidHistory.js +2 -2
  72. package/dist/features/products/components/CompareOverlay.js +2 -2
  73. package/dist/features/products/components/GroupMemberPicker.js +5 -5
  74. package/dist/features/products/components/ListingBottomActions.js +12 -3
  75. package/dist/features/products/components/MakeOfferButton.js +7 -1
  76. package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
  77. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
  78. package/dist/features/products/components/PrizeDrawCollage.js +16 -5
  79. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  80. package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
  81. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  82. package/dist/features/products/components/ProductDetailActions.js +10 -3
  83. package/dist/features/products/components/ProductFilters.js +2 -2
  84. package/dist/features/products/components/ProductGrid.js +5 -2
  85. package/dist/features/products/components/ProductsIndexListing.js +12 -1
  86. package/dist/features/products/components/ProductsIndexPageView.js +26 -0
  87. package/dist/features/products/components/ShowGroupSection.js +2 -2
  88. package/dist/features/products/components/SublistingCarouselSection.js +2 -2
  89. package/dist/features/products/constants/listing-tabs.d.ts +36 -12
  90. package/dist/features/products/constants/listing-tabs.js +38 -14
  91. package/dist/features/products/constants/sieve.d.ts +14 -0
  92. package/dist/features/products/constants/sieve.js +39 -0
  93. package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
  94. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
  95. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
  96. package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
  98. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
  99. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
  100. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
  101. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
  102. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
  103. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
  104. package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
  105. package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
  107. package/dist/features/wishlist/components/WishlistPage.js +2 -2
  108. package/dist/index.d.ts +2 -1
  109. package/dist/index.js +4 -1
  110. package/dist/react/contexts/SessionContext.js +40 -1
  111. package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
  112. package/dist/react/hooks/useCanSeePrices.js +40 -0
  113. package/dist/seed/addresses-seed-data.js +36 -2
  114. package/dist/seed/users-seed-data.js +10 -2
  115. package/dist/seo/json-ld.d.ts +21 -0
  116. package/dist/seo/json-ld.js +45 -0
  117. package/dist/styles.css +41 -0
  118. package/dist/ui/components/GatedPrice.d.ts +66 -0
  119. package/dist/ui/components/GatedPrice.js +80 -0
  120. package/dist/ui/components/GatedPrice.style.css +40 -0
  121. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  122. package/dist/ui/components/PriceDisplay.js +17 -17
  123. package/dist/ui/components/index.style.css +1 -0
  124. package/dist/ui/index.d.ts +2 -0
  125. package/dist/ui/index.js +1 -0
  126. package/package.json +1 -1
@@ -19,7 +19,22 @@ const DEFAULT_ENDPOINTS = {
19
19
  // ---------------------------------------------------------------------------
20
20
  const SESSION_COOKIE = "__session";
21
21
  const SESSION_ID_COOKIE = "__session_id";
22
- const ACTIVITY_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
22
+ /**
23
+ * How often a VISIBLE tab pings session activity and re-reads RBAC flags.
24
+ *
25
+ * Each tick costs two function invocations (`sessionActivity`, then the
26
+ * piggybacked profile re-fetch below), per open tab, wall-clock rather than
27
+ * activity-gated. At the original 5 minutes that was 24 invocations/hour for
28
+ * every tab any signed-in user had left open, and it contributed to the Vercel
29
+ * overage that paused the account (2026-09-14).
30
+ *
31
+ * 15 minutes is safe because it is a BACKSTOP, not the primary freshness
32
+ * mechanism: `RoleGuard` calls `refreshUser()` on every mount of a role-gated
33
+ * layout, so navigating into /admin, /store or /user re-reads the flags
34
+ * immediately, and the visibility handler below re-reads them the moment a
35
+ * backgrounded tab is looked at again.
36
+ */
37
+ const ACTIVITY_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
23
38
  // ---------------------------------------------------------------------------
24
39
  // Context
25
40
  // ---------------------------------------------------------------------------
@@ -166,6 +181,14 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
166
181
  const currentSessionId = getSessionIdFromCookie();
167
182
  if (!currentSessionId || !user)
168
183
  return;
184
+ // A hidden tab is not "activity", so reporting it as such is both wasteful
185
+ // and wrong — an abandoned background tab used to keep both requests below
186
+ // firing indefinitely. The visibility handler re-runs this immediately when
187
+ // the tab is looked at again, so nothing is merely deferred into staleness.
188
+ if (typeof document !== "undefined" &&
189
+ document.visibilityState === "hidden") {
190
+ return;
191
+ }
169
192
  try {
170
193
  await apiClient.post(ep.sessionActivity, {
171
194
  sessionId: currentSessionId,
@@ -203,6 +226,22 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
203
226
  useEffect(() => {
204
227
  updateSessionActivityRef.current = updateSessionActivity;
205
228
  }, [updateSessionActivity]);
229
+ // Re-read session activity and RBAC flags the moment a backgrounded tab is
230
+ // looked at again. This is what lets the interval above be 15 minutes and the
231
+ // hidden-tab skip be safe: the user-visible freshness guarantee is now "fresh
232
+ // whenever you are actually looking at it", which is strictly better than the
233
+ // old fixed 5-minute tick that fired whether anyone was there or not.
234
+ useEffect(() => {
235
+ if (typeof document === "undefined")
236
+ return;
237
+ const onVisible = () => {
238
+ if (document.visibilityState === "visible") {
239
+ void updateSessionActivityRef.current?.();
240
+ }
241
+ };
242
+ document.addEventListener("visibilitychange", onVisible);
243
+ return () => document.removeEventListener("visibilitychange", onVisible);
244
+ }, []);
206
245
  // -----------------------------------------------------------------------
207
246
  // Refresh helpers
208
247
  // -----------------------------------------------------------------------
@@ -0,0 +1,37 @@
1
+ export interface CanSeePricesState {
2
+ /** The viewer is signed in and may be shown money amounts. */
3
+ canSeePrices: boolean;
4
+ /**
5
+ * The session has not resolved yet. Render a neutral placeholder while this
6
+ * is true — NEVER the sign-in prompt. See the note below.
7
+ */
8
+ isAuthResolving: boolean;
9
+ }
10
+ /**
11
+ * May the current viewer be shown money amounts on a public listing surface?
12
+ *
13
+ * Prices, current bids, deposits and per-entry amounts are hidden from
14
+ * signed-out visitors so the catalogue cannot be read at scale without an
15
+ * account. Signed-in users see everything exactly as before.
16
+ *
17
+ * ## Why there are THREE states, not two
18
+ *
19
+ * `SessionProvider` is mounted `initialUser={null}` because the root locale
20
+ * layout must stay static (Root Cause #82), so `loading` starts `true` on every
21
+ * hard load — for signed-in users too, until Firebase persistence resolves and
22
+ * `/api/user/profile` returns. A naive `!user` test therefore renders
23
+ * "Sign in to see price" at an already-signed-in user for several hundred
24
+ * milliseconds on every page load.
25
+ *
26
+ * So: `isAuthResolving` means "don't know yet" and must render a neutral
27
+ * placeholder. Only `canSeePrices === false && isAuthResolving === false` is a
28
+ * confirmed guest.
29
+ *
30
+ * ## Why `useOptionalSession`
31
+ *
32
+ * This is library code that ADJUSTS for the viewer rather than requiring one.
33
+ * `useSession()` throws without a provider, which would turn an optional
34
+ * refinement into a breaking contract for every consumer (Root Cause #20). With
35
+ * no provider we fail CLOSED — no session means no prices.
36
+ */
37
+ export declare function useCanSeePrices(): CanSeePricesState;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { useOptionalSession } from "../contexts/SessionContext";
3
+ /**
4
+ * May the current viewer be shown money amounts on a public listing surface?
5
+ *
6
+ * Prices, current bids, deposits and per-entry amounts are hidden from
7
+ * signed-out visitors so the catalogue cannot be read at scale without an
8
+ * account. Signed-in users see everything exactly as before.
9
+ *
10
+ * ## Why there are THREE states, not two
11
+ *
12
+ * `SessionProvider` is mounted `initialUser={null}` because the root locale
13
+ * layout must stay static (Root Cause #82), so `loading` starts `true` on every
14
+ * hard load — for signed-in users too, until Firebase persistence resolves and
15
+ * `/api/user/profile` returns. A naive `!user` test therefore renders
16
+ * "Sign in to see price" at an already-signed-in user for several hundred
17
+ * milliseconds on every page load.
18
+ *
19
+ * So: `isAuthResolving` means "don't know yet" and must render a neutral
20
+ * placeholder. Only `canSeePrices === false && isAuthResolving === false` is a
21
+ * confirmed guest.
22
+ *
23
+ * ## Why `useOptionalSession`
24
+ *
25
+ * This is library code that ADJUSTS for the viewer rather than requiring one.
26
+ * `useSession()` throws without a provider, which would turn an optional
27
+ * refinement into a breaking contract for every consumer (Root Cause #20). With
28
+ * no provider we fail CLOSED — no session means no prices.
29
+ */
30
+ export function useCanSeePrices() {
31
+ const session = useOptionalSession();
32
+ // No provider: fail closed, and don't claim to be mid-resolution — there is
33
+ // nothing in flight that will ever change the answer.
34
+ if (!session)
35
+ return { canSeePrices: false, isAuthResolving: false };
36
+ return {
37
+ canSeePrices: Boolean(session.user?.uid),
38
+ isAuthResolving: session.loading,
39
+ };
40
+ }
@@ -3,12 +3,12 @@
3
3
  * addresses live entirely in store-addresses-seed-data.ts (merged in at the API route
4
4
  * level) — this file only carries ownerType:"user" entries now, to avoid seeding two
5
5
  * overlapping sources of store address data.
6
- * WHAT: Exports 3 user addresses. Top-level collection (SB-UNI-A) with ownerType discriminator.
6
+ * WHAT: Exports 4 user addresses. Top-level collection (SB-UNI-A) with ownerType discriminator.
7
7
  * PII encrypted via HMAC blind indices (emailIndex, phoneIndex). Composite indexes
8
8
  * (ownerType, ownerId, createdAt desc) + (ownerType, ownerId, isDefault).
9
9
  *
10
10
  * EXPORTS:
11
- * addressesSeedData — Array of 3 user address documents
11
+ * addressesSeedData — Array of 4 user address documents
12
12
  *
13
13
  * @tag domain:addresses,shipping
14
14
  * @tag layer:seed
@@ -74,5 +74,39 @@ const _rawAddressesSeedData = [
74
74
  createdAt: daysAgo(365),
75
75
  updatedAt: daysAgo(5),
76
76
  },
77
+ /*
78
+ * Automated checklist runner — delivery address.
79
+ *
80
+ * 🛑 THIS ROW IS WHY CHECKOUT CAN BE TESTED AT ALL.
81
+ *
82
+ * `user-claude-tester` had no address, so /checkout stopped dead at "Step 1 of 3:
83
+ * Shipping Address — No saved addresses yet." Every order, payment, coupon-at-placement,
84
+ * offer-checkout and forfeited-bid case died there: 117 blocked cases, 27% of everything
85
+ * the run could not test, and the single largest blocker by a wide margin (measured,
86
+ * run 1789300124915).
87
+ *
88
+ * It has to be SEEDED rather than created by the runner mid-run. `addresses` is PRESERVE
89
+ * tier in tester/scripts/lib/collections.mjs — teardown deliberately never touches it,
90
+ * because real people's saved addresses live there — so a row the runner created at
91
+ * runtime would survive every future run with nothing owning its cleanup. Seeded, it is
92
+ * idempotent: `appkit-seed load` upserts this exact id, and re-running changes nothing.
93
+ */
94
+ {
95
+ id: "addr-claude-tester-home",
96
+ ownerType: "user",
97
+ ownerId: "user-claude-tester",
98
+ label: "Home",
99
+ fullName: "Claude (automated)",
100
+ phone: "+91-99999-00019",
101
+ addressLine1: "1 Automation Street",
102
+ addressLine2: "Koramangala",
103
+ city: "Bengaluru",
104
+ state: "Karnataka",
105
+ postalCode: "560034",
106
+ country: "India",
107
+ isDefault: true,
108
+ createdAt: daysAgo(1),
109
+ updatedAt: daysAgo(1),
110
+ },
77
111
  ];
78
112
  export const addressesSeedData = _rawAddressesSeedData;
@@ -546,8 +546,16 @@ export const usersSeedData = [
546
546
  isBot: true,
547
547
  emailVerified: true,
548
548
  disabled: false,
549
- storeId: "store-tester-qa-seller",
550
- storeSlug: "store-tester-qa-seller",
549
+ // 🛑 This MUST name a store whose own `ownerId` is this uid. It used to say
550
+ // "store-tester-qa-seller", which is owned by `user-tester-qa` — so the app, which
551
+ // resolves sellers by `ownerId`, answered every /api/store/* call with 403
552
+ // "No store found for this account" while the client, reading `role` and `storeId`
553
+ // off this document, rendered the entire Store Panel over it. 36 checklist cases were
554
+ // blocked by that one-way pointer (measured, run 1789300124915).
555
+ // See stores-tester-seed-data.ts for why the shared sandbox could not simply be
556
+ // repointed at this account instead.
557
+ storeId: "store-claude-tester",
558
+ storeSlug: "store-claude-tester",
551
559
  storeStatus: "approved",
552
560
  publicProfile: {
553
561
  isPublic: false,
@@ -84,6 +84,27 @@ export interface BlogPostJsonLdInput {
84
84
  metaTitle?: string;
85
85
  metaDescription?: string;
86
86
  }
87
+ /**
88
+ * Declares that this page's price is behind a sign-in gate.
89
+ *
90
+ * Prices are hidden from signed-out visitors, and Googlebot crawls signed out —
91
+ * so the crawler sees "Sign in to see price" while the Product JSON-LD beside it
92
+ * declares a real `offers.price`. Left undeclared, that is a structured-data
93
+ * mismatch: Google requires marked-up content to be visible to the user, and an
94
+ * unexplained gap can cost the rich result.
95
+ *
96
+ * `isAccessibleForFree: false` + `hasPart`/`cssSelector` is the supported way to
97
+ * say "the markup is richer than what an anonymous visitor sees". It lives on a
98
+ * `WebPage` node rather than on the Product, because `isAccessibleForFree` is a
99
+ * `CreativeWork` property and `Product` is not a `CreativeWork`.
100
+ *
101
+ * Serving the price to crawler user-agents but not to people would be cloaking,
102
+ * which is a harder violation; this declares the gate instead of hiding it.
103
+ *
104
+ * 🛑 Emit this on EVERY page that emits `offers.price`. The audit enforces the
105
+ * pairing precisely so nobody removes one half later.
106
+ */
107
+ export declare function gatedPriceWebPageJsonLd(path: string): Record<string, JsonLdValue>;
87
108
  export declare function productJsonLd(product: ProductJsonLdInput): Record<string, JsonLdValue>;
88
109
  export declare function reviewJsonLd(review: ReviewJsonLdInput): Record<string, JsonLdValue>;
89
110
  export declare function aggregateRatingJsonLd(product: Pick<ProductJsonLdInput, "title" | "slug">, stats: {
@@ -39,6 +39,51 @@ function absoluteUrl(value, base = SITE_URL) {
39
39
  import { ProductStatusValues } from "../features/products/schemas";
40
40
  import { getDefaultCurrency } from "../core/baseline-resolver";
41
41
  // --- Helpers -----------------------------------------------------------------
42
+ /**
43
+ * The CSS selector the price gate renders in every state.
44
+ *
45
+ * 🛑 Must equal `GATED_PRICE_CLASS` in `ui/components/GatedPrice.tsx`. It is
46
+ * duplicated as a literal here rather than imported because `seo/` is reached
47
+ * from server-only metadata paths and must not pull in a `"use client"` module
48
+ * (Root Cause #76). `audit-guest-price-leak` cross-checks the two.
49
+ */
50
+ const GATED_PRICE_SELECTOR = ".appkit-gated-price";
51
+ /**
52
+ * Declares that this page's price is behind a sign-in gate.
53
+ *
54
+ * Prices are hidden from signed-out visitors, and Googlebot crawls signed out —
55
+ * so the crawler sees "Sign in to see price" while the Product JSON-LD beside it
56
+ * declares a real `offers.price`. Left undeclared, that is a structured-data
57
+ * mismatch: Google requires marked-up content to be visible to the user, and an
58
+ * unexplained gap can cost the rich result.
59
+ *
60
+ * `isAccessibleForFree: false` + `hasPart`/`cssSelector` is the supported way to
61
+ * say "the markup is richer than what an anonymous visitor sees". It lives on a
62
+ * `WebPage` node rather than on the Product, because `isAccessibleForFree` is a
63
+ * `CreativeWork` property and `Product` is not a `CreativeWork`.
64
+ *
65
+ * Serving the price to crawler user-agents but not to people would be cloaking,
66
+ * which is a harder violation; this declares the gate instead of hiding it.
67
+ *
68
+ * 🛑 Emit this on EVERY page that emits `offers.price`. The audit enforces the
69
+ * pairing precisely so nobody removes one half later.
70
+ */
71
+ export function gatedPriceWebPageJsonLd(path) {
72
+ return {
73
+ "@context": "https://schema.org",
74
+ "@type": "WebPage",
75
+ // Absolutised against the ONE canonical host, never a second definition of
76
+ // it (Root Cause #81) — a WebPage node naming a host the sitemap does not
77
+ // advertise is the same split-identity bug in miniature.
78
+ url: absoluteUrl(path),
79
+ isAccessibleForFree: false,
80
+ hasPart: {
81
+ "@type": "WebPageElement",
82
+ isAccessibleForFree: false,
83
+ cssSelector: GATED_PRICE_SELECTOR,
84
+ },
85
+ };
86
+ }
42
87
  export function productJsonLd(product) {
43
88
  const url = `${SITE_URL}/products/${product.slug}`;
44
89
  // Absolutise, then de-duplicate: `mainImage` is very often also `images[0]`,
package/dist/styles.css CHANGED
@@ -3295,6 +3295,47 @@
3295
3295
  color: var(--appkit-color-emerald-300);
3296
3296
  }
3297
3297
 
3298
+ /**
3299
+ * Sign-in gate for money amounts on public listing surfaces.
3300
+ *
3301
+ * The pending state must occupy the SAME vertical space as a rendered price, or
3302
+ * every card in a grid reflows once the session resolves. It is deliberately a
3303
+ * silent placeholder rather than a shimmer: the wait is short, and an animated
3304
+ * skeleton on 24 cards reads as a page that failed to load.
3305
+ */
3306
+ .appkit-gated-price__pending {
3307
+ display: inline-block;
3308
+ min-width: 5rem;
3309
+ height: 1.25rem;
3310
+ border-radius: 0.25rem;
3311
+ background: var(--appkit-color-surface-muted);
3312
+ }
3313
+
3314
+ .appkit-gated-price__prompt {
3315
+ display: inline-flex;
3316
+ align-items: center;
3317
+ gap: 0.25rem;
3318
+ font-size: 0.8125rem;
3319
+ font-weight: 600;
3320
+ line-height: 1.25rem;
3321
+ color: var(--appkit-color-primary);
3322
+ text-decoration: none;
3323
+ cursor: pointer;
3324
+ }
3325
+
3326
+ .appkit-gated-price__prompt:hover {
3327
+ text-decoration: underline;
3328
+ }
3329
+
3330
+ /* A lock glyph via ::before rather than an icon component: this renders inside
3331
+ card grids, where 24 extra SVG trees per page is real weight for decoration
3332
+ that carries no meaning the label doesn't already state. */
3333
+ .appkit-gated-price__prompt::before {
3334
+ content: "\1F512";
3335
+ font-size: 0.75rem;
3336
+ line-height: 1;
3337
+ }
3338
+
3298
3339
  .appkit-item-row {
3299
3340
  display: flex;
3300
3341
  align-items: flex-start;
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ /**
3
+ * 🛑 CONTRACT WITH THE STRUCTURED DATA — do not rename.
4
+ *
5
+ * The Product/Auction JSON-LD declares this exact selector in its paywalled
6
+ * content markup (`hasPart.cssSelector`, see `appkit/src/seo/json-ld.ts`), which
7
+ * is what tells Google the page legitimately carries an `offers.price` that an
8
+ * anonymous visitor cannot see. Rename this and the selector silently matches
9
+ * nothing, which reads to a crawler as an undeclared content mismatch.
10
+ *
11
+ * It is rendered in EVERY state — signed in, signed out and resolving — because
12
+ * the markup describes the region, not the refusal.
13
+ */
14
+ export declare const GATED_PRICE_CLASS = "appkit-gated-price";
15
+ export interface GatedPriceProps {
16
+ /** The amount to show once the viewer is allowed to see it. */
17
+ amount?: number;
18
+ currency?: string;
19
+ /** Struck-through "was" price; a discount badge is derived from the pair. */
20
+ originalAmount?: number;
21
+ variant?: "compact" | "detail";
22
+ className?: string;
23
+ /**
24
+ * Copy for the signed-out state. Default: "Sign in to see price". Override
25
+ * when the amount isn't a price — "Sign in to see the current bid".
26
+ */
27
+ label?: string;
28
+ /**
29
+ * Arbitrary money content to gate, for the sites that don't render a bare
30
+ * amount ("Buy Now — ₹1,200", "min increment ₹100", a composite row). When
31
+ * given, `amount` is ignored and this is what renders once allowed.
32
+ */
33
+ children?: React.ReactNode;
34
+ }
35
+ /**
36
+ * Renders its children only when the viewer may see prices; renders NOTHING
37
+ * otherwise — no prompt, no placeholder.
38
+ *
39
+ * For SECONDARY money details that sit beside an already-gated primary amount:
40
+ * a handling fee, a deposit note, a "you save ₹X" line, a min-bid increment.
41
+ * Those read as broken copy when gated in place ("min increment Sign in to see
42
+ * price"), and the prompt is already on screen one line up, so repeating it is
43
+ * noise rather than an affordance.
44
+ *
45
+ * 🛑 Do NOT use this for a listing's primary price. A card whose price row
46
+ * simply vanishes reads as a rendering bug, and its height stops matching its
47
+ * neighbours — use `<GatedPrice>` there, which keeps the row and explains it.
48
+ *
49
+ * Renders nothing while the session resolves, so a signed-in viewer sees these
50
+ * appear rather than flash the wrong state.
51
+ */
52
+ export declare function PricesOnly({ children }: {
53
+ children: React.ReactNode;
54
+ }): React.JSX.Element | null;
55
+ /**
56
+ * A money amount that only signed-in visitors may read.
57
+ *
58
+ * Three states, and the middle one is the whole reason this is a component
59
+ * rather than an inline `user ? price : prompt` at each call site — see
60
+ * `useCanSeePrices` for why "resolving" must not render the prompt.
61
+ *
62
+ * The amount is still present in the page payload and in the structured data;
63
+ * this gates the RENDER. Withholding it from the response entirely would mean
64
+ * fetching prices separately on every listing page — deliberately out of scope.
65
+ */
66
+ export declare function GatedPrice({ amount, currency, originalAmount, variant, className, label, children, }: GatedPriceProps): React.JSX.Element | null;
@@ -0,0 +1,80 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Span } from "./Typography";
4
+ import { Row } from "./Layout";
5
+ import { ROUTES } from "../../next/routing/route-map";
6
+ import { getDefaultCurrency } from "../../core/baseline-resolver";
7
+ import { formatCurrency, formatPercentage } from "../../utils/number.formatter";
8
+ import { useCanSeePrices } from "../../react/hooks/useCanSeePrices";
9
+ /**
10
+ * 🛑 CONTRACT WITH THE STRUCTURED DATA — do not rename.
11
+ *
12
+ * The Product/Auction JSON-LD declares this exact selector in its paywalled
13
+ * content markup (`hasPart.cssSelector`, see `appkit/src/seo/json-ld.ts`), which
14
+ * is what tells Google the page legitimately carries an `offers.price` that an
15
+ * anonymous visitor cannot see. Rename this and the selector silently matches
16
+ * nothing, which reads to a crawler as an undeclared content mismatch.
17
+ *
18
+ * It is rendered in EVERY state — signed in, signed out and resolving — because
19
+ * the markup describes the region, not the refusal.
20
+ */
21
+ export const GATED_PRICE_CLASS = "appkit-gated-price";
22
+ const DEFAULT_LABEL = "Sign in to see price";
23
+ /**
24
+ * Renders its children only when the viewer may see prices; renders NOTHING
25
+ * otherwise — no prompt, no placeholder.
26
+ *
27
+ * For SECONDARY money details that sit beside an already-gated primary amount:
28
+ * a handling fee, a deposit note, a "you save ₹X" line, a min-bid increment.
29
+ * Those read as broken copy when gated in place ("min increment Sign in to see
30
+ * price"), and the prompt is already on screen one line up, so repeating it is
31
+ * noise rather than an affordance.
32
+ *
33
+ * 🛑 Do NOT use this for a listing's primary price. A card whose price row
34
+ * simply vanishes reads as a rendering bug, and its height stops matching its
35
+ * neighbours — use `<GatedPrice>` there, which keeps the row and explains it.
36
+ *
37
+ * Renders nothing while the session resolves, so a signed-in viewer sees these
38
+ * appear rather than flash the wrong state.
39
+ */
40
+ export function PricesOnly({ children }) {
41
+ const { canSeePrices } = useCanSeePrices();
42
+ if (!canSeePrices)
43
+ return null;
44
+ return _jsx(_Fragment, { children: children });
45
+ }
46
+ /**
47
+ * A money amount that only signed-in visitors may read.
48
+ *
49
+ * Three states, and the middle one is the whole reason this is a component
50
+ * rather than an inline `user ? price : prompt` at each call site — see
51
+ * `useCanSeePrices` for why "resolving" must not render the prompt.
52
+ *
53
+ * The amount is still present in the page payload and in the structured data;
54
+ * this gates the RENDER. Withholding it from the response entirely would mean
55
+ * fetching prices separately on every listing page — deliberately out of scope.
56
+ */
57
+ export function GatedPrice({ amount, currency = getDefaultCurrency(), originalAmount, variant = "compact", className, label = DEFAULT_LABEL, children, }) {
58
+ const { canSeePrices, isAuthResolving } = useCanSeePrices();
59
+ const rootClass = `${GATED_PRICE_CLASS} ${className ?? ""}`.trim();
60
+ if (isAuthResolving) {
61
+ return (_jsx(Span, { className: `${rootClass} appkit-gated-price__pending`, "aria-hidden": "true" }));
62
+ }
63
+ if (!canSeePrices) {
64
+ return (_jsx("a", { href: String(ROUTES.AUTH.LOGIN), className: `${rootClass} appkit-gated-price__prompt`, children: label }));
65
+ }
66
+ if (children !== undefined) {
67
+ return _jsx(Span, { className: rootClass, children: children });
68
+ }
69
+ if (amount === undefined)
70
+ return null;
71
+ const hasDiscount = originalAmount !== undefined && originalAmount > amount;
72
+ const discountPct = hasDiscount ? (originalAmount - amount) / originalAmount : 0;
73
+ const priceClass = variant === "detail"
74
+ ? "appkit-price-display__price appkit-price-display__price--detail"
75
+ : "appkit-price-display__price appkit-price-display__price--compact";
76
+ const originalClass = variant === "detail"
77
+ ? "appkit-price-display__original appkit-price-display__original--detail"
78
+ : "appkit-price-display__original appkit-price-display__original--compact";
79
+ return (_jsxs(Row, { wrap: true, align: "baseline", gap: "xs", className: `appkit-price-display ${rootClass}`, children: [_jsx(Span, { className: priceClass, children: formatCurrency(amount, currency) }), hasDiscount && (_jsxs(_Fragment, { children: [_jsx(Span, { className: originalClass, children: formatCurrency(originalAmount, currency) }), _jsxs(Span, { className: "appkit-price-display__discount", children: ["-", formatPercentage(discountPct, 0)] })] }))] }));
80
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Sign-in gate for money amounts on public listing surfaces.
3
+ *
4
+ * The pending state must occupy the SAME vertical space as a rendered price, or
5
+ * every card in a grid reflows once the session resolves. It is deliberately a
6
+ * silent placeholder rather than a shimmer: the wait is short, and an animated
7
+ * skeleton on 24 cards reads as a page that failed to load.
8
+ */
9
+ .appkit-gated-price__pending {
10
+ display: inline-block;
11
+ min-width: 5rem;
12
+ height: 1.25rem;
13
+ border-radius: 0.25rem;
14
+ background: var(--appkit-color-surface-muted);
15
+ }
16
+
17
+ .appkit-gated-price__prompt {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ gap: 0.25rem;
21
+ font-size: 0.8125rem;
22
+ font-weight: 600;
23
+ line-height: 1.25rem;
24
+ color: var(--appkit-color-primary);
25
+ text-decoration: none;
26
+ cursor: pointer;
27
+ }
28
+
29
+ .appkit-gated-price__prompt:hover {
30
+ text-decoration: underline;
31
+ }
32
+
33
+ /* A lock glyph via ::before rather than an icon component: this renders inside
34
+ card grids, where 24 extra SVG trees per page is real weight for decoration
35
+ that carries no meaning the label doesn't already state. */
36
+ .appkit-gated-price__prompt::before {
37
+ content: "\1F512";
38
+ font-size: 0.75rem;
39
+ line-height: 1;
40
+ }
@@ -5,4 +5,16 @@ export interface PriceDisplayProps {
5
5
  variant?: "compact" | "detail";
6
6
  className?: string;
7
7
  }
8
+ /**
9
+ * A product price on a public surface — gated behind sign-in.
10
+ *
11
+ * This is now a thin wrapper over `<GatedPrice>`, with no opt-out, because both
12
+ * of its consumers (`ProductGrid`'s card + list rows, and the generic
13
+ * `ProductDetailPageView`) are public listing surfaces. There is deliberately no
14
+ * `ungated` prop: nothing in admin, seller, cart, checkout or orders uses this
15
+ * component, so an escape hatch here would exist only to be misused. Those
16
+ * surfaces call `formatCurrency` directly and are signed-in by definition.
17
+ *
18
+ * Rendering, discount badge and markup are unchanged for a signed-in viewer.
19
+ */
8
20
  export declare function PriceDisplay({ amount, currency, originalAmount, variant, className, }: PriceDisplayProps): import("react").JSX.Element;
@@ -1,18 +1,18 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Span } from "./Typography";
3
- import { Row } from "./Layout";
4
- import { getDefaultCurrency } from "../../core/baseline-resolver";
5
- import { formatCurrency, formatPercentage } from "../../utils/number.formatter";
6
- export function PriceDisplay({ amount, currency = getDefaultCurrency(), originalAmount, variant = "compact", className, }) {
7
- const hasDiscount = originalAmount !== undefined && originalAmount > amount;
8
- const discountPct = hasDiscount
9
- ? (originalAmount - amount) / originalAmount
10
- : 0;
11
- const priceClass = variant === "detail"
12
- ? "appkit-price-display__price appkit-price-display__price--detail"
13
- : "appkit-price-display__price appkit-price-display__price--compact";
14
- const originalClass = variant === "detail"
15
- ? "appkit-price-display__original appkit-price-display__original--detail"
16
- : "appkit-price-display__original appkit-price-display__original--compact";
17
- return (_jsxs(Row, { wrap: true, align: "baseline", gap: "xs", className: `appkit-price-display ${className ?? ""}`.trim(), children: [_jsx(Span, { className: priceClass, children: formatCurrency(amount, currency) }), hasDiscount && (_jsxs(_Fragment, { children: [_jsx(Span, { className: originalClass, children: formatCurrency(originalAmount, currency) }), _jsxs(Span, { className: "appkit-price-display__discount", children: ["-", formatPercentage(discountPct, 0)] })] }))] }));
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { GatedPrice } from "./GatedPrice";
4
+ /**
5
+ * A product price on a public surface — gated behind sign-in.
6
+ *
7
+ * This is now a thin wrapper over `<GatedPrice>`, with no opt-out, because both
8
+ * of its consumers (`ProductGrid`'s card + list rows, and the generic
9
+ * `ProductDetailPageView`) are public listing surfaces. There is deliberately no
10
+ * `ungated` prop: nothing in admin, seller, cart, checkout or orders uses this
11
+ * component, so an escape hatch here would exist only to be misused. Those
12
+ * surfaces call `formatCurrency` directly and are signed-in by definition.
13
+ *
14
+ * Rendering, discount badge and markup are unchanged for a signed-in viewer.
15
+ */
16
+ export function PriceDisplay({ amount, currency, originalAmount, variant = "compact", className, }) {
17
+ return (_jsx(GatedPrice, { amount: amount, currency: currency, originalAmount: originalAmount, variant: variant, className: className }));
18
18
  }
@@ -21,6 +21,7 @@
21
21
  @import "./Skeleton.style.css";
22
22
  @import "./RatingDisplay.style.css";
23
23
  @import "./PriceDisplay.style.css";
24
+ @import "./GatedPrice.style.css";
24
25
  @import "./ItemRow.style.css";
25
26
  @import "./Tooltip.style.css";
26
27
  @import "./Layout.style.css";
@@ -115,6 +115,8 @@ export type { RatingDisplayProps } from "./components/RatingDisplay";
115
115
  export { RatingDisplay } from "./components/RatingDisplay";
116
116
  export type { PriceDisplayProps } from "./components/PriceDisplay";
117
117
  export { PriceDisplay } from "./components/PriceDisplay";
118
+ export type { GatedPriceProps } from "./components/GatedPrice";
119
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./components/GatedPrice";
118
120
  export type { StatsGridProps, StatItem } from "./components/StatsGrid";
119
121
  export { StatsGrid } from "./components/StatsGrid";
120
122
  export type { SummaryCardProps, SummaryLine } from "./components/SummaryCard";
package/dist/ui/index.js CHANGED
@@ -60,6 +60,7 @@ export { StepperNav } from "./components/StepperNav";
60
60
  export { ViewToggle } from "./components/ViewToggle";
61
61
  export { RatingDisplay } from "./components/RatingDisplay";
62
62
  export { PriceDisplay } from "./components/PriceDisplay";
63
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./components/GatedPrice";
63
64
  export { StatsGrid } from "./components/StatsGrid";
64
65
  export { SummaryCard } from "./components/SummaryCard";
65
66
  export { CountdownDisplay } from "./components/CountdownDisplay";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.37.1",
3
+ "version": "4.38.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"