This commit is contained in:
shafi54 2026-08-03 22:03:55 +05:30
parent 27c1515c86
commit 9bfe057c49
22 changed files with 427 additions and 57 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)"
],
"deny": [],
"defaultMode": "default"
}
}

View file

@ -1,9 +1,2 @@
# Taste # Taste
See [taste/taste.md](taste/taste.md)
- 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

View file

@ -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

View file

@ -25,6 +25,13 @@ export default function AddProduct() {
const seenSignatures = new Set<string>() const seenSignatures = new Set<string>()
for (const variant of values.variants) { for (const variant of values.variants) {
const attributes = variant.attributes || [] 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 const signature = attributes
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort() .sort()
@ -68,6 +75,7 @@ export default function AddProduct() {
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
isOffer: variant.isOffer || false, isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false, isComboOnly: variant.isComboOnly || false,
isSuspended: variant.isSuspended || false,
features: variant.attributes.map((attr: any) => ({ features: variant.attributes.map((attr: any) => ({
featureName: attr.featureName, featureName: attr.featureName,
featureValue: attr.featureValue, featureValue: attr.featureValue,
@ -111,7 +119,8 @@ export default function AddProduct() {
flashPrice: '', flashPrice: '',
isOffer: false, isOffer: false,
isComboOnly: false, isComboOnly: false,
attributes: [{ featureName: 'quantity', featureValue: '' }], isSuspended: false,
attributes: [{ featureName: '', featureValue: '' }],
comboItems: [] as { skuId: number | string }[], comboItems: [] as { skuId: number | string }[],
}, },
], ],

View file

@ -51,6 +51,7 @@ export default function EditProduct() {
flashPrice: sku.flashPrice || '', flashPrice: sku.flashPrice || '',
isOffer: sku.isOffer || false, isOffer: sku.isOffer || false,
isComboOnly: sku.isComboOnly || false, isComboOnly: sku.isComboOnly || false,
isSuspended: sku.isSuspended || false,
attributes: (sku.features || []).map((f) => ({ attributes: (sku.features || []).map((f) => ({
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
@ -89,6 +90,13 @@ export default function EditProduct() {
const seenSignatures = new Set<string>() const seenSignatures = new Set<string>()
for (const variant of values.variants) { for (const variant of values.variants) {
const attributes = variant.attributes || [] 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 const signature = attributes
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`) .map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort() .sort()
@ -144,6 +152,7 @@ export default function EditProduct() {
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
isOffer: variant.isOffer || false, isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false, isComboOnly: variant.isComboOnly || false,
isSuspended: variant.isSuspended || false,
features: variant.attributes.map((attr: any) => ({ features: variant.attributes.map((attr: any) => ({
featureName: attr.featureName, featureName: attr.featureName,
featureValue: attr.featureValue, featureValue: attr.featureValue,

View file

@ -20,6 +20,7 @@ interface Variant {
flashPrice: string flashPrice: string
isOffer: boolean isOffer: boolean
isComboOnly: boolean isComboOnly: boolean
isSuspended: boolean
attributes: Attribute[] attributes: Attribute[]
comboItems: { skuId: number | string }[] comboItems: { skuId: number | string }[]
} }
@ -46,7 +47,7 @@ interface ProductFormProps {
existingVariantImageKeys?: string[][] existingVariantImageKeys?: string[][]
} }
const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
const defaultVariant = (): Variant => ({ const defaultVariant = (): Variant => ({
id: undefined, id: undefined,
@ -57,6 +58,7 @@ const defaultVariant = (): Variant => ({
flashPrice: '', flashPrice: '',
isOffer: false, isOffer: false,
isComboOnly: false, isComboOnly: false,
isSuspended: false,
attributes: [defaultAttribute()], attributes: [defaultAttribute()],
comboItems: [], comboItems: [],
}) })
@ -112,6 +114,19 @@ const productValidationSchema = Yup.object().shape({
seen.add(signature) seen.add(signature)
} }
return true 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<ProductFormRef, ProductFormProps>(({
)} )}
</View> </View>
))} ))}
<TouchableOpacity
onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
</TouchableOpacity>
</View> </View>
)} )}
</FieldArray> </FieldArray>
@ -344,6 +366,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText> <MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText>
</View> </View>
{mode === 'edit' && (
<View style={tw`flex-row items-center mb-3`}>
<Checkbox
checked={variant.isSuspended}
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
style={tw`mr-3`}
/>
<MyText style={tw`text-gray-700 font-medium`}>Suspend SKU</MyText>
</View>
)}
{variant.isFlashAvailable && ( {variant.isFlashAvailable && (
<MyTextInput <MyTextInput
topLabel="Flash Price" topLabel="Flash Price"
@ -415,6 +448,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
)} )}
</View> </View>
))} ))}
<TouchableOpacity
onPress={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`}
>
<MaterialIcons name="add" size={18} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Add Variant</MyText>
</TouchableOpacity>
</View> </View>
)} )}
</FieldArray> </FieldArray>

View file

@ -198,6 +198,7 @@ export const productRouter = router({
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOffer: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false),
isComboOnly: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false),
isSuspended: z.boolean().optional().default(false),
features: z.array(z.object({ features: z.array(z.object({
featureName: z.string().nullable().optional(), featureName: z.string().nullable().optional(),
featureValue: z.string().min(1, 'Value is required'), featureValue: z.string().min(1, 'Value is required'),
@ -226,6 +227,7 @@ export const productRouter = router({
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended,
features: sku.features.map((f) => ({ features: sku.features.map((f) => ({
featureName: f.featureName?.trim() || null, featureName: f.featureName?.trim() || null,
featureValue: f.featureValue, featureValue: f.featureValue,
@ -284,6 +286,7 @@ export const productRouter = router({
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOffer: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false),
isComboOnly: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false),
isSuspended: z.boolean().optional().default(false),
features: z.array(z.object({ features: z.array(z.object({
featureName: z.string().nullable().optional(), featureName: z.string().nullable().optional(),
featureValue: z.string().min(1, 'Value is required'), featureValue: z.string().min(1, 'Value is required'),
@ -314,6 +317,7 @@ export const productRouter = router({
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended,
features: sku.features.map((f) => ({ features: sku.features.map((f) => ({
featureName: f.featureName?.trim() || null, featureName: f.featureName?.trim() || null,
featureValue: f.featureValue, featureValue: f.featureValue,

View file

@ -4,8 +4,10 @@ import {
getNextDeliveryDateWithCapacity, getNextDeliveryDateWithCapacity,
getStoresSummary, getStoresSummary,
getAllSkusSummary as getAllSkusSummaryInDb, getAllSkusSummary as getAllSkusSummaryInDb,
getAllTagsForCache,
getAllTagProductMappings,
} from '@/src/dbService' } 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 { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-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<number, number[]>()
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 { return {
products: formattedProducts, products: formattedProducts,
count: formattedProducts.length, count: formattedProducts.length,
tags,
}; };
} }

View file

@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from "react"; 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 { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { import {
@ -58,6 +58,20 @@ const staticStyles = {
slotsListContent: { paddingBottom: 24 }, 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 { interface RenderStoreProps {
item: any; 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 (
<MyTouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={[
tw`px-4 py-2.5 rounded-full border`,
isSelected
? { backgroundColor: color.bg, borderColor: color.text }
: { backgroundColor: '#FFFFFF', borderColor: color.border },
]}
>
<MyText
style={[
tw`text-sm font-semibold`,
{ color: color.text },
]}
>
{tag.tagName} ({productCount})
</MyText>
</MyTouchableOpacity>
);
});
interface ExploreProductItemProps {
item: any;
onPress: (id: number) => void;
}
const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => {
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]);
return (
<View style={tw`mr-4`}>
<ProductCard
item={item}
itemWidth={itemWidth}
onPress={handlePress}
showDeliveryInfo={false}
useAddToCartDialog={true}
miniView={true}
/>
</View>
);
});
interface SlotItemProps { interface SlotItemProps {
item: any; item: any;
} }
@ -229,6 +298,10 @@ interface ListHeaderProps {
popularProducts: any[]; popularProducts: any[];
sortedSlots: any[]; sortedSlots: any[];
onProductPress: (id: number) => void; onProductPress: (id: number) => void;
dashboardTags: any[];
activeTagId: number | null;
activeTagProducts: any[];
onSelectTag: (id: number) => void;
} }
const ListHeader = memo(({ const ListHeader = memo(({
@ -238,6 +311,10 @@ const ListHeader = memo(({
popularProducts, popularProducts,
sortedSlots, sortedSlots,
onProductPress, onProductPress,
dashboardTags,
activeTagId,
activeTagProducts,
onSelectTag,
}: ListHeaderProps) => { }: ListHeaderProps) => {
const handleLayout = useCallback((event: any) => { const handleLayout = useCallback((event: any) => {
const { y, height } = event.nativeEvent.layout; const { y, height } = event.nativeEvent.layout;
@ -248,6 +325,10 @@ const ListHeader = memo(({
<PopularProductItem item={item} onPress={onProductPress} /> <PopularProductItem item={item} onPress={onProductPress} />
), [onProductPress]); ), [onProductPress]);
const renderExploreItem = useCallback(({ item }: { item: any }) => (
<ExploreProductItem item={item} onPress={onProductPress} />
), [onProductPress]);
const renderSlotItem = useCallback(({ item }: { item: any }) => ( const renderSlotItem = useCallback(({ item }: { item: any }) => (
<SlotItem item={item} /> <SlotItem item={item} />
), []); ), []);
@ -323,6 +404,55 @@ const ListHeader = memo(({
/> />
</View> </View>
{dashboardTags.length > 0 && (
<View style={tw`mb-4`}>
<View style={tw`px-1 mb-2`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Explore Products</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse by category</MyText>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw`gap-2 py-2`}
>
{dashboardTags.map((tag) => (
<ExploreTab
key={tag.id}
tag={tag}
isSelected={activeTagId === tag.id}
onPress={() => onSelectTag(tag.id)}
/>
))}
</ScrollView>
{activeTagProducts.length > 0 ? (
<View style={tw`mt-3 relative`}>
<MyFlatList
data={activeTagProducts}
keyExtractor={(item) => item.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={staticStyles.popularListContent}
renderItem={renderExploreItem}
removeClippedSubviews={true}
/>
<LinearGradient
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`}
pointerEvents="none"
/>
</View>
) : (
<View style={tw`py-6 items-center`}>
<MyText style={tw`text-sm text-gray-500 font-medium`}>
No products in this category yet
</MyText>
</View>
)}
</View>
)}
{sortedSlots.length > 0 && ( {sortedSlots.length > 0 && (
<View style={tw`mt-2 mb-4`}> <View style={tw`mt-2 mb-4`}>
<View style={tw`flex-row items-center justify-between px-1 mb-6`}> <View style={tw`flex-row items-center justify-between px-1 mb-6`}>
@ -386,6 +516,10 @@ export default function Dashboard() {
const { data: slotsData } = useSlots(); const { data: slotsData } = useSlots();
const products = productsData?.products || []; const products = productsData?.products || [];
const dashboardTags = productsData?.tags || [];
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null;
React.useEffect(() => { React.useEffect(() => {
@ -446,6 +580,26 @@ export default function Dashboard() {
.filter((product): product is NonNullable<typeof product> => product != null); .filter((product): product is NonNullable<typeof product> => product != null);
}, [popularItemIds, products]); }, [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 () => { const handleRefresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
try { try {
@ -499,8 +653,12 @@ export default function Dashboard() {
popularProducts={popularProducts} popularProducts={popularProducts}
sortedSlots={sortedSlots} sortedSlots={sortedSlots}
onProductPress={handleProductPress} 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(() => [ const searchBarContainerStyle = useMemo(() => [
tw`w-full px-4 pt-4 pb-2`, tw`w-full px-4 pt-4 pb-2`,

View file

@ -19,7 +19,7 @@ import TabLayoutWrapper from "@/components/TabLayoutWrapper";
const { width: screenWidth } = Dimensions.get("window"); const { width: screenWidth } = Dimensions.get("window");
const itemWidth = screenWidth * 0.45; const itemWidth = screenWidth * 0.45;
const rowListContent = { paddingBottom: 16 }; const rowListContent = { paddingBottom: 16, paddingHorizontal: 16 };
interface OffersRowProps { interface OffersRowProps {
title: string; title: string;
@ -75,7 +75,7 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
colors={["transparent", "rgba(0,0,0,0.08)"]} colors={["transparent", "rgba(0,0,0,0.08)"]}
start={{ x: 0, y: 0.5 }} start={{ x: 0, y: 0.5 }}
end={{ x: 1, 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" pointerEvents="none"
/> />
</View> </View>

View file

@ -11,7 +11,7 @@ import { useAuth } from "@/src/contexts/AuthContext";
import { tw, theme, MyTouchableOpacity, MyText } from "common-ui"; import { tw, theme, MyTouchableOpacity, MyText } from "common-ui";
import HomeIcon from "@/components/icons/HomeIcon"; import HomeIcon from "@/components/icons/HomeIcon";
import StoresIcon from "@/components/icons/StoresIcon"; 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 MeIcon from "@/components/icons/MeIcon";
import { useAppStore } from "@/src/store/appStore"; import { useAppStore } from "@/src/store/appStore";
@ -97,9 +97,9 @@ export default function Layout() {
<Tabs.Screen <Tabs.Screen
name="(tabs)/order-again" name="(tabs)/order-again"
options={{ options={{
tabBarLabel: 'Order Again', tabBarLabel: 'Offers',
tabBarIcon: ({ color, size, focused }) => ( tabBarIcon: ({ color, size, focused }) => (
<OrderAgainIcon focused={focused} size={size} color={color} /> <OffersIcon focused={focused} size={size} color={color} />
), ),
}} }}
/> />

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; 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 { tw, BottomDialog, RawBottomDialog } from 'common-ui';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import AddressForm from '@/src/components/AddressForm'; import AddressForm from '@/src/components/AddressForm';
@ -14,6 +14,8 @@ interface AddressSelectorProps {
onAddressSelect: (addressId: number) => void; onAddressSelect: (addressId: number) => void;
} }
const CARD_WIDTH = 300; // 288 (w-72) + 12 (mr-3)
const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
selectedAddress, selectedAddress,
onAddressSelect, onAddressSelect,
@ -21,6 +23,7 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
const [showAddAddress, setShowAddAddress] = useState(false); const [showAddAddress, setShowAddAddress] = useState(false);
const [editingLocationAddressId, setEditingLocationAddressId] = useState<number | null>(null); const [editingLocationAddressId, setEditingLocationAddressId] = useState<number | null>(null);
const [locationLoading, setLocationLoading] = useState(false); const [locationLoading, setLocationLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
@ -70,9 +73,15 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
// Reset scroll to left when address is selected // Reset scroll to left when address is selected
const resetScrollToLeft = () => { const resetScrollToLeft = () => {
setCurrentIndex(0);
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true }); scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true });
}; };
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(event.nativeEvent.contentOffset.x / CARD_WIDTH);
setCurrentIndex(index);
};
const handleAttachLocation = async (address: any) => { const handleAttachLocation = async (address: any) => {
setEditingLocationAddressId(address.id); setEditingLocationAddressId(address.id);
setLocationLoading(true); setLocationLoading(true);
@ -145,6 +154,11 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={tw`pb-2`} style={tw`pb-2`}
onScroll={handleScroll}
onMomentumScrollEnd={handleScroll}
scrollEventThrottle={16}
decelerationRate="fast"
snapToInterval={CARD_WIDTH}
> >
{sortedAddresses.map((address) => ( {sortedAddresses.map((address) => (
<TouchableOpacity <TouchableOpacity
@ -187,6 +201,20 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
</ScrollView> </ScrollView>
)} )}
{/* Pagination Dots */}
{sortedAddresses.length > 1 && (
<View style={tw`flex-row justify-center mt-3`}>
{sortedAddresses.map((_, index: number) => (
<View
key={index}
style={tw`w-2 h-2 rounded-full mx-1 ${
index === currentIndex ? 'bg-brand500' : 'bg-gray-300'
}`}
/>
))}
</View>
)}
{/* Attach Location for selected address - outside the white box */} {/* Attach Location for selected address - outside the white box */}
{selectedAddress && (() => { {selectedAddress && (() => {
const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress); const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress);

View file

@ -279,7 +279,9 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
<MyText style={tw`text-3xl font-bold text-gray-900`}> <MyText style={tw`text-3xl font-bold text-gray-900`}>
{productDetail.price} {productDetail.price}
</MyText> </MyText>
{productDetail.productType !== 'combo' && (
<MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {productDetail.unitNotation}</MyText> <MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {productDetail.unitNotation}</MyText>
)}
{/* Show market price discount if available */} {/* Show market price discount if available */}
{productDetail.marketPrice && ( {productDetail.marketPrice && (
@ -296,7 +298,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && ( {productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
<View style={tw`mt-1`}> <View style={tw`mt-1`}>
<MyText style={tw`text-pink-600 text-lg font-bold`}> <MyText style={tw`text-pink-600 text-lg font-bold`}>
1 Hr Delivery: {productDetail.flashPrice} / {productDetail.unitNotation} 1 Hr Delivery: {productDetail.flashPrice}{productDetail.productType !== 'combo' ? ` / ${productDetail.unitNotation}` : ''}
</MyText> </MyText>
</View> </View>
)} )}
@ -463,7 +465,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
{productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => ( {productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => (
<View key={index} style={tw`flex-row justify-between items-center p-3 bg-amber-50 rounded-xl border border-amber-100 mb-2`}> <View key={index} style={tw`flex-row justify-between items-center p-3 bg-amber-50 rounded-xl border border-amber-100 mb-2`}>
<MyText style={tw`text-amber-900 font-medium`}>Buy {deal.quantity} {productDetail.unitNotation}</MyText> <MyText style={tw`text-amber-900 font-medium`}>Buy {deal.quantity}{productDetail.productType !== 'combo' ? `${productDetail.unitNotation}` : ''}</MyText>
<MyText style={tw`text-amber-900 font-bold text-lg`}>{deal.price}</MyText> <MyText style={tw`text-amber-900 font-bold text-lg`}>{deal.price}</MyText>
</View> </View>
))} ))}

View file

@ -317,7 +317,9 @@ const CompactProductCard = ({
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && ( {item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
<MyText style={tw`text-gray-400 text-xs ml-1 line-through`}>{item.marketPrice}</MyText> <MyText style={tw`text-gray-400 text-xs ml-1 line-through`}>{item.marketPrice}</MyText>
)} )}
{item.productType !== 'combo' && (
<MyText style={tw`text-gray-600 text-xs ml-1`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unit || item.unitNotation}</MyText></MyText> <MyText style={tw`text-gray-600 text-xs ml-1`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unit || item.unitNotation}</MyText></MyText>
)}
</View> </View>
</View> </View>
</View> </View>

View file

@ -460,10 +460,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
{product?.name} {product?.name}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500 mr-2`}> <MyText style={tw`text-xs text-gray-500 mr-2`}>
{(() => { {product?.productType !== 'combo' ? (product?.unitNotation || '') : ''}
const unit = product?.unitNotation || '';
return unit;
})()}
</MyText> </MyText>
<View style={tw`flex-row items-center w-32 justify-end`}> <View style={tw`flex-row items-center w-32 justify-end`}>

View file

@ -60,12 +60,14 @@ const formatTimeRange = (deliveryTime: string | Date) => {
}; };
// Product name component with quantity // 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 truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name;
const unit = unitNotation ? ` ${unitNotation}` : ''; const unit = unitNotation ? ` ${unitNotation}` : '';
return ( return (
<MyText style={tw`text-slate-900 font-extrabold text-sm flex-1`} numberOfLines={1}> <MyText style={tw`text-slate-900 font-extrabold text-sm flex-1`} numberOfLines={1}>
{truncatedName} <MyText style={tw`text-slate-500 font-medium text-xs`}>({unit})</MyText> {truncatedName} {productType !== 'combo' && (
<MyText style={tw`text-slate-500 font-medium text-xs`}>({unit})</MyText>
)}
</MyText> </MyText>
); );
}; };
@ -272,7 +274,9 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`} style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`}
/> />
<MyText style={tw`text-gray-500 text-[9px] font-medium mt-1`}> <MyText style={tw`text-gray-500 text-[9px] font-medium mt-1`}>
{productsById[item.skuId]?.productType !== 'combo' && (
<MyText style={tw`text-[#f81260] font-semibold`}>{productsById[item.skuId]?.unitNotation || ''}</MyText> <MyText style={tw`text-[#f81260] font-semibold`}>{productsById[item.skuId]?.unitNotation || ''}</MyText>
)}
</MyText> </MyText>
</View> </View>
@ -281,6 +285,7 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
<ProductNameWithQuantity <ProductNameWithQuantity
name={productsById[item.skuId]?.name || ''} name={productsById[item.skuId]?.name || ''}
unitNotation={productsById[item.skuId]?.unitNotation || ''} unitNotation={productsById[item.skuId]?.unitNotation || ''}
productType={productsById[item.skuId]?.productType}
/> />
<MiniQuantifier <MiniQuantifier
value={quantities[item.id] || item.quantity} value={quantities[item.id] || item.quantity}

View file

@ -0,0 +1,29 @@
import React from 'react';
import Svg, { Path } from 'react-native-svg';
interface OffersIconProps {
focused: boolean;
size: number;
color: string;
}
const OffersIcon: React.FC<OffersIconProps> = ({ focused, size, color }) => {
if (focused) {
// Selected state SVG (filled offer tag)
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path fillRule="evenodd" clipRule="evenodd" d="M13.41 2.59C13.05 2.22 12.55 2 12 2H4C2.9 2 2 2.9 2 4v8c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42l-9-9zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z" fill={color} />
</Svg>
);
} else {
// Unselected state SVG (outlined offer tag)
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42z" fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M5.5 5.5m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0-3.6 0" fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
}
};
export default OffersIcon;

View file

@ -152,7 +152,7 @@ export default function AddToCartDialog() {
<MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText> <MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText>
{product?.name && ( {product?.name && (
<MyText style={tw`text-sm text-gray-500`}> <MyText style={tw`text-sm text-gray-500`}>
{product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''}) {product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''}
</MyText> </MyText>
)} )}
</View> </View>

View file

@ -279,6 +279,7 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
})) }))
).returning() ).returning()
@ -355,6 +356,31 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
}) })
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id)) const existingSkuIdSet = new Set(existingSkus.map((s) => 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) { for (const sku of skus) {
if (sku.id != null && existingSkuIdSet.has(sku.id)) { if (sku.id != null && existingSkuIdSet.has(sku.id)) {
// Update existing SKU // Update existing SKU
@ -368,6 +394,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
}) })
.where(eq(productSkus.id, sku.id)) .where(eq(productSkus.id, sku.id))
@ -401,6 +428,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
}).returning() }).returning()
await db.insert(skuFeatures).values( await db.insert(skuFeatures).values(

View file

@ -14,7 +14,7 @@ import {
productTagInfo, productTagInfo,
userIncidents, userIncidents,
} from '../db/schema' } from '../db/schema'
import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm' import { eq, and, gt, sql, isNotNull, asc } from 'drizzle-orm'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features' import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
// ============================================================================ // ============================================================================
@ -107,6 +107,7 @@ export interface ProductTagData {
export async function getAllProductsForCache(): Promise<ProductBasicData[]> { export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: true, product: true,
features: true, features: true,
@ -200,7 +201,16 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
}, },
}) })
return results.map((ci) => { 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 || [] const features = ci.sku?.features || []
return { return {
comboSkuId: ci.comboSkuId, comboSkuId: ci.comboSkuId,
@ -307,6 +317,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
let skusData: any[] = [] let skusData: any[] = []
if (skuIdsArray.length > 0) { if (skuIdsArray.length > 0) {
skusData = await db.query.productSkus.findMany({ skusData = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: { product: {
with: { store: true }, with: { store: true },

View file

@ -22,7 +22,9 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
}, },
}) })
return cartItemsWithProducts.map((item) => { return cartItemsWithProducts
.filter((item) => item.sku && !item.sku.isSuspended)
.map((item) => {
const sku = item.sku const sku = item.sku
const features = sku?.features || [] const features = sku?.features || []
const priceValue = sku?.price ?? '0' const priceValue = sku?.price ?? '0'
@ -48,7 +50,7 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
export async function getProductById(skuId: number) { export async function getProductById(skuId: number) {
return db.query.productSkus.findFirst({ return db.query.productSkus.findFirst({
where: eq(productSkus.id, skuId), where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)),
}) })
} }

View file

@ -11,7 +11,7 @@ const getStringArray = (value: unknown): string[] | null => {
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> { export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
const sku = await db.query.productSkus.findFirst({ const sku = await db.query.productSkus.findFirst({
where: eq(productSkus.id, skuId), where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)),
with: { with: {
product: true, product: true,
features: true, features: true,
@ -202,6 +202,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
} }
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: true, product: true,
features: true, features: true,
@ -278,6 +279,7 @@ export interface SkuSummary {
export async function getAllSkusSummary(): Promise<SkuSummary[]> { export async function getAllSkusSummary(): Promise<SkuSummary[]> {
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
features: true, features: true,
product: { product: {