enh
This commit is contained in:
parent
27c1515c86
commit
9bfe057c49
22 changed files with 427 additions and 57 deletions
9
.commandcode/settings.json
Normal file
9
.commandcode/settings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "default"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
11
.commandcode/taste/taste/taste.md
Normal file
11
.commandcode/taste/taste/taste.md
Normal 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
|
||||
|
|
@ -25,6 +25,13 @@ export default function AddProduct() {
|
|||
const seenSignatures = new Set<string>()
|
||||
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 }[],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<ProductFormRef, ProductFormProps>(({
|
|||
)}
|
||||
</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>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
|
@ -344,6 +366,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
<MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText>
|
||||
</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 && (
|
||||
<MyTextInput
|
||||
topLabel="Flash Price"
|
||||
|
|
@ -415,6 +448,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
)}
|
||||
</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>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
products: formattedProducts,
|
||||
count: formattedProducts.length,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 {
|
||||
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(({
|
|||
<PopularProductItem item={item} onPress={onProductPress} />
|
||||
), [onProductPress]);
|
||||
|
||||
const renderExploreItem = useCallback(({ item }: { item: any }) => (
|
||||
<ExploreProductItem item={item} onPress={onProductPress} />
|
||||
), [onProductPress]);
|
||||
|
||||
const renderSlotItem = useCallback(({ item }: { item: any }) => (
|
||||
<SlotItem item={item} />
|
||||
), []);
|
||||
|
|
@ -323,6 +404,55 @@ const ListHeader = memo(({
|
|||
/>
|
||||
</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 && (
|
||||
<View style={tw`mt-2 mb-4`}>
|
||||
<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 products = productsData?.products || [];
|
||||
const dashboardTags = productsData?.tags || [];
|
||||
|
||||
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
|
||||
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null;
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -446,6 +580,26 @@ export default function Dashboard() {
|
|||
.filter((product): product is NonNullable<typeof product> => 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`,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Tabs.Screen
|
||||
name="(tabs)/order-again"
|
||||
options={{
|
||||
tabBarLabel: 'Order Again',
|
||||
tabBarLabel: 'Offers',
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<OrderAgainIcon focused={focused} size={size} color={color} />
|
||||
<OffersIcon focused={focused} size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<AddressSelectorProps> = ({
|
||||
selectedAddress,
|
||||
onAddressSelect,
|
||||
|
|
@ -21,6 +23,7 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
|||
const [showAddAddress, setShowAddAddress] = useState(false);
|
||||
const [editingLocationAddressId, setEditingLocationAddressId] = useState<number | null>(null);
|
||||
const [locationLoading, setLocationLoading] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
|
@ -70,9 +73,15 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
|||
|
||||
// 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<NativeScrollEvent>) => {
|
||||
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<AddressSelectorProps> = ({
|
|||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={tw`pb-2`}
|
||||
onScroll={handleScroll}
|
||||
onMomentumScrollEnd={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={CARD_WIDTH}
|
||||
>
|
||||
{sortedAddresses.map((address) => (
|
||||
<TouchableOpacity
|
||||
|
|
@ -187,6 +201,20 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
|||
</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 */}
|
||||
{selectedAddress && (() => {
|
||||
const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress);
|
||||
|
|
|
|||
|
|
@ -279,7 +279,9 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
|||
<MyText style={tw`text-3xl font-bold text-gray-900`}>
|
||||
₹{productDetail.price}
|
||||
</MyText>
|
||||
{productDetail.productType !== 'combo' && (
|
||||
<MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {productDetail.unitNotation}</MyText>
|
||||
)}
|
||||
|
||||
{/* Show market price discount if available */}
|
||||
{productDetail.marketPrice && (
|
||||
|
|
@ -296,7 +298,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
|||
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
|
||||
<View style={tw`mt-1`}>
|
||||
<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>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -463,7 +465,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
|||
|
||||
{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`}>
|
||||
<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>
|
||||
</View>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -317,7 +317,9 @@ const CompactProductCard = ({
|
|||
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -460,10 +460,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
|
|||
{product?.name}
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs text-gray-500 mr-2`}>
|
||||
{(() => {
|
||||
const unit = product?.unitNotation || '';
|
||||
return unit;
|
||||
})()}
|
||||
{product?.productType !== 'combo' ? (product?.unitNotation || '') : ''}
|
||||
</MyText>
|
||||
|
||||
<View style={tw`flex-row items-center w-32 justify-end`}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -272,7 +274,9 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
|
|||
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`}>
|
||||
{productsById[item.skuId]?.productType !== 'combo' && (
|
||||
<MyText style={tw`text-[#f81260] font-semibold`}>{productsById[item.skuId]?.unitNotation || ''}</MyText>
|
||||
)}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
|
|
@ -281,6 +285,7 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
|
|||
<ProductNameWithQuantity
|
||||
name={productsById[item.skuId]?.name || ''}
|
||||
unitNotation={productsById[item.skuId]?.unitNotation || ''}
|
||||
productType={productsById[item.skuId]?.productType}
|
||||
/>
|
||||
<MiniQuantifier
|
||||
value={quantities[item.id] || item.quantity}
|
||||
|
|
|
|||
29
apps/user-ui/components/icons/OffersIcon.tsx
Normal file
29
apps/user-ui/components/icons/OffersIcon.tsx
Normal 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;
|
||||
|
|
@ -152,7 +152,7 @@ export default function AddToCartDialog() {
|
|||
<MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText>
|
||||
{product?.name && (
|
||||
<MyText style={tw`text-sm text-gray-500`}>
|
||||
{product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''})
|
||||
{product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
|
|||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||
isOffer: sku.isOffer ?? false,
|
||||
isComboOnly: sku.isComboOnly ?? false,
|
||||
isSuspended: sku.isSuspended ?? false,
|
||||
}))
|
||||
).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 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<AdminProduc
|
|||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||
isOffer: sku.isOffer ?? false,
|
||||
isComboOnly: sku.isComboOnly ?? false,
|
||||
isSuspended: sku.isSuspended ?? false,
|
||||
})
|
||||
.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,
|
||||
isOffer: sku.isOffer ?? false,
|
||||
isComboOnly: sku.isComboOnly ?? false,
|
||||
isSuspended: sku.isSuspended ?? false,
|
||||
}).returning()
|
||||
|
||||
await db.insert(skuFeatures).values(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
productTagInfo,
|
||||
userIncidents,
|
||||
} 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'
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -107,6 +107,7 @@ export interface ProductTagData {
|
|||
|
||||
export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
||||
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<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 || []
|
||||
return {
|
||||
comboSkuId: ci.comboSkuId,
|
||||
|
|
@ -307,6 +317,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
|||
let skusData: any[] = []
|
||||
if (skuIdsArray.length > 0) {
|
||||
skusData = await db.query.productSkus.findMany({
|
||||
where: eq(productSkus.isSuspended, false),
|
||||
with: {
|
||||
product: {
|
||||
with: { store: true },
|
||||
|
|
|
|||
|
|
@ -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 features = sku?.features || []
|
||||
const priceValue = sku?.price ?? '0'
|
||||
|
|
@ -48,7 +50,7 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
|
|||
|
||||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const getStringArray = (value: unknown): string[] | null => {
|
|||
|
||||
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
|
||||
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<ProductSu
|
|||
}
|
||||
|
||||
const skus = await db.query.productSkus.findMany({
|
||||
where: eq(productSkus.isSuspended, false),
|
||||
with: {
|
||||
product: true,
|
||||
features: true,
|
||||
|
|
@ -278,6 +279,7 @@ export interface SkuSummary {
|
|||
|
||||
export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
||||
const skus = await db.query.productSkus.findMany({
|
||||
where: eq(productSkus.isSuspended, false),
|
||||
with: {
|
||||
features: true,
|
||||
product: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue