From 9bfe057c49a8b9800e732da7e451f50f0208093e Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:03:55 +0530 Subject: [PATCH] enh --- .commandcode/settings.json | 9 + .commandcode/taste/taste.md | 9 +- .commandcode/taste/taste/taste.md | 11 ++ apps/admin-ui/app/(drawer)/products/add.tsx | 11 +- apps/admin-ui/app/(drawer)/products/edit.tsx | 9 + apps/admin-ui/src/components/ProductForm.tsx | 46 ++++- .../src/trpc/apis/admin-apis/apis/product.ts | 4 + .../src/trpc/apis/common-apis/common.ts | 29 +++- .../app/(drawer)/(tabs)/home/index.tsx | 162 +++++++++++++++++- .../app/(drawer)/(tabs)/order-again/index.tsx | 4 +- apps/user-ui/app/(drawer)/_layout.tsx | 6 +- .../components/CheckoutAddressSelector.tsx | 30 +++- apps/user-ui/components/ProductDetail.tsx | 8 +- apps/user-ui/components/SlotSpecificView.tsx | 4 +- apps/user-ui/components/cart-page.tsx | 5 +- apps/user-ui/components/floating-cart-bar.tsx | 11 +- apps/user-ui/components/icons/OffersIcon.tsx | 29 ++++ .../src/components/AddToCartDialog.tsx | 2 +- .../src/admin-apis/product.ts | 28 +++ .../src/stores/store-helpers.ts | 15 +- .../db_helper_sqlite/src/user-apis/cart.ts | 48 +++--- .../db_helper_sqlite/src/user-apis/product.ts | 4 +- 22 files changed, 427 insertions(+), 57 deletions(-) create mode 100644 .commandcode/settings.json create mode 100644 .commandcode/taste/taste/taste.md create mode 100644 apps/user-ui/components/icons/OffersIcon.tsx diff --git a/.commandcode/settings.json b/.commandcode/settings.json new file mode 100644 index 0000000..b8291e3 --- /dev/null +++ b/.commandcode/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)" + ], + "deny": [], + "defaultMode": "default" + } +} \ No newline at end of file diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md index eb6348c..1e0e882 100644 --- a/.commandcode/taste/taste.md +++ b/.commandcode/taste/taste.md @@ -1,9 +1,2 @@ # Taste - -- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 - -- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 - -- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 - -- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 +See [taste/taste.md](taste/taste.md) diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md new file mode 100644 index 0000000..84ef180 --- /dev/null +++ b/.commandcode/taste/taste/taste.md @@ -0,0 +1,11 @@ +# Taste +- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9 +- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6 +- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9 +- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4 +- Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7 +- Wants the agent to come up with a plan first before implementing code changes. Confidence: 0.9 +- Appreciates being asked clarifying design questions with concrete options during planning. Confidence: 0.7 +- Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9 +- Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9 +- When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8 diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 2a4427c..df118c3 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -25,6 +25,13 @@ export default function AddProduct() { const seenSignatures = new Set() for (const variant of values.variants) { const attributes = variant.attributes || [] + const hasQuantity = attributes.some( + (a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + Alert.alert('Error', 'Every SKU must have a quantity feature') + return + } const signature = attributes .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .sort() @@ -68,6 +75,7 @@ export default function AddProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, @@ -111,7 +119,8 @@ export default function AddProduct() { flashPrice: '', isOffer: false, isComboOnly: false, - attributes: [{ featureName: 'quantity', featureValue: '' }], + isSuspended: false, + attributes: [{ featureName: '', featureValue: '' }], comboItems: [] as { skuId: number | string }[], }, ], diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 4ef6f28..a6e06d2 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -51,6 +51,7 @@ export default function EditProduct() { flashPrice: sku.flashPrice || '', isOffer: sku.isOffer || false, isComboOnly: sku.isComboOnly || false, + isSuspended: sku.isSuspended || false, attributes: (sku.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue, @@ -89,6 +90,13 @@ export default function EditProduct() { const seenSignatures = new Set() for (const variant of values.variants) { const attributes = variant.attributes || [] + const hasQuantity = attributes.some( + (a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + Alert.alert('Error', 'Every SKU must have a quantity feature') + return + } const signature = attributes .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .sort() @@ -144,6 +152,7 @@ export default function EditProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, featureValue: attr.featureValue, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 8d0db2c..336c706 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -20,6 +20,7 @@ interface Variant { flashPrice: string isOffer: boolean isComboOnly: boolean + isSuspended: boolean attributes: Attribute[] comboItems: { skuId: number | string }[] } @@ -46,7 +47,7 @@ interface ProductFormProps { existingVariantImageKeys?: string[][] } -const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) +const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' }) const defaultVariant = (): Variant => ({ id: undefined, @@ -57,6 +58,7 @@ const defaultVariant = (): Variant => ({ flashPrice: '', isOffer: false, isComboOnly: false, + isSuspended: false, attributes: [defaultAttribute()], comboItems: [], }) @@ -112,6 +114,19 @@ const productValidationSchema = Yup.object().shape({ seen.add(signature) } return true + }) + .test('quantity-feature', 'Every SKU must have a quantity feature', function (variants) { + if (!Array.isArray(variants)) return true + for (const variant of variants) { + const attrs = variant?.attributes || [] + const hasQuantity = attrs.some( + (a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (!hasQuantity) { + return this.createError({ message: 'Every SKU must have a quantity feature' }) + } + } + return true }), }) @@ -289,6 +304,13 @@ const ProductForm = forwardRef(({ )} ))} + pushAttr(defaultAttribute())} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`} + > + + Add Feature + )} @@ -344,6 +366,17 @@ const ProductForm = forwardRef(({ Combo Only SKU + {mode === 'edit' && ( + + setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)} + style={tw`mr-3`} + /> + Suspend SKU + + )} + {variant.isFlashAvailable && ( (({ )} ))} + + { + push(defaultVariant()) + setVariantImages((prev) => [...prev, []]) + }} + style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`} + > + + Add Variant + )} diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 4e4f69f..8d99e3e 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -198,6 +198,7 @@ export const productRouter = router({ flashPrice: z.number().optional().nullable(), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), featureValue: z.string().min(1, 'Value is required'), @@ -226,6 +227,7 @@ export const productRouter = router({ flashPrice: sku.flashPrice ?? null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, featureValue: f.featureValue, @@ -284,6 +286,7 @@ export const productRouter = router({ flashPrice: z.number().optional().nullable(), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), featureValue: z.string().min(1, 'Value is required'), @@ -314,6 +317,7 @@ export const productRouter = router({ flashPrice: sku.flashPrice ?? null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, featureValue: f.featureValue, diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index be00330..5f945b9 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -4,8 +4,10 @@ import { getNextDeliveryDateWithCapacity, getStoresSummary, getAllSkusSummary as getAllSkusSummaryInDb, + getAllTagsForCache, + getAllTagProductMappings, } from '@/src/dbService' -import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' +import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store' @@ -60,9 +62,34 @@ export async function scaffoldProducts() { }) ); + // Fetch all product tags with their mapped product ids + const [allTags, tagMappings] = await Promise.all([ + getAllTagsForCache(), + getAllTagProductMappings(), + ]) + + const productIdsByTag = new Map() + for (const mapping of tagMappings) { + if (!productIdsByTag.has(mapping.tagId)) { + productIdsByTag.set(mapping.tagId, []) + } + productIdsByTag.get(mapping.tagId)!.push(mapping.productId) + } + + const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + isDashboardTag: tag.isDashboardTag, + relatedStores: (tag.relatedStores as number[]) || [], + productIds: productIdsByTag.get(tag.id) || [], + })) + return { products: formattedProducts, count: formattedProducts.length, + tags, }; } diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index bd26514..3ae8a63 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, memo } from "react"; -import { View, Dimensions, Image, RefreshControl } from "react-native"; +import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native"; import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { @@ -58,6 +58,20 @@ const staticStyles = { slotsListContent: { paddingBottom: 24 }, }; +// Light/pastel color pairs for the Explore Products tabs. +const TAG_COLORS = [ + { bg: '#FFE4E6', border: '#FECDD3', text: '#BE123C' }, // rose + { bg: '#FEF3C7', border: '#FDE68A', text: '#B45309' }, // amber + { bg: '#DCFCE7', border: '#BBF7D0', text: '#15803D' }, // green + { bg: '#DBEAFE', border: '#BFDBFE', text: '#1D4ED8' }, // blue + { bg: '#EDE9FE', border: '#DDD6FE', text: '#6D28D9' }, // violet + { bg: '#FFE4CC', border: '#FFD6B0', text: '#C2410C' }, // orange + { bg: '#CFFAFE', border: '#A5F3FC', text: '#0E7490' }, // cyan + { bg: '#FCE7F3', border: '#FBCFE8', text: '#BE185D' }, // pink +]; + +const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]; + interface RenderStoreProps { item: any; } @@ -196,6 +210,61 @@ const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) => ); }); +interface ExploreTabProps { + tag: any; + isSelected: boolean; + onPress: () => void; +} + +const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { + const color = getTagColor(tag.id); + const productCount = tag.productIds?.length || 0; + + return ( + + + {tag.tagName} ({productCount}) + + + ); +}); + +interface ExploreProductItemProps { + item: any; + onPress: (id: number) => void; +} + +const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => { + const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); + + return ( + + + + ); +}); + interface SlotItemProps { item: any; } @@ -229,6 +298,10 @@ interface ListHeaderProps { popularProducts: any[]; sortedSlots: any[]; onProductPress: (id: number) => void; + dashboardTags: any[]; + activeTagId: number | null; + activeTagProducts: any[]; + onSelectTag: (id: number) => void; } const ListHeader = memo(({ @@ -238,6 +311,10 @@ const ListHeader = memo(({ popularProducts, sortedSlots, onProductPress, + dashboardTags, + activeTagId, + activeTagProducts, + onSelectTag, }: ListHeaderProps) => { const handleLayout = useCallback((event: any) => { const { y, height } = event.nativeEvent.layout; @@ -248,6 +325,10 @@ const ListHeader = memo(({ ), [onProductPress]); + const renderExploreItem = useCallback(({ item }: { item: any }) => ( + + ), [onProductPress]); + const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); @@ -323,6 +404,55 @@ const ListHeader = memo(({ /> + {dashboardTags.length > 0 && ( + + + Explore Products + Browse by category + + + {dashboardTags.map((tag) => ( + onSelectTag(tag.id)} + /> + ))} + + {activeTagProducts.length > 0 ? ( + + item.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={staticStyles.popularListContent} + renderItem={renderExploreItem} + removeClippedSubviews={true} + /> + + + ) : ( + + + No products in this category yet + + + )} + + )} + {sortedSlots.length > 0 && ( @@ -386,6 +516,10 @@ export default function Dashboard() { const { data: slotsData } = useSlots(); const products = productsData?.products || []; + const dashboardTags = productsData?.tags || []; + + const [selectedTagId, setSelectedTagId] = useState(null); + const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null; React.useEffect(() => { @@ -446,6 +580,26 @@ export default function Dashboard() { .filter((product): product is NonNullable => product != null); }, [popularItemIds, products]); + const activeTagProducts = useMemo(() => { + if (activeTagId == null) return []; + const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId); + if (!activeTag) return []; + + return products + .filter((product: any) => activeTag.productIds?.includes(product.id) ?? false) + .sort((a: any, b: any) => { + const slotA = getQuickestSlot(a.id) + const slotB = getQuickestSlot(b.id) + + const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA + const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB + + if (aOutOfStock && !bOutOfStock) return 1 + if (!aOutOfStock && bOutOfStock) return -1 + return 0 + }); + }, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]); + const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { @@ -499,8 +653,12 @@ export default function Dashboard() { popularProducts={popularProducts} sortedSlots={sortedSlots} onProductPress={handleProductPress} + dashboardTags={dashboardTags} + activeTagId={activeTagId} + activeTagProducts={activeTagProducts} + onSelectTag={setSelectedTagId} /> - ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress]); + ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts]); const searchBarContainerStyle = useMemo(() => [ tw`w-full px-4 pt-4 pb-2`, diff --git a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx index b18b9dd..5428556 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/order-again/index.tsx @@ -19,7 +19,7 @@ import TabLayoutWrapper from "@/components/TabLayoutWrapper"; const { width: screenWidth } = Dimensions.get("window"); const itemWidth = screenWidth * 0.45; -const rowListContent = { paddingBottom: 16 }; +const rowListContent = { paddingBottom: 16, paddingHorizontal: 16 }; interface OffersRowProps { title: string; @@ -75,7 +75,7 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps colors={["transparent", "rgba(0,0,0,0.08)"]} start={{ x: 0, y: 0.5 }} end={{ x: 1, y: 0.5 }} - style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`} + style={tw`absolute right-4 top-0 bottom-4 w-12 rounded-l-xl`} pointerEvents="none" /> diff --git a/apps/user-ui/app/(drawer)/_layout.tsx b/apps/user-ui/app/(drawer)/_layout.tsx index d90fdd6..240176b 100755 --- a/apps/user-ui/app/(drawer)/_layout.tsx +++ b/apps/user-ui/app/(drawer)/_layout.tsx @@ -11,7 +11,7 @@ import { useAuth } from "@/src/contexts/AuthContext"; import { tw, theme, MyTouchableOpacity, MyText } from "common-ui"; import HomeIcon from "@/components/icons/HomeIcon"; import StoresIcon from "@/components/icons/StoresIcon"; -import OrderAgainIcon from "@/components/icons/OrderAgainIcon"; +import OffersIcon from "@/components/icons/OffersIcon"; import MeIcon from "@/components/icons/MeIcon"; import { useAppStore } from "@/src/store/appStore"; @@ -97,9 +97,9 @@ export default function Layout() { ( - + ), }} /> diff --git a/apps/user-ui/components/CheckoutAddressSelector.tsx b/apps/user-ui/components/CheckoutAddressSelector.tsx index 6a4129e..4d5c0a2 100644 --- a/apps/user-ui/components/CheckoutAddressSelector.tsx +++ b/apps/user-ui/components/CheckoutAddressSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, Alert } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView, Alert, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { tw, BottomDialog, RawBottomDialog } from 'common-ui'; import { useQueryClient } from '@tanstack/react-query'; import AddressForm from '@/src/components/AddressForm'; @@ -14,6 +14,8 @@ interface AddressSelectorProps { onAddressSelect: (addressId: number) => void; } +const CARD_WIDTH = 300; // 288 (w-72) + 12 (mr-3) + const CheckoutAddressSelector: React.FC = ({ selectedAddress, onAddressSelect, @@ -21,6 +23,7 @@ const CheckoutAddressSelector: React.FC = ({ const [showAddAddress, setShowAddAddress] = useState(false); const [editingLocationAddressId, setEditingLocationAddressId] = useState(null); const [locationLoading, setLocationLoading] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); const queryClient = useQueryClient(); const scrollViewRef = useRef(null); const { isAuthenticated } = useAuth(); @@ -70,9 +73,15 @@ const CheckoutAddressSelector: React.FC = ({ // Reset scroll to left when address is selected const resetScrollToLeft = () => { + setCurrentIndex(0); scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true }); }; + const handleScroll = (event: NativeSyntheticEvent) => { + const index = Math.round(event.nativeEvent.contentOffset.x / CARD_WIDTH); + setCurrentIndex(index); + }; + const handleAttachLocation = async (address: any) => { setEditingLocationAddressId(address.id); setLocationLoading(true); @@ -145,6 +154,11 @@ const CheckoutAddressSelector: React.FC = ({ horizontal showsHorizontalScrollIndicator={false} style={tw`pb-2`} + onScroll={handleScroll} + onMomentumScrollEnd={handleScroll} + scrollEventThrottle={16} + decelerationRate="fast" + snapToInterval={CARD_WIDTH} > {sortedAddresses.map((address) => ( = ({ )} + {/* Pagination Dots */} + {sortedAddresses.length > 1 && ( + + {sortedAddresses.map((_, index: number) => ( + + ))} + + )} + {/* Attach Location for selected address - outside the white box */} {selectedAddress && (() => { const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress); diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index a4db708..1fde5da 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -279,7 +279,9 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver ₹{productDetail.price} - / {productDetail.unitNotation} + {productDetail.productType !== 'combo' && ( + / {productDetail.unitNotation} + )} {/* Show market price discount if available */} {productDetail.marketPrice && ( @@ -296,7 +298,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && ( - 1 Hr Delivery: ₹{productDetail.flashPrice} / {productDetail.unitNotation} + 1 Hr Delivery: ₹{productDetail.flashPrice}{productDetail.productType !== 'combo' ? ` / ${productDetail.unitNotation}` : ''} )} @@ -463,7 +465,7 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver {productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => ( - Buy {deal.quantity} • {productDetail.unitNotation} + Buy {deal.quantity}{productDetail.productType !== 'combo' ? ` • ${productDetail.unitNotation}` : ''} ₹{deal.price} ))} diff --git a/apps/user-ui/components/SlotSpecificView.tsx b/apps/user-ui/components/SlotSpecificView.tsx index 5e796a7..2e1fd4e 100644 --- a/apps/user-ui/components/SlotSpecificView.tsx +++ b/apps/user-ui/components/SlotSpecificView.tsx @@ -317,7 +317,9 @@ const CompactProductCard = ({ {item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( ₹{item.marketPrice} )} - Quantity: {item.unit || item.unitNotation} + {item.productType !== 'combo' && ( + Quantity: {item.unit || item.unitNotation} + )} diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index af73daa..29c1abf 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -460,10 +460,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { {product?.name} - {(() => { - const unit = product?.unitNotation || ''; - return unit; - })()} + {product?.productType !== 'combo' ? (product?.unitNotation || '') : ''} diff --git a/apps/user-ui/components/floating-cart-bar.tsx b/apps/user-ui/components/floating-cart-bar.tsx index 905ba43..f9c6c03 100644 --- a/apps/user-ui/components/floating-cart-bar.tsx +++ b/apps/user-ui/components/floating-cart-bar.tsx @@ -60,12 +60,14 @@ const formatTimeRange = (deliveryTime: string | Date) => { }; // Product name component with quantity -const ProductNameWithQuantity = ({ name, unitNotation }: { name: string; unitNotation: string }) => { +const ProductNameWithQuantity = ({ name, unitNotation, productType }: { name: string; unitNotation: string; productType?: string }) => { const truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name; const unit = unitNotation ? ` ${unitNotation}` : ''; return ( - {truncatedName} ({unit}) + {truncatedName} {productType !== 'combo' && ( + ({unit}) + )} ); }; @@ -272,7 +274,9 @@ const FloatingCartBar: React.FC = ({ style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`} /> - {productsById[item.skuId]?.unitNotation || ''} + {productsById[item.skuId]?.productType !== 'combo' && ( + {productsById[item.skuId]?.unitNotation || ''} + )} @@ -281,6 +285,7 @@ const FloatingCartBar: React.FC = ({ = ({ focused, size, color }) => { + if (focused) { + // Selected state SVG (filled offer tag) + return ( + + + + ); + } else { + // Unselected state SVG (outlined offer tag) + return ( + + + + + ); + } +}; + +export default OffersIcon; diff --git a/apps/user-ui/src/components/AddToCartDialog.tsx b/apps/user-ui/src/components/AddToCartDialog.tsx index b7f2d49..a3acab8 100644 --- a/apps/user-ui/src/components/AddToCartDialog.tsx +++ b/apps/user-ui/src/components/AddToCartDialog.tsx @@ -152,7 +152,7 @@ export default function AddToCartDialog() { Select Delivery Slot {product?.name && ( - {product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''}) + {product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''} )} diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 83d0308..6d75c13 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -279,6 +279,7 @@ export async function createProduct(input: CreateProductInput): Promise s.id)) + const skusBeingSuspended = skus.filter( + (sku: any) => sku.isSuspended && sku.id != null && existingSkuIdSet.has(sku.id) + ) + if (skusBeingSuspended.length > 0) { + const suspendingIds = skusBeingSuspended.map((sku: any) => sku.id) + const comboMemberships = await db.query.productCombos.findMany({ + where: inArray(productCombos.skuId, suspendingIds), + columns: { comboSkuId: true }, + }) + const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId))) + + if (comboIds.length > 0) { + const combos = await db.query.productSkus.findMany({ + where: inArray(productSkus.id, comboIds), + columns: { id: true, isSuspended: true }, + }) + const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id) + if (activeComboIds.length > 0) { + throw new Error( + `Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` + ) + } + } + } + for (const sku of skus) { if (sku.id != null && existingSkuIdSet.has(sku.id)) { // Update existing SKU @@ -368,6 +394,7 @@ export async function updateProduct(id: number, input: any): Promise { const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { product: true, features: true, @@ -200,7 +201,16 @@ export async function getAllProductCombosForCache(): Promise { + const suspendedSkuIds = new Set( + (await db + .select({ id: productSkus.id }) + .from(productSkus) + .where(eq(productSkus.isSuspended, true))).map((r) => r.id) + ) + + return results + .filter((ci) => !suspendedSkuIds.has(ci.comboSkuId)) + .map((ci) => { const features = ci.sku?.features || [] return { comboSkuId: ci.comboSkuId, @@ -307,6 +317,7 @@ export async function getAllSlotsWithProductsForCache(): Promise 0) { skusData = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { product: { with: { store: true }, diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts index 521626d..fc396ab 100644 --- a/packages/db_helper_sqlite/src/user-apis/cart.ts +++ b/packages/db_helper_sqlite/src/user-apis/cart.ts @@ -22,33 +22,35 @@ export async function getCartItemsWithProducts(userId: number): Promise { - const sku = item.sku - const features = sku?.features || [] - const priceValue = sku?.price ?? '0' - const quantityValue = item.quantity ?? '0' - return { - id: item.id, - skuId: item.skuId, - quantity: parseFloat(quantityValue), - addedAt: item.addedAt, - product: { - id: sku?.id ?? 0, - name: composeSkuName(sku?.product?.name ?? 'Unknown', features), - price: String(priceValue), - productQuantity: 1, - unit: composeUnitNotation(features), - isOutOfStock: sku?.isOutOfStock ?? false, - images: getStringArray(sku?.images), - }, - subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), - } - }) + return cartItemsWithProducts + .filter((item) => item.sku && !item.sku.isSuspended) + .map((item) => { + const sku = item.sku + const features = sku?.features || [] + const priceValue = sku?.price ?? '0' + const quantityValue = item.quantity ?? '0' + return { + id: item.id, + skuId: item.skuId, + quantity: parseFloat(quantityValue), + addedAt: item.addedAt, + product: { + id: sku?.id ?? 0, + name: composeSkuName(sku?.product?.name ?? 'Unknown', features), + price: String(priceValue), + productQuantity: 1, + unit: composeUnitNotation(features), + isOutOfStock: sku?.isOutOfStock ?? false, + images: getStringArray(sku?.images), + }, + subtotal: parseFloat(String(priceValue)) * parseFloat(quantityValue), + } + }) } export async function getProductById(skuId: number) { return db.query.productSkus.findFirst({ - where: eq(productSkus.id, skuId), + where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), }) } diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 7dd8935..cd5ae46 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -11,7 +11,7 @@ const getStringArray = (value: unknown): string[] | null => { export async function getProductDetailById(skuId: number): Promise { const sku = await db.query.productSkus.findFirst({ - where: eq(productSkus.id, skuId), + where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), with: { product: true, features: true, @@ -202,6 +202,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), with: { features: true, product: {