@mohasinac/appkit 4.47.0 → 4.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useAuth } from "../../../react/contexts/SessionContext.js";
5
+ import { useConversationThread, useSendMessage, useMarkConversationRead, useBlockConversation, } from "../hooks/useMessages.js";
6
+ import { Div } from "../../../ui/components/Div.js";
7
+ import { Row, Stack } from "../../../ui/components/Layout.js";
8
+ import { Heading, Span, Text } from "../../../ui/components/Typography.js";
9
+ import { Button } from "../../../ui/components/Button.js";
10
+ import { FieldTextarea } from "../../../ui/forms/FieldTextarea.js";
11
+ import { EmptyState } from "../../../ui/components/EmptyState.js";
12
+ import { MediaImage } from "../../media/MediaImage.js";
13
+ function newClientMessageId() {
14
+ // crypto.randomUUID is available in every browser this app targets; a
15
+ // retry reuses the SAME id generated at compose time, not a fresh one per
16
+ // attempt, so the server resolves a retry as the same message.
17
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
18
+ ? crypto.randomUUID()
19
+ : `cm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
20
+ }
21
+ function MessageBubble({ message, isSelf }) {
22
+ if (message.deletedAt) {
23
+ return (_jsx(Row, { justify: isSelf ? "end" : "start", children: _jsx(Text, { size: "sm", color: "muted", className: "italic", children: "Message deleted" }) }));
24
+ }
25
+ return (_jsx(Row, { justify: isSelf ? "end" : "start", children: _jsxs(Stack, { gap: "xs", surface: isSelf ? "interactive" : "muted", padding: "sm", rounded: "lg", className: "max-w-[75%]", children: [message.productRef && (_jsxs(Row, { gap: "sm", surface: "card", padding: "xs", rounded: "md", children: [message.productRef.image && (_jsx(Div, { className: "relative h-10 w-10 flex-shrink-0", rounded: "default", overflow: "hidden", children: _jsx(MediaImage, { src: message.productRef.image, alt: message.productRef.title, size: "thumbnail" }) })), _jsx(Text, { size: "xs", weight: "semibold", className: "truncate", children: message.productRef.title })] })), _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: message.body })] }) }));
26
+ }
27
+ export function ConversationThreadView({ conversationId }) {
28
+ const { user, loading } = useAuth();
29
+ const { data, isPending, isError, channelError } = useConversationThread(user ? conversationId : null);
30
+ const { mutate: send, isPending: isSending, pending, retry } = useSendMessage(conversationId);
31
+ const markRead = useMarkConversationRead();
32
+ const block = useBlockConversation();
33
+ const [draft, setDraft] = useState("");
34
+ const markedRef = useRef(false);
35
+ const bottomRef = useRef(null);
36
+ useEffect(() => {
37
+ if (!markedRef.current && conversationId && user) {
38
+ markedRef.current = true;
39
+ markRead.mutate({ conversationId });
40
+ }
41
+ // Reset the guard when the thread changes.
42
+ return () => {
43
+ markedRef.current = false;
44
+ };
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [conversationId, user]);
47
+ useEffect(() => {
48
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
49
+ }, [data?.items.length, pending.length]);
50
+ if (loading)
51
+ return null;
52
+ if (!user) {
53
+ return (_jsx(EmptyState, { title: "Sign in to view this conversation", actionLabel: "Sign in", actionHref: "/auth/login" }));
54
+ }
55
+ const handleSend = () => {
56
+ const trimmed = draft.trim();
57
+ if (!trimmed)
58
+ return;
59
+ const clientMessageId = newClientMessageId();
60
+ send({ body: trimmed, clientMessageId });
61
+ setDraft("");
62
+ };
63
+ // Newest-first from the API, so reverse for display (oldest at top).
64
+ const items = data ? [...data.items].reverse() : [];
65
+ return (_jsxs(Div, { layout: "flex-col", className: "max-w-2xl mx-auto h-full", children: [_jsxs(Row, { padding: "md", justify: "between", className: "border-b border-[var(--appkit-color-border-subtle)]", children: [_jsx(Heading, { level: 1, size: "lg", children: "Conversation" }), _jsx(Button, { variant: "ghost", size: "sm", isLoading: block.isPending, onClick: () => {
66
+ if (confirm("Block this person from messaging you in this conversation?")) {
67
+ block.mutate({ conversationId });
68
+ }
69
+ }, children: "Block" })] }), channelError && (_jsx(Div, { padding: "sm", surface: "warning-surface", children: _jsxs(Text, { size: "xs", color: "warning", children: [channelError, " New messages may not appear until you reload."] }) })), _jsxs(Div, { className: "flex-1", overflow: "y-auto", padding: "md", children: [isPending && _jsx(Text, { color: "muted", children: "Loading messages\u2026" }), isError && _jsx(Text, { color: "danger", children: "Could not load this conversation." }), _jsxs(Stack, { gap: "sm", children: [items.map((m) => (_jsx(MessageBubble, { message: m, isSelf: m.authorId === user.uid }, m.id))), pending.map((p) => (_jsx(Row, { justify: "end", children: _jsxs(Stack, { gap: "xs", surface: "interactive", padding: "sm", rounded: "lg", className: "max-w-[75%] opacity-70", children: [_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: p.body }), p.status === "failed" && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => retry(p), children: "Failed \u2014 tap to retry" })), p.status === "pending" && (_jsx(Span, { size: "xs", color: "muted", children: "Sending\u2026" }))] }) }, p.clientMessageId)))] }), _jsx(Div, { ref: bottomRef })] }), _jsxs(Row, { padding: "md", gap: "sm", className: "border-t border-[var(--appkit-color-border-subtle)]", children: [_jsx(Div, { className: "flex-1", children: _jsx(FieldTextarea, { name: "message-composer", "aria-label": "Message", value: draft, onChange: setDraft, onKeyDown: (e) => {
70
+ if (e.key === "Enter" && !e.shiftKey) {
71
+ e.preventDefault();
72
+ handleSend();
73
+ }
74
+ }, maxLength: 2000, rows: 2, placeholder: "Write a message\u2026" }) }), _jsx(Button, { isLoading: isSending, disabled: !draft.trim(), onClick: handleSend, children: "Send" })] })] }));
75
+ }
@@ -0,0 +1 @@
1
+ export declare function ConversationsInboxView(): import("react").JSX.Element | null;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useAuth } from "../../../react/contexts/SessionContext.js";
4
+ import { useConversations } from "../hooks/useMessages.js";
5
+ import { Anchor } from "../../../ui/components/Anchor.js";
6
+ import { Div } from "../../../ui/components/Div.js";
7
+ import { Row, Stack } from "../../../ui/components/Layout.js";
8
+ import { Heading, Span, Text } from "../../../ui/components/Typography.js";
9
+ import { EmptyState } from "../../../ui/components/EmptyState.js";
10
+ function formatRelative(at) {
11
+ const d = typeof at === "string" ? new Date(at) : at;
12
+ const diffMs = Date.now() - d.getTime();
13
+ const mins = Math.floor(diffMs / 60000);
14
+ if (mins < 1)
15
+ return "just now";
16
+ if (mins < 60)
17
+ return `${mins}m ago`;
18
+ const hours = Math.floor(mins / 60);
19
+ if (hours < 24)
20
+ return `${hours}h ago`;
21
+ const days = Math.floor(hours / 24);
22
+ return `${days}d ago`;
23
+ }
24
+ function ConversationRow({ conversation, selfId }) {
25
+ const unread = conversation.unread[selfId] ?? 0;
26
+ return (_jsx(Anchor, { href: `/messages/${conversation.id}`, underline: "none", className: "block", children: _jsxs(Row, { surface: unread > 0 ? "muted" : "default", padding: "md", gap: "md", className: "border-b border-[var(--appkit-color-border-subtle)]", children: [_jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "sm", justify: "between", children: [_jsx(Text, { weight: unread > 0 ? "semibold" : "normal", className: "truncate", children: conversation.participantRoles[selfId] === "seller" ? "Buyer" : "Seller" }), conversation.lastMessageAt && (_jsx(Span, { size: "xs", color: "muted", children: formatRelative(conversation.lastMessageAt) }))] }), conversation.lastMessage && (_jsx(Text, { size: "sm", color: "muted", className: "truncate", children: conversation.lastMessage.hasAttachments && !conversation.lastMessage.body
27
+ ? "Sent an attachment"
28
+ : conversation.lastMessage.body }))] }), unread > 0 && (_jsx(Span, { size: "xs", weight: "semibold", color: "inverse", rounded: "full", className: "flex-shrink-0 bg-primary", padding: "x-sm", children: unread }))] }) }));
29
+ }
30
+ export function ConversationsInboxView() {
31
+ const { user, loading } = useAuth();
32
+ const { data, isPending, isError } = useConversations(20, Boolean(user));
33
+ if (loading)
34
+ return null;
35
+ if (!user) {
36
+ return (_jsx(EmptyState, { title: "Sign in to view your messages", description: "Conversations with buyers and sellers are only visible once you're signed in.", actionLabel: "Sign in", actionHref: "/auth/login" }));
37
+ }
38
+ return (_jsxs(Div, { className: "max-w-2xl mx-auto", children: [_jsx(Div, { padding: "md", className: "border-b border-[var(--appkit-color-border-subtle)]", children: _jsx(Heading, { level: 1, size: "lg", children: "Messages" }) }), isPending && (_jsx(Div, { padding: "lg", children: _jsx(Text, { color: "muted", children: "Loading conversations\u2026" }) })), isError && (_jsx(Div, { padding: "lg", children: _jsx(Text, { color: "danger", children: "Could not load your messages. Try reloading the page." }) })), data && data.items.length === 0 && (_jsx(EmptyState, { title: "No conversations yet", description: "Messages with buyers and sellers show up here." })), data && data.items.length > 0 && (_jsx(Stack, { gap: "none", children: data.items.map((c) => (_jsx(ConversationRow, { conversation: c, selfId: user.uid }, c.id))) }))] }));
39
+ }
@@ -0,0 +1,17 @@
1
+ export interface MessageSellerButtonProps {
2
+ storeId: string;
3
+ /** Render nothing when the viewer owns this store — comparing storeId
4
+ * client-side is an optional convenience; the server refuses it either way. */
5
+ isOwnStore?: boolean;
6
+ className?: string;
7
+ }
8
+ /**
9
+ * D11 — the gap tester-case authoring surfaced: the full create/open-
10
+ * conversation backend had no UI entry point anywhere. One real button, put
11
+ * wherever a page already knows a `storeId` — the product detail page's
12
+ * store card is the first and highest-value placement.
13
+ *
14
+ * Absent entirely when chat is off or the viewer is the store's own owner —
15
+ * never rendered-but-disabled, same discipline as the nav icon.
16
+ */
17
+ export declare function MessageSellerButton({ storeId, isOwnStore, className }: MessageSellerButtonProps): import("react").JSX.Element | null;
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRouter } from "next/navigation.js";
4
+ import { useAuth } from "../../../react/contexts/SessionContext.js";
5
+ import { useSiteSettings } from "../../../core/hooks/useSiteSettings.js";
6
+ import { useOpenConversationWithSeller } from "../hooks/useMessages.js";
7
+ import { Button } from "../../../ui/components/Button.js";
8
+ import { ROUTES } from "../../../next/routing/route-map.js";
9
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
10
+ /**
11
+ * D11 — the gap tester-case authoring surfaced: the full create/open-
12
+ * conversation backend had no UI entry point anywhere. One real button, put
13
+ * wherever a page already knows a `storeId` — the product detail page's
14
+ * store card is the first and highest-value placement.
15
+ *
16
+ * Absent entirely when chat is off or the viewer is the store's own owner —
17
+ * never rendered-but-disabled, same discipline as the nav icon.
18
+ */
19
+ export function MessageSellerButton({ storeId, isOwnStore, className }) {
20
+ const router = useRouter();
21
+ const { user } = useAuth();
22
+ const { data: settings } = useSiteSettings();
23
+ const { mutate, isPending } = useOpenConversationWithSeller();
24
+ if (!user || isOwnStore || settings?.chat?.enabled !== true)
25
+ return null;
26
+ return (_jsx(Button, { action: ACTIONS.PRODUCT["message-seller"], size: "sm", isLoading: isPending, className: className, onClick: () => {
27
+ mutate({ storeId }, {
28
+ onSuccess: (conversation) => {
29
+ router.push(String(ROUTES.USER.MESSAGE_THREAD(conversation.id)));
30
+ },
31
+ });
32
+ } }));
33
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * D9 — subscribe to `chat_events/{conversationId}` and fire `onPing` on every
3
+ * change. NOT `useRealtimeEvent`: that hook is shaped around a single
4
+ * terminal status (success/failed/timeout) for a one-shot job, while a chat
5
+ * channel is a CONTINUOUS ping — every new message writes to the same node,
6
+ * and there is no "done" state for an open thread.
7
+ *
8
+ * RTDB-ping-plus-REST: this channel carries no message content, only "a
9
+ * message arrived, refetch" — `firestore.rules` forbids a client Firestore
10
+ * listener entirely (`allow read, write: if false` on every document), so
11
+ * this plus the bounded cursor-paginated REST route is the only option.
12
+ *
13
+ * 🛑 Subscribes through the LEASED provider (`acquireRealtimeSession`), never
14
+ * the global one. The old feature's two bugs were both here: it never signed
15
+ * in at all (so every rule evaluated false and the channel was silently
16
+ * inert), and a shared sign-out tore one consumer's session out from under
17
+ * another's. Both are structurally impossible via the lease.
18
+ */
19
+ export declare function useConversationChannel(conversationId: string | null, getToken: (conversationId: string) => Promise<{
20
+ customToken: string;
21
+ expiresAt: number;
22
+ }>, onPing: () => void): {
23
+ error: string | null;
24
+ };
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { normalizeError } from "../../../errors/normalize.js";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { getClientRealtimeProvider } from "../../../contracts/client-realtime.js";
5
+ import { acquireRealtimeSession } from "../../../contracts/realtime-session.js";
6
+ import { RTDB_PATHS } from "../../../providers/db-firebase/rtdb-paths.js";
7
+ /**
8
+ * D9 — subscribe to `chat_events/{conversationId}` and fire `onPing` on every
9
+ * change. NOT `useRealtimeEvent`: that hook is shaped around a single
10
+ * terminal status (success/failed/timeout) for a one-shot job, while a chat
11
+ * channel is a CONTINUOUS ping — every new message writes to the same node,
12
+ * and there is no "done" state for an open thread.
13
+ *
14
+ * RTDB-ping-plus-REST: this channel carries no message content, only "a
15
+ * message arrived, refetch" — `firestore.rules` forbids a client Firestore
16
+ * listener entirely (`allow read, write: if false` on every document), so
17
+ * this plus the bounded cursor-paginated REST route is the only option.
18
+ *
19
+ * 🛑 Subscribes through the LEASED provider (`acquireRealtimeSession`), never
20
+ * the global one. The old feature's two bugs were both here: it never signed
21
+ * in at all (so every rule evaluated false and the channel was silently
22
+ * inert), and a shared sign-out tore one consumer's session out from under
23
+ * another's. Both are structurally impossible via the lease.
24
+ */
25
+ export function useConversationChannel(conversationId, getToken, onPing) {
26
+ const onPingRef = useRef(onPing);
27
+ useEffect(() => {
28
+ onPingRef.current = onPing;
29
+ });
30
+ const [error, setError] = useState(null);
31
+ const unsubscribeRef = useRef(null);
32
+ const releaseRef = useRef(null);
33
+ const cleanup = useCallback(() => {
34
+ unsubscribeRef.current?.();
35
+ unsubscribeRef.current = null;
36
+ releaseRef.current?.();
37
+ releaseRef.current = null;
38
+ }, []);
39
+ useEffect(() => {
40
+ if (!conversationId) {
41
+ cleanup();
42
+ return;
43
+ }
44
+ let cancelled = false;
45
+ setError(null);
46
+ (async () => {
47
+ try {
48
+ const token = await getToken(conversationId);
49
+ const lease = await acquireRealtimeSession(`chat:${conversationId}`, () => Promise.resolve(token));
50
+ if (cancelled) {
51
+ lease.release();
52
+ return;
53
+ }
54
+ releaseRef.current = lease.release;
55
+ const provider = lease.provider ?? getClientRealtimeProvider();
56
+ unsubscribeRef.current = provider.subscribe(
57
+ /*
58
+ * 🛑 From RTDB_PATHS, not a literal — found by the G2 sweep. The
59
+ * SERVER writes this ping via `RTDB_PATHS.CHAT_EVENTS` and the
60
+ * client was subscribing to a hardcoded copy of the same string:
61
+ * two owners of one path, where a rename on either side produces a
62
+ * channel that connects, authenticates, and never fires. Exactly
63
+ * Root Cause #42's shape on the realtime-path axis.
64
+ */
65
+ `${RTDB_PATHS.CHAT_EVENTS}/${conversationId}`, (snapshot) => {
66
+ if (!snapshot.exists())
67
+ return;
68
+ onPingRef.current();
69
+ }, (err) => {
70
+ // 🛑 Surfaced, never swallowed — the old feature's `// ignore` on
71
+ // this exact error is why it looked implemented and was inert.
72
+ setError("Live updates are unavailable for this conversation.");
73
+ void normalizeError(err);
74
+ });
75
+ }
76
+ catch (err) {
77
+ if (!cancelled) {
78
+ setError("Could not connect to live updates.");
79
+ void normalizeError(err);
80
+ }
81
+ }
82
+ })();
83
+ return () => {
84
+ cancelled = true;
85
+ cleanup();
86
+ };
87
+ }, [conversationId, getToken, cleanup]);
88
+ return { error };
89
+ }
@@ -0,0 +1,347 @@
1
+ import type { ConversationDocument, MessageDocument } from "../schemas/firestore";
2
+ interface PagedResult<T> {
3
+ items: T[];
4
+ nextCursor: string | null;
5
+ }
6
+ /**
7
+ * The inbox list. Bounded (<=50), newest-first — this IS the query the
8
+ * unread badge also sums over, so there is no second "just for the count"
9
+ * query anywhere (D9 spec: "not a second global poll").
10
+ */
11
+ export declare function useConversations(limit?: number, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<PagedResult<ConversationDocument>, Error>;
12
+ export declare function useOpenConversation(): import("@tanstack/react-query").UseMutationResult<ConversationDocument, unknown, {
13
+ otherUserId: string;
14
+ }, unknown>;
15
+ /** D11 — the entry point a product/store page actually has: a storeId, not
16
+ * the owner's uid. Resolution happens server-side. */
17
+ export declare function useOpenConversationWithSeller(): import("@tanstack/react-query").UseMutationResult<ConversationDocument, unknown, {
18
+ storeId: string;
19
+ }, unknown>;
20
+ /**
21
+ * One thread. Combines the bounded REST page with the RTDB ping channel —
22
+ * a ping triggers `refetch()`, never a client Firestore listener
23
+ * (`firestore.rules` forbids one entirely).
24
+ */
25
+ export declare function useConversationThread(conversationId: string | null): {
26
+ channelError: string | null;
27
+ data: PagedResult<MessageDocument>;
28
+ error: Error;
29
+ isError: true;
30
+ isPending: false;
31
+ isLoading: false;
32
+ isLoadingError: false;
33
+ isRefetchError: true;
34
+ isSuccess: false;
35
+ isPlaceholderData: false;
36
+ status: "error";
37
+ dataUpdatedAt: number;
38
+ errorUpdatedAt: number;
39
+ failureCount: number;
40
+ failureReason: Error | null;
41
+ errorUpdateCount: number;
42
+ isFetched: boolean;
43
+ isFetchedAfterMount: boolean;
44
+ isFetching: boolean;
45
+ isInitialLoading: boolean;
46
+ isPaused: boolean;
47
+ isRefetching: boolean;
48
+ isStale: boolean;
49
+ isEnabled: boolean;
50
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
51
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
52
+ } | {
53
+ channelError: string | null;
54
+ data: PagedResult<MessageDocument>;
55
+ error: null;
56
+ isError: false;
57
+ isPending: false;
58
+ isLoading: false;
59
+ isLoadingError: false;
60
+ isRefetchError: false;
61
+ isSuccess: true;
62
+ isPlaceholderData: false;
63
+ status: "success";
64
+ dataUpdatedAt: number;
65
+ errorUpdatedAt: number;
66
+ failureCount: number;
67
+ failureReason: Error | null;
68
+ errorUpdateCount: number;
69
+ isFetched: boolean;
70
+ isFetchedAfterMount: boolean;
71
+ isFetching: boolean;
72
+ isInitialLoading: boolean;
73
+ isPaused: boolean;
74
+ isRefetching: boolean;
75
+ isStale: boolean;
76
+ isEnabled: boolean;
77
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
78
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
79
+ } | {
80
+ channelError: string | null;
81
+ data: undefined;
82
+ error: Error;
83
+ isError: true;
84
+ isPending: false;
85
+ isLoading: false;
86
+ isLoadingError: true;
87
+ isRefetchError: false;
88
+ isSuccess: false;
89
+ isPlaceholderData: false;
90
+ status: "error";
91
+ dataUpdatedAt: number;
92
+ errorUpdatedAt: number;
93
+ failureCount: number;
94
+ failureReason: Error | null;
95
+ errorUpdateCount: number;
96
+ isFetched: boolean;
97
+ isFetchedAfterMount: boolean;
98
+ isFetching: boolean;
99
+ isInitialLoading: boolean;
100
+ isPaused: boolean;
101
+ isRefetching: boolean;
102
+ isStale: boolean;
103
+ isEnabled: boolean;
104
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
105
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
106
+ } | {
107
+ channelError: string | null;
108
+ data: undefined;
109
+ error: null;
110
+ isError: false;
111
+ isPending: true;
112
+ isLoading: true;
113
+ isLoadingError: false;
114
+ isRefetchError: false;
115
+ isSuccess: false;
116
+ isPlaceholderData: false;
117
+ status: "pending";
118
+ dataUpdatedAt: number;
119
+ errorUpdatedAt: number;
120
+ failureCount: number;
121
+ failureReason: Error | null;
122
+ errorUpdateCount: number;
123
+ isFetched: boolean;
124
+ isFetchedAfterMount: boolean;
125
+ isFetching: boolean;
126
+ isInitialLoading: boolean;
127
+ isPaused: boolean;
128
+ isRefetching: boolean;
129
+ isStale: boolean;
130
+ isEnabled: boolean;
131
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
132
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
133
+ } | {
134
+ channelError: string | null;
135
+ data: undefined;
136
+ error: null;
137
+ isError: false;
138
+ isPending: true;
139
+ isLoadingError: false;
140
+ isRefetchError: false;
141
+ isSuccess: false;
142
+ isPlaceholderData: false;
143
+ status: "pending";
144
+ dataUpdatedAt: number;
145
+ errorUpdatedAt: number;
146
+ failureCount: number;
147
+ failureReason: Error | null;
148
+ errorUpdateCount: number;
149
+ isFetched: boolean;
150
+ isFetchedAfterMount: boolean;
151
+ isFetching: boolean;
152
+ isLoading: boolean;
153
+ isInitialLoading: boolean;
154
+ isPaused: boolean;
155
+ isRefetching: boolean;
156
+ isStale: boolean;
157
+ isEnabled: boolean;
158
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
159
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
160
+ } | {
161
+ channelError: string | null;
162
+ data: PagedResult<MessageDocument>;
163
+ isError: false;
164
+ error: null;
165
+ isPending: false;
166
+ isLoading: false;
167
+ isLoadingError: false;
168
+ isRefetchError: false;
169
+ isSuccess: true;
170
+ isPlaceholderData: true;
171
+ status: "success";
172
+ dataUpdatedAt: number;
173
+ errorUpdatedAt: number;
174
+ failureCount: number;
175
+ failureReason: Error | null;
176
+ errorUpdateCount: number;
177
+ isFetched: boolean;
178
+ isFetchedAfterMount: boolean;
179
+ isFetching: boolean;
180
+ isInitialLoading: boolean;
181
+ isPaused: boolean;
182
+ isRefetching: boolean;
183
+ isStale: boolean;
184
+ isEnabled: boolean;
185
+ refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<PagedResult<MessageDocument>, Error>>;
186
+ fetchStatus: import("@tanstack/query-core").FetchStatus;
187
+ };
188
+ export interface PendingMessage {
189
+ clientMessageId: string;
190
+ body: string;
191
+ status: "pending" | "failed";
192
+ }
193
+ /**
194
+ * Optimistic send with an explicit pending/failed state and retry — a silent
195
+ * failure is how the old feature hid (D9 spec). `clientMessageId` is a stable
196
+ * uuid generated by the CALLER at compose time (not on each attempt), so a
197
+ * retry reuses the same id and the server resolves it as the same message
198
+ * rather than a duplicate.
199
+ */
200
+ export declare function useSendMessage(conversationId: string): {
201
+ pending: PendingMessage[];
202
+ retry: (p: PendingMessage) => void;
203
+ data: undefined;
204
+ variables: undefined;
205
+ error: null;
206
+ isError: false;
207
+ isIdle: true;
208
+ isPending: false;
209
+ isSuccess: false;
210
+ status: "idle";
211
+ mutate: import("@tanstack/react-query").UseMutateFunction<MessageDocument, unknown, {
212
+ body: string;
213
+ clientMessageId: string;
214
+ attachments?: string[];
215
+ productId?: string;
216
+ }, unknown>;
217
+ reset: () => void;
218
+ context: unknown;
219
+ failureCount: number;
220
+ failureReason: unknown;
221
+ isPaused: boolean;
222
+ submittedAt: number;
223
+ mutateAsync: import("@tanstack/react-query").UseMutateAsyncFunction<MessageDocument, unknown, {
224
+ body: string;
225
+ clientMessageId: string;
226
+ attachments?: string[];
227
+ productId?: string;
228
+ }, unknown>;
229
+ } | {
230
+ pending: PendingMessage[];
231
+ retry: (p: PendingMessage) => void;
232
+ data: undefined;
233
+ variables: {
234
+ body: string;
235
+ clientMessageId: string;
236
+ attachments?: string[];
237
+ productId?: string;
238
+ };
239
+ error: null;
240
+ isError: false;
241
+ isIdle: false;
242
+ isPending: true;
243
+ isSuccess: false;
244
+ status: "pending";
245
+ mutate: import("@tanstack/react-query").UseMutateFunction<MessageDocument, unknown, {
246
+ body: string;
247
+ clientMessageId: string;
248
+ attachments?: string[];
249
+ productId?: string;
250
+ }, unknown>;
251
+ reset: () => void;
252
+ context: unknown;
253
+ failureCount: number;
254
+ failureReason: unknown;
255
+ isPaused: boolean;
256
+ submittedAt: number;
257
+ mutateAsync: import("@tanstack/react-query").UseMutateAsyncFunction<MessageDocument, unknown, {
258
+ body: string;
259
+ clientMessageId: string;
260
+ attachments?: string[];
261
+ productId?: string;
262
+ }, unknown>;
263
+ } | {
264
+ pending: PendingMessage[];
265
+ retry: (p: PendingMessage) => void;
266
+ data: undefined;
267
+ error: unknown;
268
+ variables: {
269
+ body: string;
270
+ clientMessageId: string;
271
+ attachments?: string[];
272
+ productId?: string;
273
+ };
274
+ isError: true;
275
+ isIdle: false;
276
+ isPending: false;
277
+ isSuccess: false;
278
+ status: "error";
279
+ mutate: import("@tanstack/react-query").UseMutateFunction<MessageDocument, unknown, {
280
+ body: string;
281
+ clientMessageId: string;
282
+ attachments?: string[];
283
+ productId?: string;
284
+ }, unknown>;
285
+ reset: () => void;
286
+ context: unknown;
287
+ failureCount: number;
288
+ failureReason: unknown;
289
+ isPaused: boolean;
290
+ submittedAt: number;
291
+ mutateAsync: import("@tanstack/react-query").UseMutateAsyncFunction<MessageDocument, unknown, {
292
+ body: string;
293
+ clientMessageId: string;
294
+ attachments?: string[];
295
+ productId?: string;
296
+ }, unknown>;
297
+ } | {
298
+ pending: PendingMessage[];
299
+ retry: (p: PendingMessage) => void;
300
+ data: MessageDocument;
301
+ error: null;
302
+ variables: {
303
+ body: string;
304
+ clientMessageId: string;
305
+ attachments?: string[];
306
+ productId?: string;
307
+ };
308
+ isError: false;
309
+ isIdle: false;
310
+ isPending: false;
311
+ isSuccess: true;
312
+ status: "success";
313
+ mutate: import("@tanstack/react-query").UseMutateFunction<MessageDocument, unknown, {
314
+ body: string;
315
+ clientMessageId: string;
316
+ attachments?: string[];
317
+ productId?: string;
318
+ }, unknown>;
319
+ reset: () => void;
320
+ context: unknown;
321
+ failureCount: number;
322
+ failureReason: unknown;
323
+ isPaused: boolean;
324
+ submittedAt: number;
325
+ mutateAsync: import("@tanstack/react-query").UseMutateAsyncFunction<MessageDocument, unknown, {
326
+ body: string;
327
+ clientMessageId: string;
328
+ attachments?: string[];
329
+ productId?: string;
330
+ }, unknown>;
331
+ };
332
+ export declare function useMarkConversationRead(): import("@tanstack/react-query").UseMutationResult<{
333
+ ok: boolean;
334
+ }, unknown, {
335
+ conversationId: string;
336
+ }, unknown>;
337
+ export declare function useBlockConversation(): import("@tanstack/react-query").UseMutationResult<{
338
+ ok: boolean;
339
+ }, unknown, {
340
+ conversationId: string;
341
+ }, unknown>;
342
+ /** The nav badge's source when the inbox itself is not mounted — never a
343
+ * second list query of its own. */
344
+ export declare function useUnreadMessageCount(enabled: boolean): import("@tanstack/react-query").UseQueryResult<{
345
+ unread: number;
346
+ }, Error>;
347
+ export {};