@mohasinac/appkit 4.2.0 → 4.3.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/server/features/auth/actions.d.ts +0 -1
- package/dist/_internal/server/features/auth/actions.js +0 -1
- package/dist/_internal/server/features/bundles/data.d.ts +22 -4
- package/dist/_internal/server/features/bundles/data.js +41 -4
- package/dist/_internal/server/features/bundles/index.d.ts +1 -1
- package/dist/_internal/server/features/bundles/index.js +1 -1
- package/dist/_internal/server/features/checkout/actions.js +7 -78
- package/dist/_internal/server/features/checkout/prize-bundle-gates.d.ts +5 -16
- package/dist/_internal/server/features/checkout/prize-bundle-gates.js +5 -30
- package/dist/_internal/server/features/orders/adapters.js +1 -14
- package/dist/_internal/server/features/products/actions.js +8 -3
- package/dist/_internal/server/features/products/service.d.ts +15 -0
- package/dist/_internal/server/features/products/service.js +41 -0
- package/dist/_internal/server/functions/firestore.d.ts +3 -1
- package/dist/_internal/server/functions/firestore.js +17 -1
- package/dist/_internal/server/functions/scheduled.d.ts +2 -5
- package/dist/_internal/server/functions/scheduled.js +6 -30
- package/dist/_internal/server/jobs/core/bundleStockSync.js +25 -7
- package/dist/_internal/server/jobs/core/countersReconcile.js +7 -1
- package/dist/_internal/server/jobs/core/index.d.ts +4 -4
- package/dist/_internal/server/jobs/core/index.js +4 -4
- package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.d.ts +19 -0
- package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.js +30 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.d.ts +2 -2
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +2 -2
- package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.d.ts +20 -0
- package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.js +113 -0
- package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.d.ts +14 -0
- package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.js +74 -0
- package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.d.ts +22 -0
- package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.js +68 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -4
- package/dist/_internal/server/jobs/handlers/index.js +8 -6
- package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.d.ts +3 -0
- package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.js +11 -0
- package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.js +2 -0
- package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.d.ts +3 -0
- package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.js +4 -0
- package/dist/_internal/shared/actions/action-registry.js +40 -10
- package/dist/_internal/shared/checkout/rules/_defaults.js +1 -2
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +3 -0
- package/dist/_internal/shared/checkout/rules/_limits.js +3 -0
- package/dist/_internal/shared/checkout/rules/_registry.d.ts +2 -1
- package/dist/_internal/shared/checkout/rules/_registry.js +2 -2
- package/dist/_internal/shared/checkout/rules/classified.rule.js +0 -1
- package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
- package/dist/_internal/shared/checkout/rules/index.js +1 -1
- package/dist/_internal/shared/checkout/rules/prize-draw.rule.js +20 -1
- package/dist/_internal/shared/checkout/rules/types.d.ts +17 -7
- package/dist/_internal/shared/features/categories/bundle-copy.d.ts +2 -0
- package/dist/_internal/shared/features/categories/bundle-copy.js +2 -0
- package/dist/_internal/shared/features/categories/bundle-pricing.d.ts +23 -0
- package/dist/_internal/shared/features/categories/bundle-pricing.js +25 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
- package/dist/constants/api-endpoints.d.ts +6 -9
- package/dist/constants/api-endpoints.js +5 -5
- package/dist/constants/field-names.d.ts +17 -1
- package/dist/constants/field-names.js +17 -1
- package/dist/features/account/hooks/useNotifications.js +5 -0
- package/dist/features/admin/actions/admin-actions.js +6 -0
- package/dist/features/admin/actions/notification-actions.js +1 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +1 -1
- package/dist/features/admin/components/AdminPrizeDrawsView.js +3 -3
- package/dist/features/admin/components/AdminSiteSettingsView.js +33 -6
- package/dist/features/admin/components/AdminTesterChecklistView.js +74 -22
- package/dist/features/admin/hooks/useAdminListing.d.ts +4 -0
- package/dist/features/admin/hooks/useAdminListing.js +4 -2
- package/dist/features/admin/schemas/firestore.d.ts +13 -5
- package/dist/features/admin/schemas/firestore.js +1 -2
- package/dist/features/auctions/actions/bid-actions.js +3 -1
- package/dist/features/auctions/components/AuctionBidsTable.d.ts +6 -1
- package/dist/features/auctions/components/AuctionBidsTable.js +8 -4
- package/dist/features/auctions/components/AuctionDetailPageView.js +10 -13
- package/dist/features/auctions/components/CollapsibleBidHistory.d.ts +23 -2
- package/dist/features/auctions/components/CollapsibleBidHistory.js +21 -3
- package/dist/features/auctions/components/LiveBidPrice.d.ts +19 -0
- package/dist/features/auctions/components/LiveBidPrice.js +17 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
- package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
- package/dist/features/auctions/hooks/useBids.d.ts +33 -0
- package/dist/features/auctions/hooks/useBids.js +32 -0
- package/dist/features/auctions/hooks/useLiveAuctionBid.d.ts +20 -0
- package/dist/features/auctions/hooks/useLiveAuctionBid.js +23 -0
- package/dist/features/auth/checkout-value-otp.d.ts +7 -10
- package/dist/features/auth/checkout-value-otp.js +14 -10
- package/dist/features/auth/server.d.ts +0 -1
- package/dist/features/auth/server.js +0 -1
- package/dist/features/blog/api/[slug]/route.d.ts +5 -0
- package/dist/features/blog/api/[slug]/route.js +12 -3
- package/dist/features/blog/components/BlogPostView.d.ts +2 -0
- package/dist/features/blog/components/BlogPostView.js +5 -4
- package/dist/features/blog/hooks/useBlog.d.ts +2 -0
- package/dist/features/blog/hooks/useBlog.js +2 -0
- package/dist/features/blog/repository/blog.repository.d.ts +7 -0
- package/dist/features/blog/repository/blog.repository.js +44 -0
- package/dist/features/blog/schemas/firestore.d.ts +3 -0
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/index.d.ts +0 -2
- package/dist/features/cart/components/index.js +0 -1
- package/dist/features/cart/hooks/useCartCount.js +22 -5
- package/dist/features/cart/hooks/useGuestCart.js +11 -1
- package/dist/features/cart/utils/guest-cart.d.ts +4 -0
- package/dist/features/cart/utils/guest-cart.js +10 -0
- package/dist/features/cart/utils/pending-ops.d.ts +17 -0
- package/dist/features/cart/utils/pending-ops.js +46 -5
- package/dist/features/categories/components/BrandDetailPageView.js +6 -2
- package/dist/features/categories/components/BundleDetailView.js +4 -2
- package/dist/features/categories/components/CategoryDetailPageView.js +10 -2
- package/dist/features/categories/schemas/firestore.d.ts +7 -0
- package/dist/features/checkout/actions/index.d.ts +0 -1
- package/dist/features/checkout/actions/index.js +0 -1
- package/dist/features/checkout/schemas/firestore.d.ts +2 -2
- package/dist/features/events/actions/event-actions.d.ts +2 -0
- package/dist/features/events/actions/event-actions.js +5 -0
- package/dist/features/events/components/EventCard.js +1 -2
- package/dist/features/events/components/RelatedEventsCarousel.d.ts +15 -0
- package/dist/features/events/components/RelatedEventsCarousel.js +13 -0
- package/dist/features/events/components/index.d.ts +2 -0
- package/dist/features/events/components/index.js +1 -0
- package/dist/features/events/repository/events.repository.d.ts +6 -0
- package/dist/features/events/repository/events.repository.js +21 -0
- package/dist/features/history/repository/user-history.repository.d.ts +1 -1
- package/dist/features/history/utils/guest-history.d.ts +1 -1
- package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -2
- package/dist/features/homepage/components/FeaturedProductsSection.js +2 -2
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/homepage/components/WelcomeSection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +1 -1
- package/dist/features/layout/NavbarLayout.js +1 -1
- package/dist/features/layout/TitleBarLayout.js +2 -2
- package/dist/features/media/MediaVideo.d.ts +7 -0
- package/dist/features/media/MediaVideo.js +36 -5
- package/dist/features/orders/components/OrdersList.js +1 -5
- package/dist/features/orders/schemas/firestore.d.ts +2 -8
- package/dist/features/orders/schemas/index.d.ts +3 -32
- package/dist/features/orders/schemas/index.js +1 -4
- package/dist/features/orders/types/index.d.ts +6 -9
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/actions/product-actions.js +13 -10
- package/dist/features/products/components/MarketplaceBundleCard.d.ts +1 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +7 -4
- package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
- package/dist/features/products/components/PrizeDrawItemsEditor.js +2 -2
- package/dist/features/products/components/PrizeDrawWinnerMappingView.d.ts +14 -0
- package/dist/features/products/components/PrizeDrawWinnerMappingView.js +37 -0
- package/dist/features/products/components/PrizeRevealModal.d.ts +10 -23
- package/dist/features/products/components/PrizeRevealModal.js +18 -147
- package/dist/features/products/components/ProductDetailPageView.js +72 -30
- package/dist/features/products/components/ProductForm.js +7 -14
- package/dist/features/products/components/ProductGalleryClient.d.ts +10 -1
- package/dist/features/products/components/ProductGalleryClient.js +29 -10
- package/dist/features/products/components/ProductsIndexListing.js +2 -1
- package/dist/features/products/components/RelatedProductsCarousel.js +2 -2
- package/dist/features/products/components/ShowGroupSection.js +2 -2
- package/dist/features/products/components/SublistingCarouselSection.js +1 -1
- package/dist/features/products/components/index.d.ts +4 -2
- package/dist/features/products/components/index.js +1 -0
- package/dist/features/products/constants/action-defs.d.ts +0 -1
- package/dist/features/products/constants/action-defs.js +0 -2
- package/dist/features/products/repository/products.repository.d.ts +6 -0
- package/dist/features/products/repository/products.repository.js +20 -1
- package/dist/features/products/schemas/firestore.d.ts +26 -2
- package/dist/features/products/schemas/index.d.ts +11 -3
- package/dist/features/products/schemas/index.js +10 -2
- package/dist/features/products/types/index.d.ts +2 -1
- package/dist/features/reviews/actions/review-actions.js +1 -0
- package/dist/features/reviews/components/ReviewDetailPageView.js +8 -1
- package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
- package/dist/features/reviews/components/ReviewsList.js +2 -1
- package/dist/features/reviews/schemas/firestore.d.ts +3 -0
- package/dist/features/reviews/schemas/index.d.ts +6 -0
- package/dist/features/reviews/schemas/index.js +2 -0
- package/dist/features/reviews/types/index.d.ts +3 -0
- package/dist/features/scams/actions/scam-actions.d.ts +3 -0
- package/dist/features/scams/actions/scam-actions.js +3 -2
- package/dist/features/scams/components/ScamProfileView.d.ts +2 -1
- package/dist/features/scams/components/ScamProfileView.js +2 -2
- package/dist/features/scams/repository/scammer.repository.d.ts +6 -0
- package/dist/features/scams/repository/scammer.repository.js +24 -0
- package/dist/features/seller/actions/seller-actions.js +8 -0
- package/dist/features/seller/components/SellerPrizeDrawsView.js +4 -0
- package/dist/features/stores/components/StoreDetailLayoutView.js +6 -6
- package/dist/features/stores/components/StoreNavTabs.d.ts +10 -1
- package/dist/features/stores/components/StoreNavTabs.js +21 -9
- package/dist/features/tester/actions/index.d.ts +1 -0
- package/dist/features/tester/actions/index.js +1 -0
- package/dist/features/tester/actions/leaderboard-actions.d.ts +3 -0
- package/dist/features/tester/actions/leaderboard-actions.js +5 -0
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +26 -4
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +13 -0
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +23 -0
- package/dist/features/tester/components/BugHunterLeaderboardView.js +18 -0
- package/dist/features/tester/components/index.d.ts +2 -0
- package/dist/features/tester/components/index.js +1 -0
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +12 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +80 -0
- package/dist/features/tester/repository/tester-checklist-response.repository.d.ts +4 -0
- package/dist/features/tester/repository/tester-checklist-response.repository.js +4 -0
- package/dist/features/tester/schemas/firestore.d.ts +22 -1
- package/dist/features/tester/schemas/firestore.js +8 -0
- package/dist/features/tester/seed-data/categories-tester-seed-data.js +42 -7
- package/dist/features/tester/seed-data/events-tester-seed-data.js +23 -0
- package/dist/features/tester/seed-data/orders-tester-seed-data.js +69 -31
- package/dist/features/tester/seed-data/products-tester-seed-data.js +152 -8
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +249 -14
- package/dist/features/wishlist/hooks/useGuestWishlist.js +11 -1
- package/dist/features/wishlist/hooks/useWishlistCount.js +16 -2
- package/dist/features/wishlist/utils/guest-wishlist.d.ts +5 -0
- package/dist/features/wishlist/utils/guest-wishlist.js +11 -0
- package/dist/index.d.ts +19 -22
- package/dist/index.js +25 -58
- package/dist/jobs.d.ts +1 -1
- package/dist/jobs.js +2 -2
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +6 -26
- package/dist/seed/bids-seed-data.js +14 -2
- package/dist/seed/blog-posts-seed-data.js +2 -1
- package/dist/seed/categories-seed-data.js +11 -2
- package/dist/seed/events-seed-data.js +11 -11
- package/dist/seed/faq-seed-data.js +5 -1
- package/dist/seed/products-auctions-seed-data.js +2 -2
- package/dist/seed/products-preorders-seed-data.d.ts +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +56 -10
- package/dist/seed/products-standard-seed-data.js +82 -3
- package/dist/seed/scammers-seed-data.js +75 -2
- package/dist/seed/site-settings-seed-data.js +5 -2
- package/dist/server-entry.d.ts +0 -1
- package/dist/server-entry.js +0 -1
- package/dist/server.d.ts +4 -20
- package/dist/server.js +10 -57
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/BaseListingCard.js +1 -1
- package/dist/ui/components/ImageLightbox.d.ts +9 -0
- package/dist/ui/components/ImageLightbox.js +6 -3
- package/dist/ui/components/ListingToolbar.d.ts +18 -1
- package/dist/ui/components/ListingToolbar.js +5 -2
- package/dist/ui/components/StickyToolbar.js +13 -8
- package/package.json +1 -1
|
@@ -95,6 +95,50 @@ class BlogRepository extends BaseRepository {
|
|
|
95
95
|
throw new DatabaseError(`Failed to find related posts: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Published posts sharing at least one tag with the given list.
|
|
100
|
+
* `array-contains-any` caps at 10 values, so only the first 10 tags are used.
|
|
101
|
+
*/
|
|
102
|
+
async findByTagsOverlap(tags, excludeId, limit = 3) {
|
|
103
|
+
if (tags.length === 0)
|
|
104
|
+
return [];
|
|
105
|
+
try {
|
|
106
|
+
const snapshot = await this.db
|
|
107
|
+
.collection(this.collection)
|
|
108
|
+
.where(BLOG_POST_FIELDS.STATUS, "==", "published")
|
|
109
|
+
.where(BLOG_POST_FIELDS.TAGS, "array-contains-any", tags.slice(0, 10))
|
|
110
|
+
.limit(limit + 1)
|
|
111
|
+
.get();
|
|
112
|
+
return snapshot.docs
|
|
113
|
+
.map((doc) => this.mapDoc(doc))
|
|
114
|
+
.filter((post) => post.id !== excludeId)
|
|
115
|
+
.slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
void normalizeError(error);
|
|
119
|
+
throw new DatabaseError(`Failed to find posts by tag overlap: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Other published posts by the same author. */
|
|
123
|
+
async findByAuthor(authorId, excludeId, limit = 3) {
|
|
124
|
+
try {
|
|
125
|
+
const snapshot = await this.db
|
|
126
|
+
.collection(this.collection)
|
|
127
|
+
.where(BLOG_POST_FIELDS.STATUS, "==", "published")
|
|
128
|
+
.where(BLOG_POST_FIELDS.AUTHOR_ID, "==", authorId)
|
|
129
|
+
.orderBy(BLOG_POST_FIELDS.PUBLISHED_AT, "desc")
|
|
130
|
+
.limit(limit + 1)
|
|
131
|
+
.get();
|
|
132
|
+
return snapshot.docs
|
|
133
|
+
.map((doc) => this.mapDoc(doc))
|
|
134
|
+
.filter((post) => post.id !== excludeId)
|
|
135
|
+
.slice(0, limit);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
void normalizeError(error);
|
|
139
|
+
throw new DatabaseError(`Failed to find posts by author: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
98
142
|
async listPublished(opts, model) {
|
|
99
143
|
let baseQuery = this.getCollection().where(BLOG_POST_FIELDS.STATUS, "==", "published");
|
|
100
144
|
if (opts?.category) {
|
|
@@ -22,6 +22,8 @@ export interface BlogPostDocument extends BaseDocument {
|
|
|
22
22
|
coverImage?: MediaFieldInput;
|
|
23
23
|
contentImages?: MediaField[];
|
|
24
24
|
additionalImages?: MediaField[];
|
|
25
|
+
/** YouTube video ID (11 chars, e.g. "dQw4w9WgXcQ") embedded above the article content. */
|
|
26
|
+
youtubeId?: string;
|
|
25
27
|
category: BlogPostCategory;
|
|
26
28
|
tags: string[];
|
|
27
29
|
isFeatured: boolean;
|
|
@@ -46,6 +48,7 @@ export declare const BLOG_POST_FIELDS: {
|
|
|
46
48
|
readonly COVER_IMAGE: "coverImage";
|
|
47
49
|
readonly CONTENT_IMAGES: "contentImages";
|
|
48
50
|
readonly ADDITIONAL_IMAGES: "additionalImages";
|
|
51
|
+
readonly YOUTUBE_ID: "youtubeId";
|
|
49
52
|
readonly CATEGORY: "category";
|
|
50
53
|
readonly TAGS: "tags";
|
|
51
54
|
readonly IS_FEATURED: "isFeatured";
|
|
@@ -10,6 +10,8 @@ export interface BlogPost {
|
|
|
10
10
|
coverImage?: MediaFieldInput;
|
|
11
11
|
contentImages?: MediaField[];
|
|
12
12
|
additionalImages?: MediaField[];
|
|
13
|
+
/** YouTube video ID (11 chars, e.g. "dQw4w9WgXcQ") embedded above the article content. */
|
|
14
|
+
youtubeId?: string;
|
|
13
15
|
category: BlogPostCategory;
|
|
14
16
|
tags?: string[];
|
|
15
17
|
isFeatured?: boolean;
|
|
@@ -7,8 +7,6 @@ export { CheckoutView } from "./CheckoutView";
|
|
|
7
7
|
export type { CheckoutViewProps } from "./CheckoutView";
|
|
8
8
|
export { CheckoutAddressStep } from "./CheckoutAddressStep";
|
|
9
9
|
export type { CheckoutAddressStepProps, CheckoutAddressStepLabels, } from "./CheckoutAddressStep";
|
|
10
|
-
export { CheckoutOtpModal } from "./CheckoutOtpModal";
|
|
11
|
-
export type { CheckoutOtpModalProps } from "./CheckoutOtpModal";
|
|
12
10
|
export { CheckoutSuccessView } from "./CheckoutSuccessView";
|
|
13
11
|
export type { CheckoutSuccessViewProps } from "./CheckoutSuccessView";
|
|
14
12
|
export { ShippingPicker } from "./ShippingPicker";
|
|
@@ -3,6 +3,5 @@ export { CartView } from "./CartView";
|
|
|
3
3
|
export { CartSummary } from "./CartSummary";
|
|
4
4
|
export { CheckoutView } from "./CheckoutView";
|
|
5
5
|
export { CheckoutAddressStep } from "./CheckoutAddressStep";
|
|
6
|
-
export { CheckoutOtpModal } from "./CheckoutOtpModal";
|
|
7
6
|
export { CheckoutSuccessView } from "./CheckoutSuccessView";
|
|
8
7
|
export { ShippingPicker } from "./ShippingPicker";
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
1
3
|
import { useQuery } from "@tanstack/react-query";
|
|
2
4
|
import { apiClient } from "../../../http";
|
|
3
5
|
import { useGuestCart } from "./useGuestCart";
|
|
4
6
|
import { CART_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
7
|
+
import { CART_OPS_CHANGE_EVENT, getCartOpsDelta } from "../utils/pending-ops";
|
|
5
8
|
/**
|
|
6
9
|
* useCartCount
|
|
7
10
|
*
|
|
@@ -16,11 +19,25 @@ export function useCartCount(enabled = false) {
|
|
|
16
19
|
queryFn: () => apiClient.get(CART_ENDPOINTS.GET),
|
|
17
20
|
enabled,
|
|
18
21
|
});
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
22
|
+
// "Add to cart" quick actions (listing grid, product cards) write local-
|
|
23
|
+
// first — see useSyncManager — and queue an unsynced op that only reaches
|
|
24
|
+
// the server on the next ~30s background sync. Layer that pending delta on
|
|
25
|
+
// top of the server count so the badge updates the instant the op is
|
|
26
|
+
// queued instead of lagging up to 30s behind the toast.
|
|
27
|
+
const [pendingDelta, setPendingDelta] = useState(0);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled)
|
|
30
|
+
return;
|
|
31
|
+
const recompute = () => setPendingDelta(getCartOpsDelta());
|
|
32
|
+
recompute();
|
|
33
|
+
window.addEventListener(CART_OPS_CHANGE_EVENT, recompute);
|
|
34
|
+
return () => window.removeEventListener(CART_OPS_CHANGE_EVENT, recompute);
|
|
35
|
+
}, [enabled]);
|
|
36
|
+
// When authenticated, the server + pending-ops delta is authoritative —
|
|
37
|
+
// never mix in the guest count, otherwise stale localStorage entries leak
|
|
38
|
+
// into the badge after login (the cart-merge clears the local store but
|
|
39
|
+
// `useGuestCart`'s React state holds the pre-clear snapshot until remount).
|
|
23
40
|
if (enabled)
|
|
24
|
-
return data?.itemCount ?? 0;
|
|
41
|
+
return (data?.itemCount ?? 0) + pendingDelta;
|
|
25
42
|
return guestCount;
|
|
26
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useState, useEffect, useCallback } from "react";
|
|
3
|
-
import { getGuestCartItems, addToGuestCart, clearGuestCart, removeFromGuestCart, updateGuestCartQuantity, } from "../utils/guest-cart";
|
|
3
|
+
import { getGuestCartItems, addToGuestCart, clearGuestCart, removeFromGuestCart, updateGuestCartQuantity, GUEST_CART_CHANGE_EVENT, } from "../utils/guest-cart";
|
|
4
4
|
/**
|
|
5
5
|
* useGuestCart
|
|
6
6
|
*
|
|
@@ -10,6 +10,16 @@ export function useGuestCart() {
|
|
|
10
10
|
const [items, setItems] = useState([]);
|
|
11
11
|
useEffect(() => {
|
|
12
12
|
setItems(getGuestCartItems());
|
|
13
|
+
// Cross-instance sync within the same tab (e.g. a listing-page "Add to
|
|
14
|
+
// cart" quick action writing storage while the header badge's own
|
|
15
|
+
// useGuestCart() instance is mounted elsewhere) + cross-tab via "storage".
|
|
16
|
+
const onChange = () => setItems(getGuestCartItems());
|
|
17
|
+
window.addEventListener(GUEST_CART_CHANGE_EVENT, onChange);
|
|
18
|
+
window.addEventListener("storage", onChange);
|
|
19
|
+
return () => {
|
|
20
|
+
window.removeEventListener(GUEST_CART_CHANGE_EVENT, onChange);
|
|
21
|
+
window.removeEventListener("storage", onChange);
|
|
22
|
+
};
|
|
13
23
|
}, []);
|
|
14
24
|
const add = useCallback((productId, quantity, snapshot) => {
|
|
15
25
|
const updated = addToGuestCart(productId, quantity, snapshot);
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Fired whenever guest cart storage is written, so every `useGuestCart()`
|
|
2
|
+
* instance in the tab (header badge, listing quick-add, cart page) can
|
|
3
|
+
* re-read storage and stay in sync without waiting for a remount. */
|
|
4
|
+
export declare const GUEST_CART_CHANGE_EVENT = "appkit/cart/guest-changed";
|
|
1
5
|
export declare class CartFullError extends Error {
|
|
2
6
|
readonly code: "CART_FULL";
|
|
3
7
|
readonly limit = 50;
|
|
@@ -6,6 +6,15 @@ const DEFAULT_GUEST_CART_KEY = process.env.NEXT_PUBLIC_APP_ID
|
|
|
6
6
|
const DEFAULT_GUEST_RETURN_TO_KEY = process.env.NEXT_PUBLIC_APP_ID
|
|
7
7
|
? `${process.env.NEXT_PUBLIC_APP_ID}_guest_return_to`
|
|
8
8
|
: "guest_return_to";
|
|
9
|
+
/** Fired whenever guest cart storage is written, so every `useGuestCart()`
|
|
10
|
+
* instance in the tab (header badge, listing quick-add, cart page) can
|
|
11
|
+
* re-read storage and stay in sync without waiting for a remount. */
|
|
12
|
+
export const GUEST_CART_CHANGE_EVENT = "appkit/cart/guest-changed";
|
|
13
|
+
function dispatchGuestCartChange() {
|
|
14
|
+
if (typeof window === "undefined")
|
|
15
|
+
return;
|
|
16
|
+
window.dispatchEvent(new Event(GUEST_CART_CHANGE_EVENT));
|
|
17
|
+
}
|
|
9
18
|
export class CartFullError extends Error {
|
|
10
19
|
constructor(current) {
|
|
11
20
|
super(`Cart full (${current}/${CART_MAX_ITEMS})`);
|
|
@@ -39,6 +48,7 @@ function writeItems(storage, key, items) {
|
|
|
39
48
|
if (!storage)
|
|
40
49
|
return;
|
|
41
50
|
storage.setItem(key, JSON.stringify(items));
|
|
51
|
+
dispatchGuestCartChange();
|
|
42
52
|
}
|
|
43
53
|
export function getGuestCartItems(storage = getDefaultStorage(), key = DEFAULT_GUEST_CART_KEY) {
|
|
44
54
|
return readItems(storage, key);
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* Written to localStorage so they survive page refreshes.
|
|
6
6
|
* The sync manager replays these and clears them after a successful push.
|
|
7
7
|
*/
|
|
8
|
+
/** Fired whenever the pending cart/wishlist op queue changes, so a badge
|
|
9
|
+
* reading server-authoritative counts (authenticated users) can layer the
|
|
10
|
+
* not-yet-synced local delta on top instead of waiting for the next
|
|
11
|
+
* useSyncManager interval (up to 30s) to reflect a just-added item. */
|
|
12
|
+
export declare const CART_OPS_CHANGE_EVENT = "appkit/cart/ops-changed";
|
|
13
|
+
export declare const WISHLIST_OPS_CHANGE_EVENT = "appkit/wishlist/ops-changed";
|
|
8
14
|
export type CartOpKind = "add" | "remove";
|
|
9
15
|
export type WishlistOpKind = "add" | "remove";
|
|
10
16
|
export interface CartOp {
|
|
@@ -34,6 +40,17 @@ export interface WishlistOp {
|
|
|
34
40
|
export declare function getCartOps(): CartOp[];
|
|
35
41
|
export declare function pushCartOp(op: Omit<CartOp, "ts">): void;
|
|
36
42
|
export declare function clearCartOps(): void;
|
|
43
|
+
/** Net quantity of unsynced "add" ops — layered on top of the server's
|
|
44
|
+
* authoritative itemCount so the header badge updates immediately instead
|
|
45
|
+
* of waiting for the next useSyncManager interval. Cart never queues
|
|
46
|
+
* "remove" ops (removal from an authenticated cart is always a synchronous
|
|
47
|
+
* API call), so summing "add" quantities is exact, not an approximation. */
|
|
48
|
+
export declare function getCartOpsDelta(): number;
|
|
37
49
|
export declare function getWishlistOps(): WishlistOp[];
|
|
38
50
|
export declare function pushWishlistOp(op: Omit<WishlistOp, "ts">): void;
|
|
51
|
+
/** Net count of unsynced wishlist ops — add/remove pairs for the same item
|
|
52
|
+
* are already collapsed by `pushWishlistOp`, so this is exact: +1 per
|
|
53
|
+
* queued "add", -1 per queued "remove" (an item already on the server
|
|
54
|
+
* being removed before the next sync). */
|
|
55
|
+
export declare function getWishlistOpsDelta(): number;
|
|
39
56
|
export declare function clearWishlistOps(): void;
|
|
@@ -11,6 +11,22 @@ const APP_ID = () => typeof process !== "undefined" && process.env.NEXT_PUBLIC_A
|
|
|
11
11
|
: "letitrip";
|
|
12
12
|
const CART_OPS_KEY = () => `${APP_ID()}_cart_ops`;
|
|
13
13
|
const WL_OPS_KEY = () => `${APP_ID()}_wl_ops`;
|
|
14
|
+
/** Fired whenever the pending cart/wishlist op queue changes, so a badge
|
|
15
|
+
* reading server-authoritative counts (authenticated users) can layer the
|
|
16
|
+
* not-yet-synced local delta on top instead of waiting for the next
|
|
17
|
+
* useSyncManager interval (up to 30s) to reflect a just-added item. */
|
|
18
|
+
export const CART_OPS_CHANGE_EVENT = "appkit/cart/ops-changed";
|
|
19
|
+
export const WISHLIST_OPS_CHANGE_EVENT = "appkit/wishlist/ops-changed";
|
|
20
|
+
function dispatchCartOpsChange() {
|
|
21
|
+
if (typeof window === "undefined")
|
|
22
|
+
return;
|
|
23
|
+
window.dispatchEvent(new Event(CART_OPS_CHANGE_EVENT));
|
|
24
|
+
}
|
|
25
|
+
function dispatchWishlistOpsChange() {
|
|
26
|
+
if (typeof window === "undefined")
|
|
27
|
+
return;
|
|
28
|
+
window.dispatchEvent(new Event(WISHLIST_OPS_CHANGE_EVENT));
|
|
29
|
+
}
|
|
14
30
|
// ---------------------------------------------------------------------------
|
|
15
31
|
// Storage helpers
|
|
16
32
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +56,14 @@ function write(key, items) {
|
|
|
40
56
|
// storage quota exceeded — best effort
|
|
41
57
|
}
|
|
42
58
|
}
|
|
59
|
+
function writeCartOps(items) {
|
|
60
|
+
write(CART_OPS_KEY(), items);
|
|
61
|
+
dispatchCartOpsChange();
|
|
62
|
+
}
|
|
63
|
+
function writeWishlistOps(items) {
|
|
64
|
+
write(WL_OPS_KEY(), items);
|
|
65
|
+
dispatchWishlistOpsChange();
|
|
66
|
+
}
|
|
43
67
|
// ---------------------------------------------------------------------------
|
|
44
68
|
// Cart ops
|
|
45
69
|
// ---------------------------------------------------------------------------
|
|
@@ -53,7 +77,7 @@ export function pushCartOp(op) {
|
|
|
53
77
|
const withoutProduct = ops.filter((o) => o.productId !== op.productId);
|
|
54
78
|
if (withoutProduct.length < ops.length) {
|
|
55
79
|
// There was a pending add — cancelling
|
|
56
|
-
|
|
80
|
+
writeCartOps(withoutProduct);
|
|
57
81
|
return;
|
|
58
82
|
}
|
|
59
83
|
}
|
|
@@ -64,16 +88,25 @@ export function pushCartOp(op) {
|
|
|
64
88
|
const merged = ops.map((o) => o.op === "add" && o.productId === op.productId
|
|
65
89
|
? { ...o, quantity: Math.min((o.quantity ?? 1) + (op.quantity ?? 1), 99) }
|
|
66
90
|
: o);
|
|
67
|
-
|
|
91
|
+
writeCartOps(merged);
|
|
68
92
|
return;
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
|
-
|
|
95
|
+
writeCartOps([...ops, { ...op, ts: Date.now() }]);
|
|
72
96
|
}
|
|
73
97
|
export function clearCartOps() {
|
|
74
98
|
if (typeof window === "undefined")
|
|
75
99
|
return;
|
|
76
100
|
localStorage.removeItem(CART_OPS_KEY());
|
|
101
|
+
dispatchCartOpsChange();
|
|
102
|
+
}
|
|
103
|
+
/** Net quantity of unsynced "add" ops — layered on top of the server's
|
|
104
|
+
* authoritative itemCount so the header badge updates immediately instead
|
|
105
|
+
* of waiting for the next useSyncManager interval. Cart never queues
|
|
106
|
+
* "remove" ops (removal from an authenticated cart is always a synchronous
|
|
107
|
+
* API call), so summing "add" quantities is exact, not an approximation. */
|
|
108
|
+
export function getCartOpsDelta() {
|
|
109
|
+
return getCartOps().reduce((sum, op) => (op.op === "add" ? sum + (op.quantity ?? 1) : sum), 0);
|
|
77
110
|
}
|
|
78
111
|
// ---------------------------------------------------------------------------
|
|
79
112
|
// Wishlist ops
|
|
@@ -88,7 +121,7 @@ export function pushWishlistOp(op) {
|
|
|
88
121
|
if (op.op === "remove") {
|
|
89
122
|
const withoutItem = ops.filter((o) => key(o) !== key(op));
|
|
90
123
|
if (withoutItem.length < ops.length) {
|
|
91
|
-
|
|
124
|
+
writeWishlistOps(withoutItem);
|
|
92
125
|
return;
|
|
93
126
|
}
|
|
94
127
|
}
|
|
@@ -96,10 +129,18 @@ export function pushWishlistOp(op) {
|
|
|
96
129
|
if (op.op === "add" && ops.some((o) => o.op === "add" && key(o) === key(op))) {
|
|
97
130
|
return;
|
|
98
131
|
}
|
|
99
|
-
|
|
132
|
+
writeWishlistOps([...ops, { ...op, ts: Date.now() }]);
|
|
133
|
+
}
|
|
134
|
+
/** Net count of unsynced wishlist ops — add/remove pairs for the same item
|
|
135
|
+
* are already collapsed by `pushWishlistOp`, so this is exact: +1 per
|
|
136
|
+
* queued "add", -1 per queued "remove" (an item already on the server
|
|
137
|
+
* being removed before the next sync). */
|
|
138
|
+
export function getWishlistOpsDelta() {
|
|
139
|
+
return getWishlistOps().reduce((sum, op) => sum + (op.op === "add" ? 1 : -1), 0);
|
|
100
140
|
}
|
|
101
141
|
export function clearWishlistOps() {
|
|
102
142
|
if (typeof window === "undefined")
|
|
103
143
|
return;
|
|
104
144
|
localStorage.removeItem(WL_OPS_KEY());
|
|
145
|
+
dispatchWishlistOpsChange();
|
|
105
146
|
}
|
|
@@ -8,6 +8,7 @@ import { Container, Div, Heading, Main, Nav, Section, Span, Text } from "../../.
|
|
|
8
8
|
import { DynamicBgDiv } from "../../../ui/components/DynamicBgDiv";
|
|
9
9
|
import { MediaImage } from "../../media/MediaImage";
|
|
10
10
|
import { BrandDetailTabs } from "./BrandDetailTabs";
|
|
11
|
+
import { CategoryGrid } from "./CategoryGrid";
|
|
11
12
|
const __O = {
|
|
12
13
|
hidden: "overflow-hidden",
|
|
13
14
|
};
|
|
@@ -16,7 +17,7 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
16
17
|
.getCategoryBySlug(slug)
|
|
17
18
|
.catch(() => undefined)));
|
|
18
19
|
const brandName = brand?.name;
|
|
19
|
-
const [productsResult, auctionsResult, preOrdersResult, prizeDrawsResult, allBundles] = await Promise.all([
|
|
20
|
+
const [productsResult, auctionsResult, preOrdersResult, prizeDrawsResult, allBundles, activeBrands] = await Promise.all([
|
|
20
21
|
brandName
|
|
21
22
|
? productRepository
|
|
22
23
|
.list({
|
|
@@ -65,6 +66,8 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
65
66
|
.listByType("bundle", { activeOnly: true, limit: 50 })
|
|
66
67
|
.catch(() => [])
|
|
67
68
|
: Promise.resolve([]),
|
|
69
|
+
// Related brands — every other active brand row, excluding this one.
|
|
70
|
+
categoriesRepository.findActiveBrands().catch(() => []),
|
|
68
71
|
]);
|
|
69
72
|
const brandLower = brandName?.toLowerCase();
|
|
70
73
|
const brandBundles = brandLower
|
|
@@ -73,6 +76,7 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
73
76
|
return seo.includes(brandLower);
|
|
74
77
|
})
|
|
75
78
|
: [];
|
|
79
|
+
const relatedBrands = activeBrands.filter((b) => b.id !== brand?.id);
|
|
76
80
|
const coverImage = brand?.display?.coverImage;
|
|
77
81
|
const hasCover = Boolean(coverImage);
|
|
78
82
|
// Brand accent — falls back to the primary theme token when the brand hasn't
|
|
@@ -91,5 +95,5 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
91
95
|
counts.preOrders +
|
|
92
96
|
counts.prizeDraws +
|
|
93
97
|
counts.bundles;
|
|
94
|
-
return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [counts.products > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), counts.auctions > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), counts.preOrders > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.preOrders.toLocaleString(), " ", counts.preOrders === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) })] }));
|
|
98
|
+
return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [counts.products > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), counts.auctions > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), counts.preOrders > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.preOrders.toLocaleString(), " ", counts.preOrders === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) }), relatedBrands.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Brands" }), _jsx(CategoryGrid, { categories: relatedBrands, getHref: (b) => String(ROUTES.PUBLIC.BRAND_DETAIL(b.slug)) })] }) }))] }));
|
|
95
99
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Badge, Container, Div, Heading, Li, Main, Row, Section, Span, Stack, Text, Ul, } from "../../../ui";
|
|
3
3
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
4
4
|
import Link from "next/link";
|
|
@@ -7,6 +7,7 @@ import { BundleBuyNowCta } from "./BundleBuyNowCta";
|
|
|
7
7
|
import { BundleCollage } from "./BundleCollage";
|
|
8
8
|
import { MediaImage } from "../../media/MediaImage";
|
|
9
9
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
10
|
+
import { computeBundleDiscount } from "../../../_internal/shared/features/categories/bundle-pricing";
|
|
10
11
|
const __O = {
|
|
11
12
|
hidden: "overflow-hidden",
|
|
12
13
|
};
|
|
@@ -22,5 +23,6 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, }) {
|
|
|
22
23
|
const priceLabel = bundle.bundlePrice
|
|
23
24
|
? formatCurrency(bundle.bundlePrice, "INR")
|
|
24
25
|
: BUNDLE_COPY.detail.priceFallback;
|
|
25
|
-
|
|
26
|
+
const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
|
|
27
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }) }));
|
|
26
28
|
}
|
|
@@ -7,6 +7,7 @@ import { ROUTES } from "../../../next";
|
|
|
7
7
|
import { Container, Div, Heading, Main, Nav, Section, Span, Text } from "../../../ui";
|
|
8
8
|
import { MediaImage } from "../../media/MediaImage";
|
|
9
9
|
import { CategoryDetailTabs } from "./CategoryDetailTabs";
|
|
10
|
+
import { CategoryGrid } from "./CategoryGrid";
|
|
10
11
|
const __O = {
|
|
11
12
|
hidden: "overflow-hidden",
|
|
12
13
|
xAuto: "overflow-x-auto",
|
|
@@ -18,7 +19,7 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
18
19
|
// Use categorySlugs@=<id> (array-contains) — products store category FK in
|
|
19
20
|
// the categorySlugs[] array, not the legacy `category` string field.
|
|
20
21
|
const catFilter = category?.id ? sieveFilter("categorySlugs", SIEVE_OP.CONTAINS, category.id) : null;
|
|
21
|
-
const [productsResult, auctionsCountResult, preOrdersCountResult, prizeDrawsCountResult, bundlesResult, childCategories] = await Promise.all([
|
|
22
|
+
const [productsResult, auctionsCountResult, preOrdersCountResult, prizeDrawsCountResult, bundlesResult, childCategories, rootSiblingCategories] = await Promise.all([
|
|
22
23
|
catFilter
|
|
23
24
|
? productRepository
|
|
24
25
|
.list({
|
|
@@ -69,7 +70,14 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
69
70
|
category?.id
|
|
70
71
|
? categoriesRepository.getChildren(category.id).catch(() => [])
|
|
71
72
|
: Promise.resolve([]),
|
|
73
|
+
// Related categories — every other category sharing this category's root
|
|
74
|
+
// (siblings + cousins across the tree, up to the tier-0/1/2 depth the
|
|
75
|
+
// catalog actually uses), not just direct children.
|
|
76
|
+
category?.rootId
|
|
77
|
+
? categoriesRepository.getCategoriesByRootId(category.rootId).catch(() => [])
|
|
78
|
+
: Promise.resolve([]),
|
|
72
79
|
]);
|
|
80
|
+
const relatedCategories = rootSiblingCategories.filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"));
|
|
73
81
|
// Stores tab — query stores whose storeCategory matches this category or any child
|
|
74
82
|
const storeCategorySlugs = [
|
|
75
83
|
slug,
|
|
@@ -121,5 +129,5 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
121
129
|
prizeDraws: prizeDrawCount,
|
|
122
130
|
bundles: bundleCount,
|
|
123
131
|
stores: storeCount,
|
|
124
|
-
} }) }) })] }));
|
|
132
|
+
} }) }) }), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
|
|
125
133
|
}
|
|
@@ -145,6 +145,13 @@ export interface CategoryDocument extends BaseDocument {
|
|
|
145
145
|
bundleQueryResolvedAt?: Date;
|
|
146
146
|
/** Hand-picked products list (mirror of bundleQueryRule for static rules); kept for index-friendly queries. */
|
|
147
147
|
bundleProductIds?: string[];
|
|
148
|
+
/**
|
|
149
|
+
* Sum of member products' individual prices (decimal rupees) — the "buy
|
|
150
|
+
* separately" total the discount badge is measured against. Denormalised
|
|
151
|
+
* at bundle create/update time and refreshed by the daily bundle stock
|
|
152
|
+
* sync job; undefined when a member price couldn't be resolved.
|
|
153
|
+
*/
|
|
154
|
+
bundleOriginalTotal?: number;
|
|
148
155
|
/**
|
|
149
156
|
* Per-member metadata parallel to `bundleProductIds`.
|
|
150
157
|
* Carries `drawCount` for prize-draw members (how many raffle entries
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Checkout Feature Firestore Document Types & Constants
|
|
3
3
|
* Covers: failed checkouts, failed payments
|
|
4
4
|
*/
|
|
5
|
-
export type FailedCheckoutReason = "
|
|
6
|
-
export type FailedPaymentReason = "signature_mismatch" | "payment_cancelled" | "amount_mismatch" | "
|
|
5
|
+
export type FailedCheckoutReason = "stock_failed" | "address_not_found" | "cart_empty" | "validation_error" | "unknown";
|
|
6
|
+
export type FailedPaymentReason = "signature_mismatch" | "payment_cancelled" | "amount_mismatch" | "product_unavailable" | "stock_insufficient" | "unknown";
|
|
7
7
|
export type FailedCheckoutDocument = {
|
|
8
8
|
id: string;
|
|
9
9
|
uid: string;
|
|
@@ -66,6 +66,8 @@ export declare function listPublicEvents(params?: string | {
|
|
|
66
66
|
}): Promise<FirebaseSieveResult<EventDocument>>;
|
|
67
67
|
export declare function getPublicEventById(id: string): Promise<EventDocument | null>;
|
|
68
68
|
export declare function getEventLeaderboard(eventId: string): Promise<import("../types").LeaderboardEntry[]>;
|
|
69
|
+
/** Related events — other active events sharing at least one tag, excluding this one. */
|
|
70
|
+
export declare function getRelatedEvents(tags: string[], excludeId: string, limit?: number): Promise<EventDocument[]>;
|
|
69
71
|
export declare function adminListEvents(params?: {
|
|
70
72
|
filters?: string;
|
|
71
73
|
sorts?: string;
|
|
@@ -148,6 +148,11 @@ export async function getEventLeaderboard(eventId) {
|
|
|
148
148
|
return [];
|
|
149
149
|
return eventEntryRepository.getLeaderboard(event.id);
|
|
150
150
|
}
|
|
151
|
+
/** Related events — other active events sharing at least one tag, excluding this one. */
|
|
152
|
+
export async function getRelatedEvents(tags, excludeId, limit = 6) {
|
|
153
|
+
const docs = await eventRepository.findByTagsOverlap(tags, limit + 1);
|
|
154
|
+
return docs.filter((e) => e.id !== excludeId).slice(0, limit);
|
|
155
|
+
}
|
|
151
156
|
export async function adminListEvents(params) {
|
|
152
157
|
const sieve = {
|
|
153
158
|
filters: params?.filters,
|
|
@@ -3,7 +3,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { Article, BaseListingCard, Button, Div, Heading, RichText, Row, Span, Stack, TextLink } from "../../../ui";
|
|
5
5
|
import { MediaImage } from "../../media/MediaImage";
|
|
6
|
-
import { LAYOUT } from "../../../tokens";
|
|
7
6
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter";
|
|
8
7
|
import { EVENT_FIELDS } from "../schemas";
|
|
9
8
|
import { EventStatusBadge } from "./EventStatusBadge";
|
|
@@ -33,6 +32,6 @@ export function EventCard({ event, labels = {}, onParticipate, className = "", s
|
|
|
33
32
|
const msLeft = endsAt.getTime() - now.getTime();
|
|
34
33
|
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
|
|
35
34
|
const detailHref = String(ROUTES.PUBLIC.EVENT_DETAIL(event.slug ?? event.id));
|
|
36
|
-
return (_jsxs(Article, { border: "default", rounded: "xl", shadow: "hover-md", className: `group relative h-full
|
|
35
|
+
return (_jsxs(Article, { border: "default", rounded: "xl", shadow: "hover-md", className: `group relative flex h-full flex-col overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100 transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: TYPE_ICONS[event.type] }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(Span, { size: "lg", "aria-hidden": "true", children: TYPE_ICONS[event.type] }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
|
|
37
36
|
daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\uD83D\uDC65 ", event.stats.totalEntries, " ", labels.entries ?? "entries"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
|
|
38
37
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MediaField } from "../../media/types";
|
|
3
|
+
export interface RelatedEventItem {
|
|
4
|
+
id: string;
|
|
5
|
+
slug?: string;
|
|
6
|
+
title: string;
|
|
7
|
+
coverImage?: MediaField | null;
|
|
8
|
+
coverImageUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
interface RelatedEventsCarouselProps {
|
|
11
|
+
items: RelatedEventItem[];
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function RelatedEventsCarousel({ items, title, }: RelatedEventsCarouselProps): React.JSX.Element | null;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ROUTES } from "../../../next";
|
|
5
|
+
import { SectionCarousel } from "../../homepage/components/SectionCarousel";
|
|
6
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
7
|
+
import { Div, Heading, Stack } from "../../../ui";
|
|
8
|
+
import { CAROUSEL_PER_VIEW } from "../../homepage/constants/carousel-per-view";
|
|
9
|
+
export function RelatedEventsCarousel({ items, title = "Related Events", }) {
|
|
10
|
+
if (items.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
return (_jsx(SectionCarousel, { title: title, pillLabel: "Related Events", headingVariant: "editorial", items: items, isLoading: false, perView: CAROUSEL_PER_VIEW.standard, keyExtractor: (item) => item.id, renderItem: (item) => (_jsx(Link, { href: String(ROUTES.PUBLIC.EVENT_DETAIL(item.slug || item.id)), className: "block h-full", children: _jsxs(Stack, { className: "h-full", surface: "default", rounded: "xl", border: "default", overflow: "hidden", children: [_jsx(Div, { surface: "muted", overflow: "hidden", className: "relative aspect-[4/3] w-full flex-shrink-0", children: (item.coverImage?.url || item.coverImageUrl) && (_jsx(MediaImage, { src: (item.coverImage?.url || item.coverImageUrl), alt: item.title, size: "card" })) }), _jsx(Heading, { level: 3, className: "p-[var(--appkit-space-3)]", truncate: 2, size: "sm", weight: "semibold", children: item.title })] }) })) }));
|
|
13
|
+
}
|
|
@@ -2,6 +2,8 @@ export { EventStatusBadge } from "./EventStatusBadge";
|
|
|
2
2
|
export { EventFilters, EVENT_SORT_OPTIONS, EVENT_FILTER_KEYS, EVENT_ADMIN_SORT_OPTIONS, EVENT_SELLER_SORT_OPTIONS, EVENT_PUBLIC_SORT_OPTIONS, getEventFilterKeys, getEventSortOptions, } from "./EventFilters";
|
|
3
3
|
export type { EventFiltersProps, EventFilterVariant } from "./EventFilters";
|
|
4
4
|
export { EventCard } from "./EventCard";
|
|
5
|
+
export { RelatedEventsCarousel } from "./RelatedEventsCarousel";
|
|
6
|
+
export type { RelatedEventItem } from "./RelatedEventsCarousel";
|
|
5
7
|
export { EventsListView } from "./EventsListView";
|
|
6
8
|
export { EventsIndexListing } from "./EventsIndexListing";
|
|
7
9
|
export type { EventsIndexListingProps } from "./EventsIndexListing";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { EventStatusBadge } from "./EventStatusBadge";
|
|
2
2
|
export { EventFilters, EVENT_SORT_OPTIONS, EVENT_FILTER_KEYS, EVENT_ADMIN_SORT_OPTIONS, EVENT_SELLER_SORT_OPTIONS, EVENT_PUBLIC_SORT_OPTIONS, getEventFilterKeys, getEventSortOptions, } from "./EventFilters";
|
|
3
3
|
export { EventCard } from "./EventCard";
|
|
4
|
+
export { RelatedEventsCarousel } from "./RelatedEventsCarousel";
|
|
4
5
|
export { EventsListView } from "./EventsListView";
|
|
5
6
|
export { EventsIndexListing } from "./EventsIndexListing";
|
|
6
7
|
export { EventDetailView } from "./EventDetailView";
|