Compare commits

..

No commits in common. "6a0bc18a8d4671d22f9a75f8e4fd4e1606832f52" and "b41980736a51dc2463ea0e6637ee434fa98f04ba" have entirely different histories.

43 changed files with 442 additions and 1678 deletions

View file

@ -1,10 +1,7 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)", "Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)"
"Shell(npx tsc --noEmit 2 >& 1)",
"Shell(grep:*)",
"Shell(npx tsc --noEmit -p packages/shared/tsconfig.json 2 >& 1)"
], ],
"deny": [], "deny": [],
"defaultMode": "default" "defaultMode": "default"

View file

@ -463,7 +463,7 @@ export default function OrderDetails() {
{item.name} {item.name}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500`}> <MyText style={tw`text-xs text-gray-500`}>
{Number(item.quantity)} x {item.productSize}{item.unit} × {item.price} {Number(item.quantity) * item.productSize} {item.unit} × {item.price}
</MyText> </MyText>
<View style={tw`flex-row items-center mt-2 gap-3`}> <View style={tw`flex-row items-center mt-2 gap-3`}>
<TouchableOpacity <TouchableOpacity

View file

@ -413,7 +413,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}> <View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}> <View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity} x {item.productSize}{item.unit}</MyText> <MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity * item.productSize } {item.unit}</MyText>
</View> </View>
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail"> <MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}

View file

@ -402,6 +402,15 @@ export default function PricesOverview() {
/> />
</View> </View>
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium mb-1`}>Size</MyText>
<TextInput
style={tw`border border-gray-300 rounded-md px-3 py-2`}
keyboardType="decimal-pad"
placeholder="Enter size"
/>
</View>
<TouchableOpacity <TouchableOpacity
style={tw`bg-blue-600 py-3 rounded-md items-center`} style={tw`bg-blue-600 py-3 rounded-md items-center`}
onPress={saveEditDialog} onPress={saveEditDialog}

View file

@ -6,7 +6,6 @@ export default function Layout() {
<Stack.Screen name="index" options={{ title: "Product Tags" }} /> <Stack.Screen name="index" options={{ title: "Product Tags" }} />
<Stack.Screen name="add" options={{ title: "Add Tag" }} /> <Stack.Screen name="add" options={{ title: "Add Tag" }} />
<Stack.Screen name="edit/index" options={{ title: "Edit Tag" }} /> <Stack.Screen name="edit/index" options={{ title: "Edit Tag" }} />
<Stack.Screen name="order" options={{ title: "Tag Orders" }} />
</Stack> </Stack>
); );
} }

View file

@ -11,7 +11,6 @@ interface TagFormData {
tagDescription: string; tagDescription: string;
isDashboardTag: boolean; isDashboardTag: boolean;
relatedStores: number[]; relatedStores: number[];
productIds: number[];
} }
export default function AddTag() { export default function AddTag() {
@ -46,7 +45,6 @@ export default function AddTag() {
imageUrl, imageUrl,
isDashboardTag: values.isDashboardTag, isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores, relatedStores: values.relatedStores,
productIds: values.productIds,
uploadUrls, uploadUrls,
}) })
@ -68,7 +66,6 @@ export default function AddTag() {
tagDescription: '', tagDescription: '',
isDashboardTag: false, isDashboardTag: false,
relatedStores: [], relatedStores: [],
productIds: [],
}; };
return ( return (

View file

@ -11,7 +11,6 @@ interface TagFormData {
tagDescription: string; tagDescription: string;
isDashboardTag: boolean; isDashboardTag: boolean;
relatedStores: number[]; relatedStores: number[];
productIds: number[];
existingImageUrl?: string; existingImageUrl?: string;
} }
@ -59,7 +58,6 @@ export default function EditTag() {
imageUrl, imageUrl,
isDashboardTag: values.isDashboardTag, isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores, relatedStores: values.relatedStores,
productIds: values.productIds,
uploadUrls, uploadUrls,
}) })
@ -97,16 +95,11 @@ export default function EditTag() {
} }
const tag = tagData.tag; const tag = tagData.tag;
const tagProductIds = tag.productIds || (tag.products || []).map((p: any) => p.productId);
// Order by the saved sortOrder (fall back to the join order if empty).
const orderedProductIds = (tag.sortOrder || []).filter((id: number) => tagProductIds.includes(id));
const remainingProductIds = tagProductIds.filter((id: number) => !orderedProductIds.includes(id));
const initialValues: TagFormData = { const initialValues: TagFormData = {
tagName: tag.tagName, tagName: tag.tagName,
tagDescription: tag.tagDescription || '', tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [], relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
productIds: [...orderedProductIds, ...remainingProductIds],
existingImageUrl: tag.imageUrl || undefined, existingImageUrl: tag.imageUrl || undefined,
}; };

View file

@ -53,18 +53,11 @@ const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
interface TagHeaderProps { interface TagHeaderProps {
onAddNewTag: () => void; onAddNewTag: () => void;
onOrderTags: () => void;
} }
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag, onOrderTags }) => ( const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
<View style={tw`flex-row justify-between items-center p-4 bg-white border-b border-gray-200`}> <View style={tw`flex-row justify-between items-center p-4 bg-white border-b border-gray-200`}>
<TouchableOpacity <MyText style={tw`text-xl font-bold text-gray-800`}>Product Tags</MyText>
onPress={onOrderTags}
style={tw`bg-gray-100 px-3 py-2 rounded-lg flex-row items-center`}
>
<MaterialIcons name="sort" size={18} color="#374151" />
<MyText style={tw`text-gray-800 font-medium ml-1.5`}>Tag Orders</MyText>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={onAddNewTag} onPress={onAddNewTag}
style={tw`bg-blue-500 px-4 py-2 rounded-lg flex-row items-center`} style={tw`bg-blue-500 px-4 py-2 rounded-lg flex-row items-center`}
@ -98,10 +91,6 @@ export default function ProductTags() {
router.push('/product-tags/add'); router.push('/product-tags/add');
}; };
const handleOrderTags = () => {
router.push('/product-tags/order');
};
if (isLoading) { if (isLoading) {
@ -138,7 +127,7 @@ export default function ProductTags() {
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} /> <RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
} }
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />} ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} />}
contentContainerStyle={tw`pb-4`} contentContainerStyle={tw`pb-4`}
ListEmptyComponent={ ListEmptyComponent={
<View style={tw`flex-1 justify-center items-center py-12`}> <View style={tw`flex-1 justify-center items-center py-12`}>

View file

@ -1,369 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Alert,
ActivityIndicator,
Dimensions,
StyleSheet,
} from 'react-native';
import { TouchableOpacity } from 'react-native-gesture-handler';
import { Image } from 'expo-image';
import DraggableFlatList, {
ScaleDecorator,
} from 'react-native-draggable-flatlist';
import {
AppContainer,
MyText,
tw,
MyTouchableOpacity,
} from 'common-ui';
import { useRouter } from 'expo-router';
import { trpc } from '../../../src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useQueryClient } from '@tanstack/react-query';
const { width: screenWidth } = Dimensions.get('window');
const itemWidth = screenWidth - 48;
const itemHeight = 80;
interface Tag {
id: number;
tagName: string;
imageUrl: string | null;
}
interface TagItemProps {
item: Tag;
drag: () => void;
isActive: boolean;
}
const TagItem: React.FC<TagItemProps> = ({ item, drag, isActive }) => {
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={drag}
activeOpacity={1}
style={[
styles.item,
isActive && styles.activeItem,
]}
>
{/* Drag Handle */}
<View style={styles.dragHandle}>
<MaterialIcons
name="drag-indicator"
size={24}
color={isActive ? '#3b82f6' : '#9ca3af'}
/>
</View>
{/* Tag Image */}
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
style={styles.image}
contentFit="cover"
/>
) : (
<View style={styles.placeholderImage}>
<MaterialIcons name="label" size={24} color="#9ca3af" />
</View>
)}
{/* Tag Info */}
<View style={styles.info}>
<MyText style={styles.name} numberOfLines={1}>
{item.tagName}
</MyText>
</View>
</TouchableOpacity>
</ScaleDecorator>
);
};
export default function TagOrders() {
const router = useRouter();
const queryClient = useQueryClient();
const [tags, setTags] = useState<Tag[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Get current order from constants
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
const { data: tagsData, isLoading: isLoadingTags, error: tagsError } = trpc.admin.product.getProductTags.useQuery();
const updateConstants = trpc.admin.const.updateConstants.useMutation();
// Initialize tags from the tagsOrder constant
useEffect(() => {
if (tagsData?.tags) {
const tagsOrderConstant = constants?.find(c => c.key === 'tagsOrder');
let orderedIds: number[] = [];
if (tagsOrderConstant) {
const value = tagsOrderConstant.value;
if (Array.isArray(value)) {
orderedIds = value.map((id: any) => parseInt(id));
} else if (typeof value === 'string') {
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter(id => !isNaN(id));
}
}
// Create tag map for quick lookup
const tagMap = new Map(tagsData.tags.map(t => [t.id, t]));
// Sort tags based on order, tags not in order go to end
const sortedTags: Tag[] = [];
// First add tags in the specified order
for (const id of orderedIds) {
const tag = tagMap.get(id);
if (tag) {
sortedTags.push({
id: tag.id,
tagName: tag.tagName,
imageUrl: tag.imageUrl || null,
});
tagMap.delete(id);
}
}
// Then add remaining tags (not in order yet)
for (const tag of tagMap.values()) {
sortedTags.push({
id: tag.id,
tagName: tag.tagName,
imageUrl: tag.imageUrl || null,
});
}
setTags(sortedTags);
}
}, [constants, tagsData]);
const handleDragEnd = useCallback(({ data }: { data: Tag[] }) => {
setTags(data);
setHasChanges(true);
}, []);
const renderItem = useCallback(({ item, drag, isActive }: { item: Tag; drag: () => void; isActive: boolean }) => {
return (
<TagItem
item={item}
drag={drag}
isActive={isActive}
/>
);
}, []);
const handleSave = () => {
const tagIds = tags.map(t => t.id);
updateConstants.mutate(
{
constants: [{
key: 'tagsOrder',
value: tagIds
}]
},
{
onSuccess: () => {
setHasChanges(false);
Alert.alert('Success', 'Tag order updated successfully!');
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] });
},
onError: (error) => {
Alert.alert('Error', 'Failed to update tag order. Please try again.');
console.error('Update tag order error:', error);
}
}
);
};
// Show loading state while data is being fetched
if (isLoadingConstants || isLoadingTags) {
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<TouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</TouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
<View style={tw`w-16`} />
</View>
<View style={tw`flex-1 justify-center items-center p-8`}>
<ActivityIndicator size="large" color="#3b82f6" />
<MyText style={tw`text-gray-500 mt-4 text-center`}>
{isLoadingConstants ? 'Loading order...' : 'Loading tags...'}
</MyText>
</View>
</View>
</AppContainer>
);
}
// Show error state if queries failed
if (constantsError || tagsError) {
return (
<AppContainer>
<View style={tw`flex-1 bg-gray-50`}>
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<TouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</TouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
<View style={tw`w-16`} />
</View>
<View style={tw`flex-1 justify-center items-center p-8`}>
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
<MyText style={tw`text-gray-500 mt-2 text-center`}>
{constantsError ? 'Failed to load order' : 'Failed to load tags'}
</MyText>
<TouchableOpacity
onPress={() => router.back()}
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
>
<MyText style={tw`text-white font-semibold`}>Go Back</MyText>
</TouchableOpacity>
</View>
</View>
</AppContainer>
);
}
return (
<View style={tw`flex-1 bg-gray-50`}>
{/* Header */}
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
<MyTouchableOpacity
onPress={() => router.back()}
style={tw`p-2 -ml-4`}
>
<MaterialIcons name="chevron-left" size={24} color="#374151" />
</MyTouchableOpacity>
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
<MyTouchableOpacity
onPress={handleSave}
disabled={!hasChanges || updateConstants.isPending}
style={tw`px-4 py-2 rounded-lg ${
hasChanges && !updateConstants.isPending
? 'bg-blue-600'
: 'bg-gray-300'
}`}
>
<MyText style={tw`${
hasChanges && !updateConstants.isPending
? 'text-white'
: 'text-gray-500'
} font-semibold`}>
{updateConstants.isPending ? 'Saving...' : 'Save'}
</MyText>
</MyTouchableOpacity>
</View>
{/* Content */}
{tags.length === 0 ? (
<View style={tw`flex-1 justify-center items-center p-8`}>
<MaterialIcons name="label-off" size={64} color="#e5e7eb" />
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
No tags available
</MyText>
</View>
) : (
<View style={tw`flex-1`}>
<View style={tw`bg-blue-50 px-4 py-2 mb-2 mt-2 mx-4 rounded-lg`}>
<MyText style={tw`text-blue-700 text-xs text-center`}>
Long press and drag to reorder {tags.length} items
</MyText>
</View>
<View style={tw`flex-1 px-3`}>
<DraggableFlatList
data={tags}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={true}
contentContainerStyle={{ paddingBottom: 20 }}
containerStyle={tw`flex-1`}
keyboardShouldPersistTaps="handled"
activationDistance={10}
/>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
item: {
width: itemWidth,
height: 60,
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 10,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
marginVertical: 4,
},
activeItem: {
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderColor: '#3b82f6',
transform: [{ scale: 1.02 }],
},
dragHandle: {
marginRight: 8,
padding: 2,
},
image: {
width: 30,
height: 30,
borderRadius: 6,
marginRight: 10,
},
placeholderImage: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: '#f3f4f6',
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
},
info: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
name: {
fontSize: 13,
color: '#111827',
fontWeight: '500',
flex: 1,
marginRight: 4,
},
});

View file

@ -1,14 +1,9 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Formik } from 'formik'; import { Formik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'; import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { TouchableOpacity as GHTouchableOpacity } from 'react-native-gesture-handler';
import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist';
import { Image } from 'expo-image';
import ProductsSelector from '@/components/ProductsSelector';
import { trpc } from '@/src/trpc-client';
interface StoreOption { interface StoreOption {
id: number; id: number;
@ -20,7 +15,6 @@ interface TagFormData {
tagDescription: string; tagDescription: string;
isDashboardTag: boolean; isDashboardTag: boolean;
relatedStores: number[]; relatedStores: number[];
productIds: number[];
} }
interface TagFormProps { interface TagFormProps {
@ -32,13 +26,6 @@ interface TagFormProps {
stores?: StoreOption[]; stores?: StoreOption[];
} }
interface SelectedProduct {
skuId: number;
productId: number;
label: string;
imageUrl?: string | null;
}
const TagForm = forwardRef<any, TagFormProps>(({ const TagForm = forwardRef<any, TagFormProps>(({
mode, mode,
initialValues, initialValues,
@ -50,35 +37,10 @@ const TagForm = forwardRef<any, TagFormProps>(({
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]) const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false) const [removedExisting, setRemovedExisting] = useState(false)
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag)); const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([]);
const existingImageUrl = existingImageUrlRaw || '' const existingImageUrl = existingImageUrlRaw || ''
const stores = storesRaw || [] const stores = storesRaw || []
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery();
const allSkus: SelectedProduct[] = (skusData?.skus || []).map((sku: any) => ({
skuId: sku.id,
productId: sku.productId,
label: sku.label,
imageUrl: sku.images?.[0] || null,
}));
// Build the ordered list from initialValues.productIds (product ids, in sortOrder).
useEffect(() => {
const ordered: SelectedProduct[] = [];
const skuMap = new Map<number, SelectedProduct>();
for (const sku of allSkus) {
skuMap.set(sku.productId, sku);
}
for (const productId of initialValues.productIds || []) {
const sku = skuMap.get(productId);
if (sku) ordered.push(sku);
else ordered.push({ skuId: 0, productId, label: `Product #${productId}`, imageUrl: null });
}
setSelectedProducts(ordered);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues.productIds, skusData]);
// Update checkbox when initial values change // Update checkbox when initial values change
useEffect(() => { useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
@ -90,6 +52,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
setRemovedExisting(false) setRemovedExisting(false)
}, [existingImageUrlRaw, initialValues.isDashboardTag]); }, [existingImageUrlRaw, initialValues.isDashboardTag]);
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
tagName: Yup.string() tagName: Yup.string()
.required('Tag name is required') .required('Tag name is required')
@ -99,74 +62,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
.max(500, 'Description must be less than 500 characters'), .max(500, 'Description must be less than 500 characters'),
}); });
// When a product is picked in the selector, add it to the ordered list (if not already present).
const handleProductSelect = (value: number | number[]) => {
const skuIds = Array.isArray(value) ? value : [value];
setSelectedProducts((prev) => {
const next = [...prev];
for (const skuId of skuIds) {
if (next.some((p) => p.skuId === skuId)) continue;
const sku = allSkus.find((s) => s.skuId === skuId);
if (sku) next.push(sku);
}
return next;
});
};
const handleDragEnd = useCallback(({ data }: { data: SelectedProduct[] }) => {
setSelectedProducts(data);
}, []);
const handleRemoveProduct = (productId: number) => {
setSelectedProducts((prev) => prev.filter((p) => p.productId !== productId));
};
const renderSelectedItem = useCallback(({ item, drag, isActive }: { item: SelectedProduct; drag: () => void; isActive: boolean }) => (
<ScaleDecorator>
<GHTouchableOpacity
onLongPress={drag}
disabled={isActive}
activeOpacity={1}
style={[styles.item, isActive && styles.activeItem]}
>
<View style={styles.dragHandle}>
<MaterialIcons name="drag-indicator" size={24} color={isActive ? '#3b82f6' : '#9ca3af'} />
</View>
{item.imageUrl ? (
<Image source={{ uri: item.imageUrl }} style={styles.image} contentFit="cover" />
) : (
<View style={styles.placeholderImage}>
<MaterialIcons name="image" size={24} color="#9ca3af" />
</View>
)}
<MyText style={styles.name} numberOfLines={1}>
{item.label}
</MyText>
<TouchableOpacity onPress={() => handleRemoveProduct(item.productId)} style={styles.removeButton}>
<MaterialIcons name="close" size={18} color="#9ca3af" />
</TouchableOpacity>
</GHTouchableOpacity>
</ScaleDecorator>
), []);
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((p) => p.productId) }, images, removedExisting)} onSubmit={(values) => onSubmit(values, images, removedExisting)}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus (create mode only — edit keeps its loaded products) // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setImages([]) setImages([])
setRemovedExisting(false) setRemovedExisting(false)
setIsDashboardTagChecked(false); setIsDashboardTagChecked(false);
if (mode === 'create') {
setSelectedProducts([]);
}
resetForm(); resetForm();
}, [resetForm, mode]); }, [resetForm]);
useFocusCallback(clearForm); useFocusCallback(clearForm);
@ -196,6 +106,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'} Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
</MyText> </MyText>
<ImageUploaderNeo <ImageUploaderNeo
images={images} images={images}
onImageAdd={(payload: ImageUploaderNeoPayload[]) => { onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
@ -221,7 +132,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
onPress={() => { onPress={() => {
const newValue = !isDashboardTagChecked; const newValue = !isDashboardTagChecked;
setIsDashboardTagChecked(newValue); setIsDashboardTagChecked(newValue);
setFieldValue('isDashboardTag', newValue); formikSetFieldValue('isDashboardTag', newValue);
}} }}
/> />
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText> <MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
@ -242,53 +153,12 @@ const TagForm = forwardRef<any, TagFormProps>(({
}))} }))}
onValueChange={(selectedValues) => { onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v)); const numericValues = (selectedValues as string[]).map(v => parseInt(v));
setFieldValue('relatedStores', numericValues); formikSetFieldValue('relatedStores', numericValues);
}} }}
multiple={true} multiple={true}
/> />
</View> </View>
{/* Products Section: selector + reorderable list */}
<View style={tw`mb-6`}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>
Products
</MyText>
<ProductsSelector
value={[]}
onChange={handleProductSelect}
multiple
label="Select Products"
placeholder="Search and select products..."
showGroups={false}
/>
<View style={tw`mt-4`}>
{selectedProducts.length > 0 && (
<View style={tw`bg-blue-50 px-4 py-2 mb-2 rounded-lg`}>
<MyText style={tw`text-blue-700 text-xs text-center`}>
Long press and drag to reorder {selectedProducts.length} items
</MyText>
</View>
)}
{selectedProducts.length > 0 ? (
<DraggableFlatList
data={selectedProducts}
renderItem={renderSelectedItem}
keyExtractor={(item) => item.productId.toString()}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }}
keyboardShouldPersistTaps="handled"
activationDistance={10}
/>
) : (
<View style={tw`bg-gray-50 p-6 rounded-xl border border-gray-200 border-dashed items-center justify-center`}>
<MaterialIcons name="inventory" size={32} color="#9CA3AF" />
<MyText style={tw`text-gray-500 mt-2 text-sm`}>No products selected yet</MyText>
</View>
)}
</View>
</View>
<TouchableOpacity <TouchableOpacity
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
disabled={isLoading} disabled={isLoading}
@ -305,62 +175,6 @@ const TagForm = forwardRef<any, TagFormProps>(({
); );
}); });
const styles = StyleSheet.create({
item: {
backgroundColor: 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 10,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
marginVertical: 4,
},
activeItem: {
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderColor: '#3b82f6',
transform: [{ scale: 1.02 }],
},
dragHandle: {
marginRight: 8,
padding: 2,
},
image: {
width: 30,
height: 30,
borderRadius: 6,
marginRight: 10,
},
placeholderImage: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: '#f3f4f6',
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
},
name: {
flex: 1,
fontSize: 13,
color: '#111827',
fontWeight: '500',
marginRight: 4,
},
removeButton: {
padding: 2,
},
});
TagForm.displayName = 'TagForm'; TagForm.displayName = 'TagForm';
export default TagForm; export default TagForm;

View file

@ -1,11 +1,11 @@
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
import { scaffoldProducts, scaffoldAvailability } from '@/src/trpc/apis/common-apis/common' import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common'
import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index' import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index'
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores' import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots' import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
import { scaffoldBanners } from '@/src/trpc/apis/user-apis/apis/banners' import { scaffoldBanners } from '@/src/trpc/apis/user-apis/apis/banners'
import { scaffoldStoreWithProducts } from '@/src/trpc/apis/user-apis/apis/stores' import { scaffoldStoreWithProducts } from '@/src/trpc/apis/user-apis/apis/stores'
import { getStoresSummary, incrementCacheVersion, incrementAvailabilityVersionNum, incrementSlotsVersionNum } from '@/src/dbService' import { getStoresSummary, incrementCacheVersion } from '@/src/dbService'
import { imageUploadS3 } from '@/src/lib/s3-client' import { imageUploadS3 } from '@/src/lib/s3-client'
import { getApiCacheKey, getCloudflareApiToken, getCloudflareZoneId, getAssetsDomain } from '@/src/lib/env-exporter' import { getApiCacheKey, getCloudflareApiToken, getCloudflareZoneId, getAssetsDomain } from '@/src/lib/env-exporter'
import { CACHE_FILENAMES } from '@packages/shared' import { CACHE_FILENAMES } from '@packages/shared'
@ -13,29 +13,16 @@ import { retryWithExponentialBackoff } from '@/src/lib/retry'
const buildCachePath = (path: string, version: number) => `v-${version}/${path}` const buildCachePath = (path: string, version: number) => `v-${version}/${path}`
const buildAvailabilityPath = (version: number) => `av-${version}/${CACHE_FILENAMES.availability}`
const buildSlotsPath = (version: number) => `slots/v-${version}/${CACHE_FILENAMES.slots}`
function constructCacheUrl(path: string, version: number): string { function constructCacheUrl(path: string, version: number): string {
return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}` return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}`
} }
function constructAvailabilityUrl(version: number): string {
return `${getAssetsDomain()}${buildAvailabilityPath(version)}`
}
function constructSlotsUrl(version: number): string {
return `${getAssetsDomain()}${buildSlotsPath(version)}`
}
export interface CreateAllCacheFilesResult { export interface CreateAllCacheFilesResult {
cacheVersion: number cacheVersion: number
products: string products: string
essentialConsts: string essentialConsts: string
stores: string stores: string
slotsVersion: number slots: string
availabilityVersion: number
banners: string banners: string
individualStores: string[] individualStores: string[]
} }
@ -50,16 +37,14 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
productsKey, productsKey,
essentialConstsKey, essentialConstsKey,
storesKey, storesKey,
slotsVersion, slotsKey,
availabilityVersion,
bannersKey, bannersKey,
individualStoreKeys, individualStoreKeys,
] = await Promise.all([ ] = await Promise.all([
createProductsFileInternal(cacheVersion), createProductsFileInternal(cacheVersion),
createEssentialConstsFileInternal(cacheVersion), createEssentialConstsFileInternal(cacheVersion),
createStoresFileInternal(cacheVersion), createStoresFileInternal(cacheVersion),
createSlotsCacheFile(), createSlotsFileInternal(cacheVersion),
createAvailabilityCacheFile(),
createBannersFileInternal(cacheVersion), createBannersFileInternal(cacheVersion),
createAllStoresFilesInternal(cacheVersion), createAllStoresFilesInternal(cacheVersion),
]) ])
@ -71,8 +56,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
constructCacheUrl(CACHE_FILENAMES.products, cacheVersion), constructCacheUrl(CACHE_FILENAMES.products, cacheVersion),
constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion), constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion),
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion), constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
constructSlotsUrl(slotsVersion), constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
constructAvailabilityUrl(availabilityVersion),
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion), constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)), ...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
] ]
@ -92,8 +76,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
products: productsKey, products: productsKey,
essentialConsts: essentialConstsKey, essentialConsts: essentialConstsKey,
stores: storesKey, stores: storesKey,
slotsVersion, slots: slotsKey,
availabilityVersion,
banners: bannersKey, banners: bannersKey,
individualStores: individualStoreKeys, individualStores: individualStoreKeys,
} }
@ -115,23 +98,6 @@ async function createProductsFileInternal(version: number): Promise<string> {
} }
export async function createAvailabilityCacheFile(): Promise<number> {
const version = await incrementAvailabilityVersionNum()
const availabilityData = await scaffoldAvailability()
const jsonContent = JSON.stringify(availabilityData, null, 2)
const buffer = Buffer.from(jsonContent, 'utf-8')
const filePath = buildAvailabilityPath(version)
console.log(filePath)
await imageUploadS3(
buffer,
'application/json',
filePath
)
return version
}
async function createEssentialConstsFileInternal(version: number): Promise<string> { async function createEssentialConstsFileInternal(version: number): Promise<string> {
const essentialConstsData = await scaffoldEssentialConsts() const essentialConstsData = await scaffoldEssentialConsts()
const jsonContent = JSON.stringify(essentialConstsData, null, 2) const jsonContent = JSON.stringify(essentialConstsData, null, 2)
@ -154,21 +120,15 @@ async function createStoresFileInternal(version: number): Promise<string> {
) )
} }
export async function createSlotsCacheFile(): Promise<number> { async function createSlotsFileInternal(version: number): Promise<string> {
const version = await incrementSlotsVersionNum()
const slotsData = await scaffoldSlotsWithProducts() const slotsData = await scaffoldSlotsWithProducts()
const jsonContent = JSON.stringify(slotsData, null, 2) const jsonContent = JSON.stringify(slotsData, null, 2)
const buffer = Buffer.from(jsonContent, 'utf-8') const buffer = Buffer.from(jsonContent, 'utf-8')
const filePath = buildSlotsPath(version) return await imageUploadS3(
console.log(filePath)
await imageUploadS3(
buffer, buffer,
'application/json', 'application/json',
filePath `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}`
) )
return version
} }
async function createBannersFileInternal(version: number): Promise<string> { async function createBannersFileInternal(version: number): Promise<string> {

View file

@ -17,7 +17,6 @@ export const CONST_KEYS = {
appStoreUrl: 'appStoreUrl', appStoreUrl: 'appStoreUrl',
popularItems: 'popularItems', popularItems: 'popularItems',
allItemsOrder: 'allItemsOrder', allItemsOrder: 'allItemsOrder',
tagsOrder: 'tagsOrder',
isFlashDeliveryEnabled: 'isFlashDeliveryEnabled', isFlashDeliveryEnabled: 'isFlashDeliveryEnabled',
supportMobile: 'supportMobile', supportMobile: 'supportMobile',
supportEmail: 'supportEmail', supportEmail: 'supportEmail',
@ -42,7 +41,6 @@ export const CONST_LABELS: Record<ConstKey, string> = {
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
allItemsOrder: 'All Items Order', allItemsOrder: 'All Items Order',
tagsOrder: 'Tags Order',
isFlashDeliveryEnabled: 'Enable Flash Delivery', isFlashDeliveryEnabled: 'Enable Flash Delivery',
supportMobile: 'Support Mobile', supportMobile: 'Support Mobile',
supportEmail: 'Support Email', supportEmail: 'Support Email',
@ -73,7 +71,6 @@ export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
appStoreUrl: 'string', appStoreUrl: 'string',
popularItems: 'string', popularItems: 'string',
allItemsOrder: 'string', allItemsOrder: 'string',
tagsOrder: 'string',
isFlashDeliveryEnabled: 'boolean', isFlashDeliveryEnabled: 'boolean',
supportMobile: 'string', supportMobile: 'string',
supportEmail: 'string', supportEmail: 'string',
@ -98,7 +95,6 @@ export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
appStoreUrl: true, appStoreUrl: true,
popularItems: true, popularItems: true,
allItemsOrder: true, allItemsOrder: true,
tagsOrder: false,
isFlashDeliveryEnabled: true, isFlashDeliveryEnabled: true,
supportMobile: true, supportMobile: true,
supportEmail: true, supportEmail: true,

View file

@ -72,7 +72,6 @@ export {
createSpecialDealsForSku, createSpecialDealsForSku,
updateSkuDeals, updateSkuDeals,
replaceProductTags, replaceProductTags,
replaceTagProducts,
mergeSkus, mergeSkus,
updateSlotProducts, updateSlotProducts,
getSlotsProductIds, getSlotsProductIds,
@ -241,7 +240,6 @@ export {
// Store Helpers // Store Helpers
getAllBannersForCache, getAllBannersForCache,
getAllProductsForCache, getAllProductsForCache,
getAvailabilityForCache,
getAllStoresForCache, getAllStoresForCache,
getAllDeliverySlotsForCache, getAllDeliverySlotsForCache,
getAllSpecialDealsForCache, getAllSpecialDealsForCache,

View file

@ -44,7 +44,6 @@ interface Product {
productName: string productName: string
images: string[] | null images: string[] | null
price: string price: string
isOffer: boolean
}> }>
} }
@ -251,7 +250,6 @@ export async function getAllProducts(): Promise<Product[]> {
productName: ci.productName, productName: ci.productName,
images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null,
price: ci.price, price: ci.price,
isOffer: ci.isOffer,
})) }))
products.push({ products.push({

View file

@ -3,7 +3,6 @@ import { z } from 'zod'
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache'
import { import {
getAllProducts as getAllProductsInDb, getAllProducts as getAllProductsInDb,
getProductById as getProductByIdInDb, getProductById as getProductByIdInDb,
@ -21,7 +20,6 @@ import {
checkUnitExists, checkUnitExists,
createProduct as createProductInDb, createProduct as createProductInDb,
replaceProductTags, replaceProductTags,
replaceTagProducts,
getProductImagesById, getProductImagesById,
updateProduct as updateProductInDb, updateProduct as updateProductInDb,
checkProductTagExistsByName, checkProductTagExistsByName,
@ -30,7 +28,6 @@ import {
deleteProductTag as deleteProductTagInDb, deleteProductTag as deleteProductTagInDb,
getAllProductTagInfos as getAllProductTagInfosInDb, getAllProductTagInfos as getAllProductTagInfosInDb,
getProductTagInfoById as getProductTagInfoByIdInDb, getProductTagInfoById as getProductTagInfoByIdInDb,
getProductTagById as getProductTagByIdInDb,
} from '@/src/dbService' } from '@/src/dbService'
import type { import type {
AdminProduct, AdminProduct,
@ -199,7 +196,6 @@ export const productRouter = router({
images: z.array(z.string()).optional().default([]), images: z.array(z.string()).optional().default([]),
isFlashAvailable: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false),
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOutOfStock: z.boolean().optional().default(false),
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), isSuspended: z.boolean().optional().default(false),
@ -229,7 +225,6 @@ export const productRouter = router({
images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOutOfStock: sku.isOutOfStock,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended, isSuspended: sku.isSuspended,
@ -289,7 +284,6 @@ export const productRouter = router({
images: z.array(z.string()).optional().default([]), images: z.array(z.string()).optional().default([]),
isFlashAvailable: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false),
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOutOfStock: z.boolean().optional().default(false),
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), isSuspended: z.boolean().optional().default(false),
@ -321,7 +315,6 @@ export const productRouter = router({
images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOutOfStock: sku.isOutOfStock,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended, isSuspended: sku.isSuspended,
@ -853,9 +846,7 @@ export const productRouter = router({
throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400)
} }
await createAvailabilityCacheFile().catch((err) => { await scheduleStoreInitialization()
console.error('Failed to regenerate availability cache after price update:', err)
})
return { return {
message: `Updated prices for ${result.updatedCount} product(s)`, message: `Updated prices for ${result.updatedCount} product(s)`,
@ -882,8 +873,8 @@ export const productRouter = router({
getProductTagById: protectedProcedure getProductTagById: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date; products: Array<{ productId: number; tagId: number; assignedAt: Date; product: any }>; productIds: number[] }; message: string }> => { .query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
const tag = await getProductTagByIdInDb(input.id) const tag = await getProductTagInfoByIdInDb(input.id)
if (!tag) { if (!tag) {
throw new ApiError('Tag not found', 404) throw new ApiError('Tag not found', 404)
@ -907,11 +898,10 @@ export const productRouter = router({
imageUrl: z.string().optional().nullable(), imageUrl: z.string().optional().nullable(),
isDashboardTag: z.boolean().optional().default(false), isDashboardTag: z.boolean().optional().default(false),
relatedStores: z.array(z.number()).optional().default([]), relatedStores: z.array(z.number()).optional().default([]),
productIds: z.array(z.number()).optional().default([]),
uploadUrls: z.array(z.string()).optional().default([]), uploadUrls: z.array(z.string()).optional().default([]),
})) }))
.mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date }; message: string }> => { .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
const existingTag = await checkProductTagExistsByName(tagName.trim()) const existingTag = await checkProductTagExistsByName(tagName.trim())
if (existingTag) { if (existingTag) {
@ -924,11 +914,8 @@ export const productRouter = router({
imageUrl: imageUrl ?? null, imageUrl: imageUrl ?? null,
isDashboardTag, isDashboardTag,
relatedStores, relatedStores,
sortOrder: productIds,
}) })
await replaceTagProducts(createdTag.id, productIds)
if (uploadUrls.length > 0) { if (uploadUrls.length > 0) {
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
} }
@ -954,11 +941,10 @@ export const productRouter = router({
imageUrl: z.string().optional().nullable(), imageUrl: z.string().optional().nullable(),
isDashboardTag: z.boolean().optional(), isDashboardTag: z.boolean().optional(),
relatedStores: z.array(z.number()).optional(), relatedStores: z.array(z.number()).optional(),
productIds: z.array(z.number()).optional(),
uploadUrls: z.array(z.string()).optional().default([]), uploadUrls: z.array(z.string()).optional().default([]),
})) }))
.mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[]; createdAt: Date }; message: string }> => { .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
const currentTag = await getProductTagInfoByIdInDb(id) const currentTag = await getProductTagInfoByIdInDb(id)
@ -978,13 +964,8 @@ export const productRouter = router({
imageUrl: imageUrl ?? undefined, imageUrl: imageUrl ?? undefined,
isDashboardTag, isDashboardTag,
relatedStores, relatedStores,
sortOrder: productIds,
}) })
if (productIds !== undefined) {
await replaceTagProducts(id, productIds)
}
if (uploadUrls.length > 0) { if (uploadUrls.length > 0) {
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
} }

View file

@ -6,7 +6,6 @@ import { getAppUrl } from "@/src/lib/env-exporter"
// import redisClient from "@/src/lib/redis-client" // import redisClient from "@/src/lib/redis-client"
// import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters" // import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { createSlotsCacheFile } from '@/src/lib/cloud_cache'
import { import {
getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb, getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb,
getActiveSlots as getActiveSlotsInDb, getActiveSlots as getActiveSlotsInDb,
@ -267,9 +266,7 @@ export const slotsRouter = router({
}; };
*/ */
await createSlotsCacheFile().catch((err) => { await scheduleStoreInitialization()
console.error('Failed to regenerate slots cache after product update:', err)
})
return { return {
message: result.message, message: result.message,
@ -363,10 +360,8 @@ export const slotsRouter = router({
}); });
*/ */
// Regenerate slots cache file (availability/products stay as-is) // Reinitialize stores to reflect changes (outside transaction)
await createSlotsCacheFile().catch((error) => { await scheduleStoreInitialization()
console.error('Failed to regenerate slots cache after slot create:', error)
})
// Fire and forget: cleanup stale product slot associations // Fire and forget: cleanup stale product slot associations
staleSlotsCleanup().catch((error) => { staleSlotsCleanup().catch((error) => {
@ -553,10 +548,8 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) throw new ApiError('Slot not found', 404)
} }
// Regenerate slots cache file (availability/products stay as-is) // Reinitialize stores to reflect changes (outside transaction)
await createSlotsCacheFile().catch((error) => { await scheduleStoreInitialization()
console.error('Failed to regenerate slots cache after slot update:', error)
})
return result return result
} }
@ -594,10 +587,8 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) throw new ApiError('Slot not found', 404)
} }
// Regenerate slots cache file (availability/products stay as-is) // Reinitialize stores to reflect changes
await createSlotsCacheFile().catch((error) => { await scheduleStoreInitialization()
console.error('Failed to regenerate slots cache after slot delete:', error)
})
return { return {
message: 'Slot deleted successfully', message: 'Slot deleted successfully',
@ -745,9 +736,7 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) throw new ApiError('Slot not found', 404)
} }
await createSlotsCacheFile().catch((error) => { await scheduleStoreInitialization()
console.error('Failed to regenerate slots cache after capacity update:', error)
})
return result return result
}), }),

View file

@ -4,8 +4,6 @@ import {
getStoresSummary, getStoresSummary,
healthCheck, healthCheck,
getCacheVersion, getCacheVersion,
getAvailabilityVersionNum,
getSlotsVersionNum,
} from '@/src/dbService' } from '@/src/dbService'
import type { StoresSummaryResponse } from '@packages/shared' import type { StoresSummaryResponse } from '@packages/shared'
import { polygon as turfPolygon, point as turfPoint } from '@turf/helpers'; import { polygon as turfPolygon, point as turfPoint } from '@turf/helpers';
@ -23,8 +21,6 @@ const polygon = turfPolygon(mbnrGeoJson.features[0].geometry.coordinates);
export async function scaffoldEssentialConsts() { export async function scaffoldEssentialConsts() {
const consts = await getAllConstValues(); const consts = await getAllConstValues();
const cacheVersion = await getCacheVersion() const cacheVersion = await getCacheVersion()
const availabilityVersionNum = await getAvailabilityVersionNum()
const slotsVersionNum = await getSlotsVersionNum()
return { return {
freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200, freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200,
@ -44,8 +40,6 @@ export async function scaffoldEssentialConsts() {
assetsDomain: getAssetsDomain(), assetsDomain: getAssetsDomain(),
apiCacheKey: getApiCacheKey(), apiCacheKey: getApiCacheKey(),
cacheVersion, cacheVersion,
availabilityVersionNum,
slotsVersionNum,
}; };
} }

View file

@ -6,13 +6,10 @@ import {
getAllSkusSummary as getAllSkusSummaryInDb, getAllSkusSummary as getAllSkusSummaryInDb,
getAllTagsForCache, getAllTagsForCache,
getAllTagProductMappings, getAllTagProductMappings,
getAvailabilityForCache,
} from '@/src/dbService' } from '@/src/dbService'
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } 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'
import { getConstant } from '@/src/lib/const-store'
import { CONST_KEYS } from '@/src/lib/const-keys'
// Re-export with original name for backwards compatibility // Re-export with original name for backwards compatibility
export const getNextDeliveryDate = getNextDeliveryDateWithCapacity export const getNextDeliveryDate = getNextDeliveryDateWithCapacity
@ -48,14 +45,18 @@ export async function scaffoldProducts() {
id: product.id, id: product.id,
name: product.name, name: product.name,
shortDescription: product.shortDescription, shortDescription: product.shortDescription,
price: parseFloat(product.price),
marketPrice: product.marketPrice ? parseFloat(product.marketPrice) : null,
unit: product.unitNotation, unit: product.unitNotation,
unitNotation: product.unitNotation, unitNotation: product.unitNotation,
incrementStep: product.incrementStep, incrementStep: product.incrementStep,
productQuantity: product.productQuantity, productQuantity: product.productQuantity,
storeId: product.store?.id || null, storeId: product.store?.id || null,
isOutOfStock: product.isOutOfStock, isOutOfStock: product.isOutOfStock,
isFlashAvailable: product.isFlashAvailable,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: product.images, images: product.images,
flashPrice: product.flashPrice,
productType: product.productType || 'item' productType: product.productType || 'item'
}; };
}) })
@ -67,28 +68,6 @@ export async function scaffoldProducts() {
getAllTagProductMappings(), getAllTagProductMappings(),
]) ])
// Order tags by the admin-defined tagsOrder (unknown tags go to the end).
const tagsOrderRaw = await getConstant(CONST_KEYS.tagsOrder)
let tagsOrderIds: number[] = []
if (Array.isArray(tagsOrderRaw)) {
tagsOrderIds = tagsOrderRaw.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id))
} else if (typeof tagsOrderRaw === 'string') {
tagsOrderIds = tagsOrderRaw.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id))
}
const tagsById = new Map(allTags.map((tag: any) => [tag.id, tag]))
const orderedTags: any[] = []
for (const id of tagsOrderIds) {
const tag = tagsById.get(id)
if (tag) {
orderedTags.push(tag)
tagsById.delete(id)
}
}
for (const tag of tagsById.values()) {
orderedTags.push(tag)
}
const productIdsByTag = new Map<number, number[]>() const productIdsByTag = new Map<number, number[]>()
for (const mapping of tagMappings) { for (const mapping of tagMappings) {
if (!productIdsByTag.has(mapping.tagId)) { if (!productIdsByTag.has(mapping.tagId)) {
@ -97,25 +76,14 @@ export async function scaffoldProducts() {
productIdsByTag.get(mapping.tagId)!.push(mapping.productId) productIdsByTag.get(mapping.tagId)!.push(mapping.productId)
} }
// Reorder each tag's product ids by the admin-defined sortOrder (unknown products go to the end). const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({
const reorderProductIds = (tagId: number, tagSortOrder: number[] | undefined) => {
const current = productIdsByTag.get(tagId) || []
if (!Array.isArray(tagSortOrder) || tagSortOrder.length === 0) {
return current
}
const ordered = tagSortOrder.filter((id: number) => current.includes(id))
const rest = current.filter((id: number) => !ordered.includes(id))
return [...ordered, ...rest]
}
const tags = orderedTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; sortOrder: number[] | null }) => ({
id: tag.id, id: tag.id,
tagName: tag.tagName, tagName: tag.tagName,
tagDescription: tag.tagDescription, tagDescription: tag.tagDescription,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: (tag.relatedStores as number[]) || [], relatedStores: (tag.relatedStores as number[]) || [],
productIds: reorderProductIds(tag.id, tag.sortOrder ?? undefined), productIds: productIdsByTag.get(tag.id) || [],
})) }))
return { return {
@ -125,15 +93,6 @@ export async function scaffoldProducts() {
}; };
} }
export async function scaffoldAvailability() {
const availability = await getAvailabilityForCache()
return {
availability,
count: availability.length,
};
}
export const commonRouter = router({ export const commonRouter = router({
getDashboardTags: publicProcedure getDashboardTags: publicProcedure
.query(async () => { .query(async () => {

View file

@ -17,16 +17,28 @@ export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProducts
const productAvailability = await getUserProductAvailabilityInDb() const productAvailability = await getUserProductAvailabilityInDb()
return { /*
slots: validSlots.map((slot) => ({ // Old implementation - direct DB query:
id: slot.id, const allProducts = await db
deliveryTime: slot.deliveryTime, .select({
freezeTime: slot.freezeTime, id: productInfo.id,
products: (slot.products || []).map((product) => ({ name: productInfo.name,
isOutOfStock: productInfo.isOutOfStock,
isFlashAvailable: productInfo.isFlashAvailable,
})
.from(productInfo)
.where(eq(productInfo.isSuspended, false));
const productAvailability = allProducts.map(product => ({
id: product.id, id: product.id,
images: product.images, name: product.name,
})), isOutOfStock: product.isOutOfStock,
})), isFlashAvailable: product.isFlashAvailable,
}));
*/
return {
slots: validSlots,
productAvailability, productAvailability,
count: validSlots.length, count: validSlots.length,
}; };

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { adminRouter } from '@/src/trpc/apis/admin-apis/apis/admin-trpc-index' import { adminRouter } from '@/src/trpc/apis/admin-apis/apis/admin-trpc-index'
import { userRouter } from '@/src/trpc/apis/user-apis/apis/user-trpc-index' import { userRouter } from '@/src/trpc/apis/user-apis/apis/user-trpc-index'
import { commonApiRouter } from '@/src/trpc/apis/common-apis/common-trpc-index' import { commonApiRouter } from '@/src/trpc/apis/common-apis/common-trpc-index'
import { scaffoldProducts, scaffoldAvailability } from './apis/common-apis/common'; import { scaffoldProducts } from './apis/common-apis/common';
import { scaffoldStores, scaffoldStoreWithProducts } from './apis/user-apis/apis/stores'; import { scaffoldStores, scaffoldStoreWithProducts } from './apis/user-apis/apis/stores';
import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots'; import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots';
import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index'; import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index';
@ -26,7 +26,6 @@ export const appRouter = router({
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
export type AllProductsApiType = Awaited<ReturnType<typeof scaffoldProducts>>; export type AllProductsApiType = Awaited<ReturnType<typeof scaffoldProducts>>;
export type AvailabilityApiType = Awaited<ReturnType<typeof scaffoldAvailability>>;
export type StoresApiType = Awaited<ReturnType<typeof scaffoldStores>>; export type StoresApiType = Awaited<ReturnType<typeof scaffoldStores>>;
export type SlotsApiType = Awaited<ReturnType<typeof scaffoldSlotsWithProducts>>; export type SlotsApiType = Awaited<ReturnType<typeof scaffoldSlotsWithProducts>>;
export type EssentialConstsApiType = Awaited<ReturnType<typeof scaffoldEssentialConsts>>; export type EssentialConstsApiType = Awaited<ReturnType<typeof scaffoldEssentialConsts>>;

View file

@ -49,16 +49,10 @@ and paste it ABOVE the child table's block. Then verify it loads cleanly:
sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/<dump>.sql; COMMIT;" sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/<dump>.sql; COMMIT;"
This should exit 0 with no error. (Example already applied to `latest_1.sql` and This should exit 0 with no error. (Example already applied to `latest_1.sql`:
`local_8_aug.sql`: `product_info` was moved above `product_skus`.) `product_info` was moved above `product_skus`.)
## When to re-check ## When to re-check
After ANY new `wrangler d1 export`, especially once a migration that re-creates After ANY new `wrangler d1 export`, especially once a migration that re-creates
tables has been applied. This is a general trap, not specific to one dump. tables has been applied. This is a general trap, not specific to one dump.
> The SKU-split migration re-creates these tables in this historical order:
> `product_skus`, `sku_features`, `product_market_stats`, `product_info`,
> `cart_items`, `order_items`, `product_combos`. Exports put `product_info`
> AFTER its child `product_skus` — every fresh export needs `product_info`
> moved above `product_skus` (or the whole chain checked) before local import.

View file

@ -9,7 +9,7 @@ routes = [
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "freshyo-backend-dev" database_name = "freshyo-backend-dev"
database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa" database_id = "0814d709-5278-4311-8978-c36c0f05875d"
#database_name = "freshyo-dev" #database_name = "freshyo-dev"
#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" #database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de"
migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_dir="../../packages/db_helper_sqlite/drizzle"

View file

@ -1,6 +1,5 @@
import React, { useState, useCallback, useMemo, memo, useRef } from "react"; import React, { useState, useCallback, useMemo, memo } from "react";
import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native"; import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native";
import { TabView, TabBar } from "react-native-tab-view";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { import {
@ -26,6 +25,7 @@ import { useProductSlotIdentifier } from "@/hooks/useProductSlotIdentifier";
import { useCentralSlotStore } from "@/src/store/centralSlotStore"; import { useCentralSlotStore } from "@/src/store/centralSlotStore";
import { useCentralProductStore } from "@/src/store/centralProductStore"; import { useCentralProductStore } from "@/src/store/centralProductStore";
import FloatingCartBar from "@/components/floating-cart-bar"; import FloatingCartBar from "@/components/floating-cart-bar";
import BannerCarousel from "@/components/BannerCarousel";
import { useUserDetails } from "@/src/contexts/AuthContext"; import { useUserDetails } from "@/src/contexts/AuthContext";
import TabLayoutWrapper from "@/components/TabLayoutWrapper"; import TabLayoutWrapper from "@/components/TabLayoutWrapper";
import { useNavigationStore } from "@/src/store/navigationStore"; import { useNavigationStore } from "@/src/store/navigationStore";
@ -37,14 +37,6 @@ const itemWidth = screenWidth * 0.45;
const heroItemWidth = (screenWidth - 72) / 3; const heroItemWidth = (screenWidth - 72) / 3;
const gridItemWidth = (screenWidth - 48) / 2; const gridItemWidth = (screenWidth - 48) / 2;
// Hero product card geometry (used to size the tab scene heights)
const heroCardImageHeight = heroItemWidth * 0.82;
const heroCardTextBlock = 86;
const heroCardHeight = heroCardImageHeight + heroCardTextBlock;
const heroRowHeight = heroCardHeight + 12; // + mb-3
const TAG_GRID_COLUMNS = 3;
const TAB_BAR_HEIGHT = 48;
const formatTimeRange = (deliveryTime: string) => { const formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime); const time = dayjs(deliveryTime);
const endTime = time.add(1, 'hour'); const endTime = time.add(1, 'hour');
@ -61,6 +53,7 @@ const formatTimeRange = (deliveryTime: string) => {
const staticStyles = { const staticStyles = {
flatListContent: { gap: 16 }, flatListContent: { gap: 16 },
columnWrapper: { gap: 16, paddingHorizontal: 16 }, columnWrapper: { gap: 16, paddingHorizontal: 16 },
popularListContent: { paddingBottom: 16 },
slotsListContent: { paddingBottom: 24 }, slotsListContent: { paddingBottom: 24 },
}; };
@ -98,15 +91,15 @@ const RenderStore = memo(({ item }: RenderStoreProps) => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<View <View
style={tw`w-16 h-16 rounded-2xl bg-brand50 border border-brand100 items-center justify-center mb-2 shadow-sm overflow-hidden`} style={tw`w-16 h-16 rounded-2xl bg-white/20 border-2 border-white/30 items-center justify-center mb-2 shadow-lg overflow-hidden`}
> >
{item.signedImageUrl ? ( {item.signedImageUrl ? (
<Image source={{ uri: item.signedImageUrl }} style={tw`w-16 h-16 rounded-2xl`} resizeMode="cover" /> <Image source={{ uri: item.signedImageUrl }} style={tw`w-16 h-16 rounded-2xl`} resizeMode="cover" />
) : ( ) : (
<MaterialIcons name="storefront" size={28} color={theme.colors.brand600} /> <MaterialIcons name="storefront" size={28} color="#FFF" />
)} )}
</View> </View>
<MyText style={tw`font-bold text-xs text-center tracking-wide text-neutral-800`} numberOfLines={1}> <MyText style={tw`font-bold text-xs text-center tracking-wide drop-shadow-sm text-neutral-800`} numberOfLines={1}>
{item.name.replace(/^The\s+/i, "")} {item.name.replace(/^The\s+/i, "")}
</MyText> </MyText>
</MyTouchableOpacity> </MyTouchableOpacity>
@ -132,7 +125,7 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
<MyTouchableOpacity <MyTouchableOpacity
testID={`slot-card-${slot.id}`} testID={`slot-card-${slot.id}`}
style={[ style={[
tw`bg-white rounded-[24px] p-5 mr-4 shadow-sm border border-gray-100 min-w-[280px]`, tw`bg-white rounded-[24px] p-5 mr-4 shadow-xl shadow-slate-200 border border-slate-100 min-w-[280px]`,
isClosingSoon ? tw`border-l-4 border-l-amber-400` : tw`border-l-4 border-l-brand500`, isClosingSoon ? tw`border-l-4 border-l-amber-400` : tw`border-l-4 border-l-brand500`,
]} ]}
onPress={handlePress} onPress={handlePress}
@ -186,15 +179,35 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
</View> </View>
))} ))}
</View> </View>
<View style={tw`flex-row items-center bg-brand50 rounded-full px-3 py-1.5`}> <MyText style={tw`text-[11px] font-bold text-brand600`}>View all {slot.products.length} items</MyText>
<MyText style={tw`text-[11px] font-bold text-brand700`}>View all {slot.products.length} items</MyText> <MaterialIcons name="chevron-right" size={16} color={theme.colors.brand600} />
<MaterialIcons name="chevron-right" size={16} color={theme.colors.brand600} style={tw`ml-0.5`} />
</View>
</View> </View>
</MyTouchableOpacity> </MyTouchableOpacity>
); );
}); });
interface PopularProductItemProps {
item: any;
onPress: (id: number) => void;
}
const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) => {
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 ExploreTabProps { interface ExploreTabProps {
tag: any; tag: any;
isSelected: boolean; isSelected: boolean;
@ -213,7 +226,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
style={[ style={[
tw`text-base tracking-tight`, tw`text-base tracking-tight`,
{ {
color: isSelected ? theme.colors.brand600 : '#64748B', color: isSelected ? '#111827' : '#64748B',
fontWeight: isSelected ? '700' : '500', fontWeight: isSelected ? '700' : '500',
}, },
]} ]}
@ -226,7 +239,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
height: 4, height: 4,
borderRadius: 999, borderRadius: 999,
marginTop: 8, marginTop: 8,
backgroundColor: isSelected ? theme.colors.brand500 : 'transparent', backgroundColor: isSelected ? '#111827' : 'transparent',
}} }}
/> />
</View> </View>
@ -234,141 +247,28 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
); );
}); });
interface TagTabViewProps { interface ExploreTabsRowProps {
dashboardTags: any[];
activeTagId: number | null;
productsByTagId: Record<number, any[]>;
onSelectTag: (id: number) => void;
onProductPress: (id: number) => void;
}
const TagTabView = memo(({
dashboardTags,
activeTagId,
productsByTagId,
onSelectTag,
onProductPress,
}: TagTabViewProps) => {
const [expandedByTagId, setExpandedByTagId] = useState<Record<number, boolean>>({});
const routes = useMemo(
() => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })),
[dashboardTags]
);
const index = useMemo(() => {
const idx = dashboardTags.findIndex((tag) => tag.id === activeTagId);
return idx < 0 ? 0 : idx;
}, [dashboardTags, activeTagId]);
// Height: fit the max visible product rows across all tags (default 6 per tag).
const sceneHeight = useMemo(() => {
let maxRows = 1;
for (const tag of dashboardTags) {
const count = (productsByTagId[tag.id] || []).length;
const visible = expandedByTagId[tag.id] ? count : Math.min(count, 6);
const rows = Math.max(1, Math.ceil(visible / TAG_GRID_COLUMNS));
maxRows = Math.max(maxRows, rows);
}
return maxRows * heroRowHeight + 16;
}, [dashboardTags, productsByTagId, expandedByTagId]);
const renderTabBar = useCallback((props: any) => (
<TabBar
{...props}
scrollEnabled
activeColor={theme.colors.brand600}
inactiveColor="#64748B"
indicatorStyle={{
backgroundColor: theme.colors.brand500,
height: 4,
borderRadius: 999,
}}
labelStyle={tw`text-sm font-bold`}
style={tw`bg-transparent`}
tabStyle={{ width: 'auto', paddingHorizontal: 8, minWidth: 0 }}
indicatorContainerStyle={tw`mb-1`}
/>
), []);
const renderScene = useCallback(({ route }: any) => {
const tagId = Number(route.key);
const products = productsByTagId[tagId] || [];
const expanded = !!expandedByTagId[tagId];
const visible = expanded ? products : products.slice(0, 6);
const hasMore = products.length > 6 && !expanded;
return (
<View style={tw`mt-3`}>
{products.length > 0 ? (
<View style={tw`flex-row flex-wrap justify-between`}>
{visible.map((product: any) => (
<ExploreProductItem key={product.id} item={product} onPress={onProductPress} />
))}
</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>
)}
{hasMore && (
<MyTouchableOpacity
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-brand500 shadow-sm`}
activeOpacity={0.85}
onPress={() => setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))}
>
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
</MyTouchableOpacity>
)}
</View>
);
}, [productsByTagId, expandedByTagId, onProductPress]);
return (
<TabView
navigationState={{ index, routes }}
onIndexChange={(i) => onSelectTag(Number(routes[i].key))}
renderTabBar={renderTabBar}
renderScene={renderScene}
swipeEnabled
lazy
style={{ height: TAB_BAR_HEIGHT + sceneHeight }}
/>
);
});
interface StickyTabsRowProps {
dashboardTags: any[]; dashboardTags: any[];
activeTagId: number | null; activeTagId: number | null;
onSelectTag: (id: number) => void; onSelectTag: (id: number) => void;
} }
const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => { const ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => (
const scrollRef = useRef<ScrollView>(null);
const handleSelect = (id: number) => {
onSelectTag(id);
const idx = dashboardTags.findIndex((tag) => tag.id === id);
if (idx >= 0) {
scrollRef.current?.scrollTo({ x: idx * 96, animated: true });
}
};
return (
<ScrollView <ScrollView
ref={scrollRef}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={tw`gap-3 py-1 px-1`} contentContainerStyle={tw`gap-3 py-1 px-1`}
> >
{dashboardTags.map((tag) => ( {dashboardTags.map((tag) => (
<ExploreTab key={tag.id} tag={tag} isSelected={activeTagId === tag.id} onPress={() => handleSelect(tag.id)} /> <ExploreTab
key={tag.id}
tag={tag}
isSelected={activeTagId === tag.id}
onPress={() => onSelectTag(tag.id)}
/>
))} ))}
</ScrollView> </ScrollView>
); ));
});
interface ExploreProductItemProps { interface ExploreProductItemProps {
item: any; item: any;
@ -423,11 +323,12 @@ interface ListHeaderProps {
gradientHeight: number; gradientHeight: number;
onGradientLayout: (height: number) => void; onGradientLayout: (height: number) => void;
storesData: any; storesData: any;
popularProducts: any[];
sortedSlots: any[]; sortedSlots: any[];
onProductPress: (id: number) => void; onProductPress: (id: number) => void;
dashboardTags: any[]; dashboardTags: any[];
activeTagId: number | null; activeTagId: number | null;
productsByTagId: Record<number, any[]>; activeTagProducts: any[];
onSelectTag: (id: number) => void; onSelectTag: (id: number) => void;
onTabsSectionLayout: (layout: { y: number; height: number }) => void; onTabsSectionLayout: (layout: { y: number; height: number }) => void;
} }
@ -436,19 +337,29 @@ const ListHeader = memo(({
gradientHeight, gradientHeight,
onGradientLayout, onGradientLayout,
storesData, storesData,
popularProducts,
sortedSlots, sortedSlots,
onProductPress, onProductPress,
dashboardTags, dashboardTags,
activeTagId, activeTagId,
productsByTagId, activeTagProducts,
onSelectTag, onSelectTag,
onTabsSectionLayout, onTabsSectionLayout,
}: ListHeaderProps) => { }: ListHeaderProps) => {
const [showAllActiveProducts, setShowAllActiveProducts] = useState(false);
const handleLayout = useCallback((event: any) => { const handleLayout = useCallback((event: any) => {
const { y, height } = event.nativeEvent.layout; const { y, height } = event.nativeEvent.layout;
onGradientLayout(y + height); onGradientLayout(y + height);
}, [onGradientLayout]); }, [onGradientLayout]);
React.useEffect(() => {
setShowAllActiveProducts(false);
}, [activeTagId]);
const renderPopularItem = useCallback(({ item }: { item: any }) => (
<PopularProductItem item={item} onPress={onProductPress} />
), [onProductPress]);
const renderSlotItem = useCallback(({ item }: { item: any }) => ( const renderSlotItem = useCallback(({ item }: { item: any }) => (
<SlotItem item={item} /> <SlotItem item={item} />
), []); ), []);
@ -459,14 +370,16 @@ const ListHeader = memo(({
], [gradientHeight]); ], [gradientHeight]);
const activeColor = activeTagId == null ? null : getTagColor(activeTagId); const activeColor = activeTagId == null ? null : getTagColor(activeTagId);
const pageTint = activeColor?.pageBg ?? '#FFFFFF'; const pageTint = activeColor?.pageBg ?? '#FFFFFF';
const visibleActiveTagProducts = showAllActiveProducts ? activeTagProducts : activeTagProducts.slice(0, 6);
const hasMoreActiveTagProducts = activeTagProducts.length > visibleActiveTagProducts.length;
return ( return (
<> <>
<View onLayout={handleLayout} style={{ backgroundColor: '#FFFFFF' }}> <View onLayout={handleLayout} style={{ backgroundColor: pageTint }}>
<LinearGradient <LinearGradient
colors={['#FFFFFF', '#FFFFFF']} colors={[pageTint, pageTint]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 1, y: 0.5 }}
style={gradientStyle} style={gradientStyle}
/> />
@ -477,29 +390,59 @@ const ListHeader = memo(({
onTabsSectionLayout({ y, height }); onTabsSectionLayout({ y, height });
}} }}
style={[ style={[
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`, tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-2`,
{ backgroundColor: theme.colors.brand25 }, { backgroundColor: pageTint },
]} ]}
> >
<TagTabView <ExploreTabsRow
dashboardTags={dashboardTags} dashboardTags={dashboardTags}
activeTagId={activeTagId} activeTagId={activeTagId}
productsByTagId={productsByTagId}
onSelectTag={onSelectTag} onSelectTag={onSelectTag}
onProductPress={onProductPress}
/> />
{activeTagProducts.length > 0 ? (
<View style={[tw`mt-4 relative`, { backgroundColor: pageTint }]}>
<View style={tw`flex-row flex-wrap justify-between`}>
{visibleActiveTagProducts.map((product: any) => (
<ExploreProductItem
key={product.id}
item={product}
onPress={onProductPress}
/>
))}
</View>
{hasMoreActiveTagProducts && (
<MyTouchableOpacity
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-gray-900`}
activeOpacity={0.85}
onPress={() => setShowAllActiveProducts(true)}
>
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
</MyTouchableOpacity>
)}
</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>
)} )}
</View> </View>
)}
</View>
<View style={tw`py-4`}>
<BannerCarousel />
</View>
<View <View
style={[ style={[
tw`rounded-t-3xl px-4`, tw`rounded-t-3xl px-4`,
{ backgroundColor: '#FFFFFF' }, { backgroundColor: pageTint },
]} ]}
> >
{storesData?.stores && storesData.stores.length > 0 && ( {storesData?.stores && storesData.stores.length > 0 && (
<View style={tw`mt-4 mb-5 rounded-[28px] bg-white shadow-sm border border-gray-100 px-3 pt-4 pb-2`}> <View style={tw`mt-4 mb-5 rounded-[28px] bg-white/70 border border-white px-3 pt-4 pb-2`}>
<View style={tw`flex-row items-center justify-between mb-4 px-1`}> <View style={tw`flex-row items-center justify-between mb-4 px-1`}>
<View> <View>
<MyText style={tw`text-xl font-extrabold text-gray-900 tracking-tight`}> <MyText style={tw`text-xl font-extrabold text-gray-900 tracking-tight`}>
@ -524,15 +467,36 @@ const ListHeader = memo(({
<NextOrderGlimpse /> <NextOrderGlimpse />
</View> </View>
<View style={tw`mb-4 pt-2 px-1`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Popular Items</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium`}>Trending fresh picks just for you</MyText>
</View>
<View style={tw`relative`}>
<MyFlatList
data={popularProducts}
keyExtractor={(item) => item.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={staticStyles.popularListContent}
renderItem={renderPopularItem}
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>
{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`}>
<View> <View>
<View style={tw`flex-row items-center mb-1.5`}>
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Upcoming Delivery Slots</MyText> <MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Upcoming Delivery Slots</MyText>
</View> <MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Plan your fresh deliveries ahead</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Plan your fresh deliveries ahead</MyText>
</View> </View>
</View> </View>
<MyFlatList <MyFlatList
@ -552,11 +516,8 @@ const ListHeader = memo(({
<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-4`}> <View style={tw`flex-row items-center justify-between px-1 mb-4`}>
<View> <View>
<View style={tw`flex-row items-center mb-1.5`}>
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>All Available Products</MyText> <MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>All Available Products</MyText>
</View> <MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse our complete selection</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Browse our complete selection</MyText>
</View> </View>
</View> </View>
</View> </View>
@ -626,6 +587,21 @@ export default function Dashboard() {
setHasMore(products.length > 10) setHasMore(products.length > 10)
}, [productsData, productSlotsMap]); }, [productsData, productSlotsMap]);
const popularItemIds = useMemo(() => {
const popularItems = essentialConsts?.popularItems;
if (!popularItems) return [];
if (Array.isArray(popularItems)) {
return popularItems.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id));
} else if (typeof popularItems === 'string') {
return popularItems
.split(',')
.map((id: string) => parseInt(id.trim()))
.filter((id: number) => !isNaN(id));
}
return [];
}, [essentialConsts?.popularItems]);
const sortedSlots = useMemo(() => { const sortedSlots = useMemo(() => {
if (!slotsData?.slots) return []; if (!slotsData?.slots) return [];
const now = dayjs(); const now = dayjs();
@ -638,43 +614,31 @@ export default function Dashboard() {
}); });
}, [slotsData]); }, [slotsData]);
const productsByTagId = useMemo(() => { const popularProducts = useMemo(() => {
const map: Record<number, any[]> = {}; return popularItemIds
for (const tag of dashboardTags) { .map(id => products.find(product => product.id === id))
const productById = new Map<number, any>(); .filter((product): product is NonNullable<typeof product> => product != null);
for (const product of products) { }, [popularItemIds, products]);
productById.set(product.id, product);
}
// tag.productIds is already in the admin-curated order (backend sorts by sortOrder). const activeTagProducts = useMemo(() => {
const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id)); if (activeTagId == null) return [];
const ordered: any[] = []; const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId);
const rest: any[] = []; if (!activeTag) return [];
for (const id of orderedIds) {
const product = productById.get(id);
const isOutOfStock = Boolean(productSlotsMap[id]?.isOutOfStock) || !getQuickestSlot(id);
if (isOutOfStock) rest.push(product);
else ordered.push(product);
}
// Products in the tag's productIds but not in the curated order (added later) — availability sort. return products
const seen = new Set(orderedIds); .filter((product: any) => activeTag.productIds?.includes(product.id) ?? false)
const extra = products
.filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.id))
.sort((a: any, b: any) => { .sort((a: any, b: any) => {
const slotA = getQuickestSlot(a.id) const slotA = getQuickestSlot(a.id)
const slotB = getQuickestSlot(b.id) const slotB = getQuickestSlot(b.id)
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
if (aOutOfStock && !bOutOfStock) return 1 if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1 if (!aOutOfStock && bOutOfStock) return -1
return 0 return 0
}); });
}, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]);
map[tag.id] = [...ordered, ...rest, ...extra];
}
return map;
}, [dashboardTags, products, getQuickestSlot, productSlotsMap]);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
@ -739,25 +703,26 @@ export default function Dashboard() {
gradientHeight={gradientHeight} gradientHeight={gradientHeight}
onGradientLayout={handleGradientLayout} onGradientLayout={handleGradientLayout}
storesData={storesData} storesData={storesData}
popularProducts={popularProducts}
sortedSlots={sortedSlots} sortedSlots={sortedSlots}
onProductPress={handleProductPress} onProductPress={handleProductPress}
dashboardTags={dashboardTags} dashboardTags={dashboardTags}
activeTagId={activeTagId} activeTagId={activeTagId}
productsByTagId={productsByTagId} activeTagProducts={activeTagProducts}
onSelectTag={setSelectedTagId} onSelectTag={setSelectedTagId}
onTabsSectionLayout={handleTabsSectionLayout} onTabsSectionLayout={handleTabsSectionLayout}
/> />
), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId, handleTabsSectionLayout]); ), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts, handleTabsSectionLayout]);
const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg;
const pageTintStyle = useMemo(() => [ const pageTintStyle = useMemo(() => [
tw`flex-1`, tw`flex-1`,
{ backgroundColor: '#FFFFFF' } { backgroundColor: pageTint }
], [pageTint]); ], [pageTint]);
const searchBarContainerStyle = useMemo(() => [ const searchBarContainerStyle = useMemo(() => [
tw`w-full px-4 pt-4 pb-0`, tw`w-full px-4 pt-4 pb-0`,
{ backgroundColor: '#FFFFFF' } { backgroundColor: pageTint }
], [pageTint]); ], [pageTint]);
const listContentContainerStyle = useMemo(() => [ const listContentContainerStyle = useMemo(() => [
@ -786,8 +751,11 @@ export default function Dashboard() {
</View> </View>
); );
} }
let str = ''
displayedProducts.forEach(product => str += `${product.id}-`)
// console.log(str)
return ( return (
<TabLayoutWrapper style={{ backgroundColor: '#FFFFFF' }}> <TabLayoutWrapper style={{ backgroundColor: pageTint }}>
<View style={pageTintStyle}> <View style={pageTintStyle}>
<View <View
style={searchBarContainerStyle} style={searchBarContainerStyle}
@ -812,7 +780,7 @@ export default function Dashboard() {
data={displayedProducts} data={displayedProducts}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
numColumns={2} numColumns={2}
style={{ backgroundColor: '#FFFFFF' }} style={{ backgroundColor: pageTint }}
onScroll={handleListScroll} onScroll={handleListScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
contentContainerStyle={listContentContainerStyle} contentContainerStyle={listContentContainerStyle}
@ -823,8 +791,8 @@ export default function Dashboard() {
<RefreshControl <RefreshControl
refreshing={isRefreshing} refreshing={isRefreshing}
onRefresh={handleRefresh} onRefresh={handleRefresh}
tintColor={theme.colors.brand500} tintColor="#3b82f6"
colors={[theme.colors.brand500]} colors={["#3b82f6"]}
/> />
} }
ListEmptyComponent={ ListEmptyComponent={
@ -846,7 +814,7 @@ export default function Dashboard() {
tw`absolute left-0 right-0 z-20 px-4 pb-2`, tw`absolute left-0 right-0 z-20 px-4 pb-2`,
{ {
top: searchBarHeight, top: searchBarHeight,
backgroundColor: '#FFFFFF', backgroundColor: pageTint,
elevation: 8, elevation: 8,
}, },
]} ]}
@ -854,10 +822,10 @@ export default function Dashboard() {
<View <View
style={[ style={[
tw`rounded-[28px] px-3 pt-2 pb-1`, tw`rounded-[28px] px-3 pt-2 pb-1`,
{ backgroundColor: theme.colors.brand25 }, { backgroundColor: pageTint },
]} ]}
> >
<StickyTabsRow <ExploreTabsRow
dashboardTags={dashboardTags} dashboardTags={dashboardTags}
activeTagId={activeTagId} activeTagId={activeTagId}
onSelectTag={setSelectedTagId} onSelectTag={setSelectedTagId}

View file

@ -2,7 +2,6 @@ import React, { useState, useMemo } from 'react';
import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native'; import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui'; import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { theme } from 'common-ui/src/theme'; import { theme } from 'common-ui/src/theme';
@ -378,54 +377,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
</View> </View>
</View> </View>
{/* Combo Items */}
{productDetail.productType === 'combo' && productDetail.comboItems && productDetail.comboItems.length > 0 && (
<View style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-8 h-8 bg-brand50 rounded-full items-center justify-center mr-3`}>
<MaterialIcons name="view-list" size={18} color="#3B82F6" />
</View>
<MyText style={tw`text-lg font-bold text-gray-900`}>Included Items</MyText>
</View>
{productDetail.comboItems.map((comboItem, index) => (
<View
key={comboItem.skuId}
style={tw`flex-row items-center py-2 ${index !== productDetail.comboItems.length - 1 ? 'border-b border-gray-50' : ''}`}
>
<View style={tw`w-12 h-12 bg-gray-100 rounded-lg overflow-hidden items-center justify-center mr-3`}>
{comboItem.images?.[0] ? (
<Image
source={{ uri: comboItem.images[0] }}
style={tw`w-full h-full`}
resizeMode="cover"
/>
) : (
<MaterialIcons name="image" size={20} color="#9CA3AF" />
)}
</View>
<View style={tw`flex-1`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-sm font-bold text-gray-900 flex-1`} numberOfLines={1}>
{comboItem.productName}
</MyText>
{comboItem.isOffer && (
<View style={tw`ml-2 bg-pink-100 px-2 py-0.5 rounded-full`}>
<MyText style={tw`text-[10px] font-bold text-pink-600`}>OFFER</MyText>
</View>
)}
</View>
<MyText style={tw`text-xs text-gray-500 mt-0.5`}>
{comboItem.unitNotation || comboItem.skuName || ''}
</MyText>
</View>
</View>
))}
</View>
</View>
)}
{/* Delivery Slots */} {/* Delivery Slots */}
<View style={tw`px-4 mb-4`}> <View style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}> <View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>

View file

@ -1,4 +1,4 @@
import { useCentralProductStore } from '@/src/store/centralProductStore'; import { useAllProducts } from '@/src/hooks/prominent-api-hooks';
import { useCentralSlotStore } from '@/src/store/centralSlotStore'; import { useCentralSlotStore } from '@/src/store/centralSlotStore';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query';
@ -184,7 +184,7 @@ const clearLocalCart = async (cartType: CartType = "regular"): Promise<void> =>
}; };
export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = "regular"): UseGetCartReturn { export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = "regular"): UseGetCartReturn {
const productsById = useCentralProductStore((state) => state.productsById); const { data: products } = useAllProducts();
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const query: UseQueryResult<CartData, Error> = useQuery({ const query: UseQueryResult<CartData, Error> = useQuery({
@ -193,7 +193,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
const cartItems = await getLocalCart(cartType); const cartItems = await getLocalCart(cartType);
const productMap: Record<number, Omit<ProductSummary, 'isOutOfStock' | 'isFlashAvailable'>> = Object.fromEntries( const productMap: Record<number, Omit<ProductSummary, 'isOutOfStock' | 'isFlashAvailable'>> = Object.fromEntries(
Object.values(productsById).map((p) => [ products?.products?.map((p) => [
p.id, p.id,
{ {
id: p.id, id: p.id,
@ -206,7 +206,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
productQuantity: p.productQuantity, productQuantity: p.productQuantity,
unitNotation: p.unitNotation, unitNotation: p.unitNotation,
}, },
]) ]) ?? []
); );
const items: CartItem[] = cartItems const items: CartItem[] = cartItems
@ -236,7 +236,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
}; };
}, },
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
enabled: (options?.enabled ?? true) && Object.keys(productsById).length > 0, enabled: (options?.enabled ?? true) && !!products,
}); });
return { return {

View file

@ -1,8 +1,7 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
import { trpc } from '@/src/trpc-client' import { trpc } from '@/src/trpc-client'
import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType, AvailabilityApiType } from "@backend/trpc/router"; import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType } from "@backend/trpc/router";
import { CACHE_FILENAMES } from "@packages/shared"; import { CACHE_FILENAMES } from "@packages/shared";
// Local useGetEssentialConsts hook // Local useGetEssentialConsts hook
@ -19,19 +18,6 @@ type SlotsResponse = SlotsApiType;
type EssentialConstsResponse = EssentialConstsApiType; type EssentialConstsResponse = EssentialConstsApiType;
type BannersResponse = BannersApiType; type BannersResponse = BannersApiType;
type StoreWithProductsResponse = StoreWithProductsApiType; type StoreWithProductsResponse = StoreWithProductsApiType;
type AvailabilityResponse = AvailabilityApiType;
type BaseProduct = AllProductsApiType['products'][number]
type AvailabilityEntry = AvailabilityApiType['availability'][number]
export type MergedProduct = BaseProduct & {
price: number
marketPrice: number | null
flashPrice: string | null
isFlashAvailable: boolean
isOutOfStock: boolean
isSuspended: boolean
}
function useCacheUrl(filename: string): string | null { function useCacheUrl(filename: string): string | null {
const { data: essentialConsts } = useGetEssentialConsts() const { data: essentialConsts } = useGetEssentialConsts()
@ -47,37 +33,11 @@ function useCacheUrl(filename: string): string | null {
return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}`
} }
function useAvailabilityCacheUrl(): string | null {
const { data: essentialConsts } = useGetEssentialConsts()
const assetsDomain = essentialConsts?.assetsDomain
const availabilityVersionNum = essentialConsts?.availabilityVersionNum
if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) {
return null
}
return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}`
}
function useSlotsCacheUrl(): string | null {
const { data: essentialConsts } = useGetEssentialConsts()
const assetsDomain = essentialConsts?.assetsDomain
const slotsVersionNum = essentialConsts?.slotsVersionNum
if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) {
return null
}
return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}`
}
export function useAllProducts() { export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability()
const productsQuery = useQuery<ProductsResponse>({
return useQuery<ProductsResponse>({
queryKey: ['all-products', cacheUrl], queryKey: ['all-products', cacheUrl],
queryFn: async () => { queryFn: async () => {
if (!cacheUrl) { if (!cacheUrl) {
@ -89,57 +49,6 @@ export function useAllProducts() {
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl, enabled: !!cacheUrl,
}) })
const mergedProducts = React.useMemo(() => {
const rawProducts = productsQuery.data?.products || []
const availabilityById: Record<number, AvailabilityEntry> = {}
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
availabilityById[entry.id] = entry
})
return rawProducts.map((product) => {
const availability = availabilityById[product.id]
return {
...product,
price: availability ? Number(availability.price) : 0,
marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null,
flashPrice: availability?.flashPrice ?? null,
isFlashAvailable: availability?.isFlashAvailable ?? false,
isOutOfStock: availability?.isOutOfStock ?? false,
isSuspended: availability?.isSuspended ?? false,
}
})
}, [productsQuery.data, availabilityData])
const mergedData = React.useMemo(() => {
if (!productsQuery.data) return undefined
return {
...productsQuery.data,
products: mergedProducts,
} as ProductsResponse & { products: MergedProduct[] }
}, [productsQuery.data, mergedProducts])
return {
...productsQuery,
data: mergedData,
}
}
export function useAvailability() {
const cacheUrl = useAvailabilityCacheUrl()
return useQuery<AvailabilityResponse>({
queryKey: ['availability', cacheUrl],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<AvailabilityResponse>(cacheUrl)
return response.data
},
staleTime: 60000, // 1 minute
enabled: !!cacheUrl,
})
} }
export function useStores() { export function useStores() {
@ -160,7 +69,7 @@ export function useStores() {
} }
export function useSlots() { export function useSlots() {
const cacheUrl = useSlotsCacheUrl() const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots)
return useQuery<SlotsResponse>({ return useQuery<SlotsResponse>({
queryKey: ['slots', cacheUrl], queryKey: ['slots', cacheUrl],

View file

@ -1,8 +1,9 @@
import { create } from 'zustand' import { create } from 'zustand'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAllProducts, type MergedProduct } from '@/src/hooks/prominent-api-hooks' import { useAllProducts } from '@/src/hooks/prominent-api-hooks'
import { AllProductsApiType } from '@backend/trpc/router'
export type Product = MergedProduct type Product = AllProductsApiType['products'][number]
interface CentralProductState { interface CentralProductState {
products: Product[] products: Product[]

View file

@ -1,24 +1,22 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { useSlots, useAvailability } from '@/src/hooks/prominent-api-hooks'; import { useSlots } from '@/src/hooks/prominent-api-hooks';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { SlotsApiType, AvailabilityApiType } from "@backend/trpc/router"; import { SlotsApiType } from "@backend/trpc/router";
type Slot = SlotsApiType['slots'][number]; type Slot = SlotsApiType['slots'][number];
type ProductAvailability = SlotsApiType['productAvailability'][number]; type ProductAvailability = SlotsApiType['productAvailability'][number];
type AvailabilityEntry = AvailabilityApiType['availability'][number];
interface ProductSlotInfo { interface ProductSlotInfo {
slots: Slot[]; slots: Slot[];
isOutOfStock: boolean; isOutOfStock: boolean;
isFlashAvailable: boolean; isFlashAvailable: boolean;
isSuspended: boolean;
} }
interface CentralSlotState { interface CentralSlotState {
slots: Slot[]; slots: Slot[];
productSlotsMap: Record<number, ProductSlotInfo>; productSlotsMap: Record<number, ProductSlotInfo>;
refetchSlots: (() => Promise<void>) | null; refetchSlots: (() => Promise<void>) | null;
setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[], availability: AvailabilityEntry[]) => void; setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[]) => void;
clearSlotsData: () => void; clearSlotsData: () => void;
setRefetchSlots: (refetch: () => Promise<void>) => void; setRefetchSlots: (refetch: () => Promise<void>) => void;
} }
@ -27,20 +25,15 @@ export const useCentralSlotStore = create<CentralSlotState>((set) => ({
slots: [], slots: [],
productSlotsMap: {}, productSlotsMap: {},
refetchSlots: null, refetchSlots: null,
setSlotsData: (slots, productAvailability, availability) => { setSlotsData: (slots, productAvailability) => {
const productSlotsMap: Record<number, ProductSlotInfo> = {}; const productSlotsMap: Record<number, ProductSlotInfo> = {};
const availabilityById: Record<number, AvailabilityEntry> = {};
availability.forEach((entry) => {
availabilityById[entry.id] = entry;
});
// First, create entries for ALL products from productAvailability // First, create entries for ALL products from productAvailability
productAvailability.forEach((product) => { productAvailability.forEach((product) => {
productSlotsMap[product.id] = { productSlotsMap[product.id] = {
slots: [], slots: [],
isOutOfStock: availabilityById[product.id]?.isOutOfStock ?? false, isOutOfStock: product.isOutOfStock,
isFlashAvailable: availabilityById[product.id]?.isFlashAvailable ?? false, isFlashAvailable: product.isFlashAvailable,
isSuspended: availabilityById[product.id]?.isSuspended ?? false,
}; };
}); });
@ -61,15 +54,14 @@ export const useCentralSlotStore = create<CentralSlotState>((set) => ({
export function useInitializeCentralSlotStore() { export function useInitializeCentralSlotStore() {
const { data: slotsData, refetch } = useSlots(); const { data: slotsData, refetch } = useSlots();
const { data: availabilityData } = useAvailability();
const setSlotsData = useCentralSlotStore((state) => state.setSlotsData); const setSlotsData = useCentralSlotStore((state) => state.setSlotsData);
const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots); const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots);
useEffect(() => { useEffect(() => {
if (slotsData?.slots) { if (slotsData?.slots) {
setSlotsData(slotsData.slots, slotsData.productAvailability || [], availabilityData?.availability || []); setSlotsData(slotsData.slots, slotsData.productAvailability || []);
} }
}, [slotsData, availabilityData, setSlotsData]); }, [slotsData, setSlotsData]);
useEffect(() => { useEffect(() => {
setRefetchSlots(async () => { setRefetchSlots(async () => {

View file

@ -4,7 +4,6 @@ import { BottomDialog, p, div, Quantifier } from 'web-components'
import { useSlots } from '../hooks/prominent-api-hooks' import { useSlots } from '../hooks/prominent-api-hooks'
import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks' import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks'
import { useCartStore } from '../lib/stores/cart-store' import { useCartStore } from '../lib/stores/cart-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { ShoppingCart, Truck, Zap, X } from 'lucide-react' import { ShoppingCart, Truck, Zap, X } from 'lucide-react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -30,7 +29,6 @@ export default function AddToCartDialog() {
const { data: slotsData } = useSlots() const { data: slotsData } = useSlots()
const { data: cartData } = useGetCart() const { data: cartData } = useGetCart()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
const isFlashDeliveryEnabled = true const isFlashDeliveryEnabled = true
const addToCart = useAddToCart('regular') const addToCart = useAddToCart('regular')
@ -78,7 +76,7 @@ export default function AddToCartDialog() {
const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id) const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id)
const isUpdate = (cartItem?.quantity || 0) >= 1 const isUpdate = (cartItem?.quantity || 0) >= 1
const productAvailability = productSlotsMap[product?.id] const productAvailability = slotsData?.productAvailability?.find((pa: any) => pa.id === product?.id)
const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled
const handleAddToCart = () => { const handleAddToCart = () => {

View file

@ -1,10 +1,8 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import type { import type {
AllProductsApiType, AllProductsApiType,
AvailabilityApiType,
StoresApiType, StoresApiType,
SlotsApiType, SlotsApiType,
EssentialConstsApiType, EssentialConstsApiType,
@ -25,17 +23,6 @@ type StoresResponse = StoresApiType
type SlotsResponse = SlotsApiType type SlotsResponse = SlotsApiType
type BannersResponse = BannersApiType type BannersResponse = BannersApiType
type StoreWithProductsResponse = StoreWithProductsApiType type StoreWithProductsResponse = StoreWithProductsApiType
type AvailabilityResponse = AvailabilityApiType
type BaseProduct = AllProductsApiType['products'][number]
type AvailabilityEntry = AvailabilityApiType['availability'][number]
export type MergedProduct = BaseProduct & {
price: number
marketPrice: number | null
flashPrice: string | null
isFlashAvailable: boolean
}
function useCacheUrl(filename: string): string | null { function useCacheUrl(filename: string): string | null {
const { data: essentialConsts } = useGetEssentialConsts() const { data: essentialConsts } = useGetEssentialConsts()
@ -56,37 +43,10 @@ function useCacheUrl(filename: string): string | null {
return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}`
} }
function useAvailabilityCacheUrl(): string | null {
const { data: essentialConsts } = useGetEssentialConsts()
const assetsDomain = essentialConsts?.assetsDomain
const availabilityVersionNum = essentialConsts?.availabilityVersionNum
if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) {
return null
}
return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}`
}
function useSlotsCacheUrl(): string | null {
const { data: essentialConsts } = useGetEssentialConsts()
const assetsDomain = essentialConsts?.assetsDomain
const slotsVersionNum = essentialConsts?.slotsVersionNum
if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) {
return null
}
return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}`
}
export function useAllProducts() { export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability()
const productsQuery = useQuery<ProductsResponse>({ return useQuery<ProductsResponse>({
queryKey: ['all-products', cacheUrl], queryKey: ['all-products', cacheUrl],
queryFn: async () => { queryFn: async () => {
if (!cacheUrl) { if (!cacheUrl) {
@ -98,55 +58,6 @@ export function useAllProducts() {
staleTime: 60000, staleTime: 60000,
enabled: !!cacheUrl, enabled: !!cacheUrl,
}) })
const mergedProducts = useMemo(() => {
const rawProducts = productsQuery.data?.products || []
const availabilityById: Record<number, AvailabilityEntry> = {}
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
availabilityById[entry.id] = entry
})
return rawProducts.map((product) => {
const availability = availabilityById[product.id]
return {
...product,
price: availability ? Number(availability.price) : 0,
marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null,
flashPrice: availability?.flashPrice ?? null,
isFlashAvailable: availability?.isFlashAvailable ?? false,
}
})
}, [productsQuery.data, availabilityData])
const mergedData = useMemo(() => {
if (!productsQuery.data) return undefined
return {
...productsQuery.data,
products: mergedProducts,
} as ProductsResponse & { products: MergedProduct[] }
}, [productsQuery.data, mergedProducts])
return {
...productsQuery,
data: mergedData,
}
}
export function useAvailability() {
const cacheUrl = useAvailabilityCacheUrl()
return useQuery<AvailabilityResponse>({
queryKey: ['availability', cacheUrl],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<AvailabilityResponse>(cacheUrl)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
})
} }
export function useStores() { export function useStores() {
@ -167,7 +78,7 @@ export function useStores() {
} }
export function useSlots() { export function useSlots() {
const cacheUrl = useSlotsCacheUrl() const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots)
return useQuery<SlotsResponse>({ return useQuery<SlotsResponse>({
queryKey: ['slots', cacheUrl], queryKey: ['slots', cacheUrl],

View file

@ -9,7 +9,13 @@ CREATE TABLE `product_skus` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`product_id` integer NOT NULL, `product_id` integer NOT NULL,
`name` text, `name` text,
`price` text NOT NULL,
`market_price` text,
`images` text, `images` text,
`is_out_of_stock` integer DEFAULT false NOT NULL,
`is_suspended` integer DEFAULT false NOT NULL,
`is_flash_available` integer DEFAULT false NOT NULL,
`flash_price` text,
`is_offer` integer DEFAULT false NOT NULL, `is_offer` integer DEFAULT false NOT NULL,
`is_combo_only` integer DEFAULT false NOT NULL, `is_combo_only` integer DEFAULT false NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
@ -28,12 +34,19 @@ CREATE UNIQUE INDEX `unique_sku_feature_name` ON `sku_features` (`sku_id`,`featu
-- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature. -- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature.
INSERT INTO `product_skus` ( INSERT INTO `product_skus` (
`product_id`, `name`, `images`, `created_at` `product_id`, `name`, `price`, `market_price`, `images`, `is_out_of_stock`,
`is_suspended`, `is_flash_available`, `flash_price`, `created_at`
) )
SELECT SELECT
`id`, `id`,
NULL, NULL,
`price`,
`market_price`,
`images`, `images`,
`is_out_of_stock`,
`is_suspended`,
`is_flash_available`,
`flash_price`,
`created_at` `created_at`
FROM `product_info`; FROM `product_info`;
@ -46,33 +59,6 @@ FROM `product_info` `pi`
JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id`
LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`; LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`;
-- 2b. Create product_market_stats to hold pricing/flash/stock per SKU, and backfill it.
CREATE TABLE `product_market_stats` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`sku_id` integer NOT NULL,
`market_price` text,
`our_price` text NOT NULL,
`is_flash_available` integer DEFAULT false NOT NULL,
`flash_price` text,
`is_out_of_stock` integer DEFAULT false NOT NULL,
`is_suspended` integer DEFAULT false NOT NULL,
FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action
);
CREATE UNIQUE INDEX `product_market_stats_sku_id_unique` ON `product_market_stats` (`sku_id`);
INSERT INTO `product_market_stats` (`sku_id`, `market_price`, `our_price`, `is_flash_available`, `flash_price`, `is_out_of_stock`, `is_suspended`)
SELECT
`ps`.`id`,
`pi`.`market_price`,
`pi`.`price`,
`pi`.`is_flash_available`,
`pi`.`flash_price`,
`pi`.`is_out_of_stock`,
`pi`.`is_suspended`
FROM `product_info` `pi`
JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id`;
-- 3. Build a product_id -> sku_id mapping for downstream tables. -- 3. Build a product_id -> sku_id mapping for downstream tables.
CREATE TABLE `__product_to_sku` ( CREATE TABLE `__product_to_sku` (
`product_id` integer PRIMARY KEY, `product_id` integer PRIMARY KEY,
@ -261,8 +247,5 @@ CREATE TABLE `product_combos` (
CREATE UNIQUE INDEX `unique_combo_sku_item` ON `product_combos` (`combo_sku_id`,`sku_id`); CREATE UNIQUE INDEX `unique_combo_sku_item` ON `product_combos` (`combo_sku_id`,`sku_id`);
-- 11. Add sort_order to product_tag_info for curated product ordering per tag.
ALTER TABLE `product_tag_info` ADD COLUMN `sort_order` text DEFAULT '[]';
-- PRAGMA foreign_keys=ON; -- PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys = off; PRAGMA defer_foreign_keys = off;

View file

@ -32,10 +32,6 @@ export {
upsertConstants, upsertConstants,
getCacheVersion, getCacheVersion,
incrementCacheVersion, incrementCacheVersion,
getAvailabilityVersionNum,
incrementAvailabilityVersionNum,
getSlotsVersionNum,
incrementSlotsVersionNum,
} from './src/admin-apis/const' } from './src/admin-apis/const'
export { export {
@ -86,7 +82,6 @@ export {
createSpecialDealsForSku, createSpecialDealsForSku,
updateSkuDeals, updateSkuDeals,
replaceProductTags, replaceProductTags,
replaceTagProducts,
mergeSkus, mergeSkus,
updateSlotProducts, updateSlotProducts,
getSlotsProductIds, getSlotsProductIds,
@ -319,14 +314,12 @@ export {
type BannerData, type BannerData,
// Product Store // Product Store
getAllProductsForCache, getAllProductsForCache,
getAvailabilityForCache,
getAllStoresForCache, getAllStoresForCache,
getAllDeliverySlotsForCache, getAllDeliverySlotsForCache,
getAllSpecialDealsForCache, getAllSpecialDealsForCache,
getAllProductTagsForCache, getAllProductTagsForCache,
getAllProductCombosForCache, getAllProductCombosForCache,
type ProductBasicData, type ProductBasicData,
type AvailabilityCacheData,
type StoreBasicData, type StoreBasicData,
type DeliverySlotData, type DeliverySlotData,
type SpecialDealData, type SpecialDealData,

View file

@ -76,69 +76,3 @@ export async function incrementCacheVersion(): Promise<number> {
return nextValue return nextValue
}) })
} }
const AVAILABILITY_VERSION_KEY = CONST_KEYS.availabilityVersionNum
export async function getAvailabilityVersionNum(): Promise<number> {
const record = await db.query.keyValStore.findFirst({
where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY),
columns: { value: true },
})
return record ? parseCacheVersion(record.value) : 0
}
export async function incrementAvailabilityVersionNum(): Promise<number> {
return db.transaction(async (tx) => {
const existing = await tx.query.keyValStore.findFirst({
where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY),
columns: { value: true },
})
const nextValue = parseCacheVersion(existing?.value) + 1
if (existing) {
await tx.update(keyValStore)
.set({ value: nextValue +'' })
.where(eq(keyValStore.key, AVAILABILITY_VERSION_KEY))
} else {
await tx.insert(keyValStore)
.values({ key: AVAILABILITY_VERSION_KEY, value: nextValue+'' })
}
return nextValue
})
}
const SLOTS_VERSION_KEY = CONST_KEYS.slotsVersionNum
export async function getSlotsVersionNum(): Promise<number> {
const record = await db.query.keyValStore.findFirst({
where: eq(keyValStore.key, SLOTS_VERSION_KEY),
columns: { value: true },
})
return record ? parseCacheVersion(record.value) : 0
}
export async function incrementSlotsVersionNum(): Promise<number> {
return db.transaction(async (tx) => {
const existing = await tx.query.keyValStore.findFirst({
where: eq(keyValStore.key, SLOTS_VERSION_KEY),
columns: { value: true },
})
const nextValue = parseCacheVersion(existing?.value) + 1
if (existing) {
await tx.update(keyValStore)
.set({ value: nextValue +'' })
.where(eq(keyValStore.key, SLOTS_VERSION_KEY))
} else {
await tx.insert(keyValStore)
.values({ key: SLOTS_VERSION_KEY, value: nextValue+'' })
}
return nextValue
})
}

View file

@ -1,7 +1,7 @@
// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { import {
productInfo, productInfo,
productMarketStats,
productSkus, productSkus,
skuFeatures, skuFeatures,
productCombos, productCombos,
@ -45,42 +45,7 @@ import type {
type ProductRow = InferSelectModel<typeof productInfo> type ProductRow = InferSelectModel<typeof productInfo>
type SkuRow = InferSelectModel<typeof productSkus> type SkuRow = InferSelectModel<typeof productSkus>
type MarketStatsRow = InferSelectModel<typeof productMarketStats>
type SkuFeatureRow = InferSelectModel<typeof skuFeatures> type SkuFeatureRow = InferSelectModel<typeof skuFeatures>
interface CreateSkuFeatureInput {
featureName?: string | null
featureValue: string
}
interface CreateComboItemInput {
skuId: number
}
interface CreateSkuInput {
name?: string | null
price: number
marketPrice?: number | null
images?: string[] | null
isFlashAvailable?: boolean
flashPrice?: number | null
isOutOfStock?: boolean
isSuspended?: boolean
isOffer?: boolean
isComboOnly?: boolean
features: CreateSkuFeatureInput[]
comboItems?: CreateComboItemInput[]
}
interface CreateProductInput {
name: string
shortDescription?: string | null
longDescription?: string | null
storeId?: number | null
incrementStep?: number
productType?: 'item' | 'combo'
skus: CreateSkuInput[]
}
type UnitRow = InferSelectModel<typeof units> type UnitRow = InferSelectModel<typeof units>
type StoreRow = InferSelectModel<typeof storeInfo> type StoreRow = InferSelectModel<typeof storeInfo>
type SpecialDealRow = InferSelectModel<typeof specialDeals> type SpecialDealRow = InferSelectModel<typeof specialDeals>
@ -129,23 +94,18 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
featureValue: feature.featureValue, featureValue: feature.featureValue,
}) })
const mapSku = ( const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({
sku: SkuRow,
features: SkuFeatureRow[] = [],
comboItems: any[] = [],
marketStats: MarketStatsRow | null = null
): AdminSku => ({
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
name: sku.name ?? null, name: sku.name ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
images: getStringArray(sku.images), images: getStringArray(sku.images),
imageKeys: getStringArray(sku.images), imageKeys: getStringArray(sku.images),
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
isSuspended: marketStats?.isSuspended ?? false, isSuspended: sku.isSuspended,
isFlashAvailable: marketStats?.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable,
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
createdAt: sku.createdAt, createdAt: sku.createdAt,
@ -168,14 +128,13 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({
imageUrl: tag.imageUrl ?? null, imageUrl: tag.imageUrl ?? null,
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: tag.relatedStores, relatedStores: tag.relatedStores,
sortOrder: tag.sortOrder ?? [],
createdAt: tag.createdAt, createdAt: tag.createdAt,
}) })
export async function getAllProducts(): Promise<AdminProductWithRelations[]> { export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
type ProductWithRelationsRow = ProductRow & { type ProductWithRelationsRow = ProductRow & {
store: StoreRow | null store: StoreRow | null
skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[]; marketStats: MarketStatsRow | null }> skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[] }>
} }
const products = await db.query.productInfo.findMany({ const products = await db.query.productInfo.findMany({
orderBy: productInfo.name, orderBy: productInfo.name,
@ -184,10 +143,9 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
skus: { skus: {
with: { with: {
features: true, features: true,
marketStats: true,
comboItems: { comboItems: {
with: { with: {
sku: { with: { product: true, features: true, marketStats: true } }, sku: { with: { product: true, features: true } },
}, },
}, },
}, },
@ -205,9 +163,9 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })),
productName: ci.sku?.product?.name ?? 'Unknown', productName: ci.sku?.product?.name ?? 'Unknown',
images: getStringArray(ci.sku?.images), images: getStringArray(ci.sku?.images),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', price: String(ci.sku?.price ?? '0'),
})) }))
return mapSku(sku, sku.features, comboItems, sku.marketStats) return mapSku(sku, sku.features, comboItems)
}), }),
})) }))
} }
@ -220,10 +178,9 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
skus: { skus: {
with: { with: {
features: true, features: true,
marketStats: true,
comboItems: { comboItems: {
with: { with: {
sku: { with: { product: true, features: true, marketStats: true } }, sku: { with: { product: true, features: true } },
}, },
}, },
}, },
@ -255,12 +212,12 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
const comboItems = (sku.comboItems || []).map((ci: any) => ({ const comboItems = (sku.comboItems || []).map((ci: any) => ({
skuId: ci.skuId, skuId: ci.skuId,
skuName: ci.sku?.name ?? null, skuName: ci.sku?.name ?? null,
features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
productName: ci.sku?.product?.name ?? 'Unknown', productName: ci.sku?.product?.name ?? 'Unknown',
images: getStringArray(ci.sku?.images), images: getStringArray(ci.sku?.images),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', price: String(ci.sku?.price ?? '0'),
})) }))
return mapSku(sku, sku.features, comboItems, sku.marketStats) return mapSku(sku, sku.features, comboItems)
}) })
return { return {
@ -315,26 +272,20 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
skus.map((sku) => ({ skus.map((sku) => ({
productId: product.id, productId: product.id,
name: sku.name ?? null, name: sku.name ?? null,
price: String(sku.price),
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
images: sku.images ?? null, images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false,
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()
for (let i = 0; i < skuRows.length; i++) { for (let i = 0; i < skuRows.length; i++) {
const skuRow = skuRows[i] const skuRow = skuRows[i]
const sku = skus[i] const sku = skus[i]
await db.insert(productMarketStats).values({
skuId: skuRow.id,
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
ourPrice: sku.price != null ? String(sku.price) : '0',
isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOutOfStock: sku.isOutOfStock ?? false,
isSuspended: sku.isSuspended ?? false,
})
await db.insert(skuFeatures).values( await db.insert(skuFeatures).values(
sku.features.map((f) => ({ sku.features.map((f) => ({
skuId: skuRow.id, skuId: skuRow.id,
@ -355,13 +306,13 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
const createdSkus = await db.query.productSkus.findMany({ const createdSkus = await db.query.productSkus.findMany({
where: eq(productSkus.productId, product.id), where: eq(productSkus.productId, product.id),
with: { features: true, marketStats: true }, with: { features: true },
}) })
return { return {
...mapProduct(product), ...mapProduct(product),
store: null, store: null,
skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), skus: createdSkus.map((s) => mapSku(s, s.features)),
} }
} }
@ -417,11 +368,11 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId))) const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId)))
if (comboIds.length > 0) { if (comboIds.length > 0) {
const combos = await db.query.productMarketStats.findMany({ const combos = await db.query.productSkus.findMany({
where: inArray(productMarketStats.skuId, comboIds), where: inArray(productSkus.id, comboIds),
columns: { skuId: true, isSuspended: true }, columns: { id: true, isSuspended: true },
}) })
const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.skuId) const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id)
if (activeComboIds.length > 0) { if (activeComboIds.length > 0) {
throw new Error( throw new Error(
`Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` `Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended`
@ -436,34 +387,16 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
await db.update(productSkus) await db.update(productSkus)
.set({ .set({
name: sku.name ?? null, name: sku.name ?? null,
images: sku.images ?? null, price: String(sku.price),
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
})
.where(eq(productSkus.id, sku.id))
const existingMarketStats = await db.query.productMarketStats.findFirst({
where: eq(productMarketStats.skuId, sku.id),
columns: { id: true },
})
const marketStatsValues = {
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
ourPrice: sku.price != null ? String(sku.price) : '0', images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOutOfStock: sku.isOutOfStock ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false, isSuspended: sku.isSuspended ?? false,
}
if (existingMarketStats) {
await db.update(productMarketStats)
.set(marketStatsValues)
.where(eq(productMarketStats.skuId, sku.id))
} else {
await db.insert(productMarketStats).values({
skuId: sku.id,
...marketStatsValues,
}) })
} .where(eq(productSkus.id, sku.id))
await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id))
await db.insert(skuFeatures).values( await db.insert(skuFeatures).values(
@ -488,20 +421,15 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
const [newSku] = await db.insert(productSkus).values({ const [newSku] = await db.insert(productSkus).values({
productId: id, productId: id,
name: sku.name ?? null, name: sku.name ?? null,
images: sku.images ?? null, price: String(sku.price),
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
}).returning()
await db.insert(productMarketStats).values({
skuId: newSku.id,
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
ourPrice: sku.price != null ? String(sku.price) : '0', images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOutOfStock: sku.isOutOfStock ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false, isSuspended: sku.isSuspended ?? false,
}) }).returning()
await db.insert(skuFeatures).values( await db.insert(skuFeatures).values(
sku.features.map((f: any) => ({ sku.features.map((f: any) => ({
@ -519,7 +447,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
with: { with: {
store: true, store: true,
skus: { skus: {
with: { features: true, marketStats: true }, with: { features: true },
}, },
}, },
}) })
@ -531,7 +459,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
return { return {
...mapProduct(updatedProduct), ...mapProduct(updatedProduct),
store: updatedProduct.store ? mapStore(updatedProduct.store) : null, store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)), skus: updatedProduct.skus.map((s) => mapSku(s, s.features)),
} }
} }
@ -588,7 +516,6 @@ export async function getAllProductTags(): Promise<AdminProductTagWithProducts[]
assignedAt: assignment.assignedAt, assignedAt: assignment.assignedAt,
product: mapProduct(assignment.product), product: mapProduct(assignment.product),
})), })),
productIds: tag.products.map((assignment: ProductTagRow) => assignment.productId),
})) }))
} }
@ -618,7 +545,6 @@ export interface CreateProductTagInput {
imageUrl?: string | null imageUrl?: string | null
isDashboardTag?: boolean isDashboardTag?: boolean
relatedStores?: number[] relatedStores?: number[]
sortOrder?: number[]
} }
export async function createProductTag(input: CreateProductTagInput): Promise<AdminProductTagWithProducts> { export async function createProductTag(input: CreateProductTagInput): Promise<AdminProductTagWithProducts> {
@ -628,13 +554,11 @@ export async function createProductTag(input: CreateProductTagInput): Promise<Ad
imageUrl: input.imageUrl || null, imageUrl: input.imageUrl || null,
isDashboardTag: input.isDashboardTag || false, isDashboardTag: input.isDashboardTag || false,
relatedStores: input.relatedStores || [], relatedStores: input.relatedStores || [],
sortOrder: input.sortOrder || [],
}).returning() }).returning()
return { return {
...mapTagInfo(tag), ...mapTagInfo(tag),
products: [], products: [],
productIds: [],
} }
} }
@ -662,7 +586,6 @@ export async function getProductTagById(tagId: number): Promise<AdminProductTagW
assignedAt: assignment.assignedAt, assignedAt: assignment.assignedAt,
product: mapProduct(assignment.product), product: mapProduct(assignment.product),
})), })),
productIds: tag.products.map((assignment: ProductTagRow) => assignment.productId),
} }
} }
@ -672,7 +595,6 @@ export interface UpdateProductTagInput {
imageUrl?: string | null imageUrl?: string | null
isDashboardTag?: boolean isDashboardTag?: boolean
relatedStores?: number[] relatedStores?: number[]
sortOrder?: number[]
} }
export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise<AdminProductTagWithProducts> { export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise<AdminProductTagWithProducts> {
@ -682,7 +604,6 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }), ...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }),
...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }), ...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
}).where(eq(productTagInfo.id, tagId)).returning() }).where(eq(productTagInfo.id, tagId)).returning()
const fullTag = await db.query.productTagInfo.findFirst({ const fullTag = await db.query.productTagInfo.findFirst({
@ -704,7 +625,6 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp
assignedAt: assignment.assignedAt, assignedAt: assignment.assignedAt,
product: mapProduct(assignment.product), product: mapProduct(assignment.product),
})) || [], })) || [],
productIds: fullTag?.products.map((assignment: ProductTagRow) => assignment.productId) || [],
} }
} }
@ -978,32 +898,15 @@ export async function updateProductPrices(updates: Array<{
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update
const updateData: any = {} const updateData: any = {}
if (price !== undefined) updateData.ourPrice = price.toString() if (price !== undefined) updateData.price = price.toString()
if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString()
if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString()
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable
if (Object.keys(updateData).length === 0) continue
const existingMarketStats = await tx.query.productMarketStats.findFirst({
where: eq(productMarketStats.skuId, productId),
columns: { id: true },
})
if (existingMarketStats) {
await tx await tx
.update(productMarketStats) .update(productSkus)
.set(updateData) .set(updateData)
.where(eq(productMarketStats.skuId, productId)) .where(eq(productSkus.id, productId))
} else {
await tx.insert(productMarketStats).values({
skuId: productId,
ourPrice: updateData.ourPrice ?? '0',
marketPrice: updateData.marketPrice ?? null,
flashPrice: updateData.flashPrice ?? null,
isFlashAvailable: updateData.isFlashAvailable ?? false,
})
}
} }
}) })
@ -1053,7 +956,7 @@ export interface CreateSpecialDealInput {
} }
export async function createSpecialDealsForSku( export async function createSpecialDealsForSku(
skuId: number, productId: number,
deals: CreateSpecialDealInput[] deals: CreateSpecialDealInput[]
): Promise<AdminSpecialDeal[]> { ): Promise<AdminSpecialDeal[]> {
if (deals.length === 0) { if (deals.length === 0) {
@ -1061,7 +964,7 @@ export async function createSpecialDealsForSku(
} }
const dealInserts = deals.map((deal) => ({ const dealInserts = deals.map((deal) => ({
skuId, productId,
quantity: deal.quantity.toString(), quantity: deal.quantity.toString(),
price: deal.price.toString(), price: deal.price.toString(),
validTill: new Date(deal.validTill), validTill: new Date(deal.validTill),
@ -1122,7 +1025,7 @@ export async function updateSkuDeals(
if (dealsToAdd.length > 0) { if (dealsToAdd.length > 0) {
const dealInserts = dealsToAdd.map((deal) => ({ const dealInserts = dealsToAdd.map((deal) => ({
skuId: productId, productId,
quantity: deal.quantity.toString(), quantity: deal.quantity.toString(),
price: deal.price.toString(), price: deal.price.toString(),
validTill: new Date(deal.validTill), validTill: new Date(deal.validTill),
@ -1156,21 +1059,6 @@ export async function replaceProductTags(productId: number, tagIds: number[]): P
await db.insert(productTags).values(tagAssociations) await db.insert(productTags).values(tagAssociations)
} }
export async function replaceTagProducts(tagId: number, productIds: number[]): Promise<void> {
await db.delete(productTags).where(eq(productTags.tagId, tagId))
if (productIds.length === 0) {
return
}
const productAssociations = productIds.map((productId) => ({
productId,
tagId,
}))
await db.insert(productTags).values(productAssociations)
}
export async function mergeSkus(fromSkuId: number, toSkuId: number) { export async function mergeSkus(fromSkuId: number, toSkuId: number) {
if (fromSkuId === toSkuId) { if (fromSkuId === toSkuId) {
return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} } return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} }

View file

@ -201,23 +201,18 @@ export const productSkus = sqliteTable('product_skus', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
productId: integer('product_id').notNull().references(() => productInfo.id), productId: integer('product_id').notNull().references(() => productInfo.id),
name: text(), name: text(),
price: numericText('price').notNull(),
marketPrice: numericText('market_price'),
images: jsonText<string[] | null>('images'), images: jsonText<string[] | null>('images'),
isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false),
isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false),
isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false),
flashPrice: numericText('flash_price'),
isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false), isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false),
isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false), isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false),
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}) })
export const productMarketStats = sqliteTable('product_market_stats', {
id: integer().primaryKey({ autoIncrement: true }),
skuId: integer('sku_id').notNull().references(() => productSkus.id).unique(),
marketPrice: numericText('market_price'),
ourPrice: numericText('our_price').notNull(),
isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false),
flashPrice: numericText('flash_price'),
isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false),
isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false),
})
export const skuFeatures = sqliteTable('sku_features', { export const skuFeatures = sqliteTable('sku_features', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
skuId: integer('sku_id').notNull().references(() => productSkus.id), skuId: integer('sku_id').notNull().references(() => productSkus.id),
@ -291,7 +286,6 @@ export const productTagInfo = sqliteTable('product_tag_info', {
imageUrl: text('image_url'), imageUrl: text('image_url'),
isDashboardTag: integer('is_dashboard_tag', { mode: 'boolean' }).notNull().default(false), isDashboardTag: integer('is_dashboard_tag', { mode: 'boolean' }).notNull().default(false),
relatedStores: jsonText<number[]>('related_stores').$defaultFn(() => []), relatedStores: jsonText<number[]>('related_stores').$defaultFn(() => []),
sortOrder: jsonText<number[]>('sort_order').$defaultFn(() => []),
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}) })
@ -596,7 +590,6 @@ export const productInfoRelations = relations(productInfo, ({ one, many }) => ({
export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({
product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }), product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }),
features: many(skuFeatures), features: many(skuFeatures),
marketStats: one(productMarketStats),
specialDeals: many(specialDeals), specialDeals: many(specialDeals),
orderItems: many(orderItems), orderItems: many(orderItems),
cartItems: many(cartItems), cartItems: many(cartItems),
@ -604,10 +597,6 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({
comboItems: many(productCombos, { relationName: 'comboSku' }), comboItems: many(productCombos, { relationName: 'comboSku' }),
})) }))
export const productMarketStatsRelations = relations(productMarketStats, ({ one }) => ({
sku: one(productSkus, { fields: [productMarketStats.skuId], references: [productSkus.id] }),
}))
export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({
sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }),
})) }))

View file

@ -13,8 +13,6 @@ export const CONST_KEYS = {
readableOrderId: 'readableOrderId', readableOrderId: 'readableOrderId',
versionNum: 'versionNum', versionNum: 'versionNum',
cacheVersion: 'cache_version', cacheVersion: 'cache_version',
availabilityVersionNum: 'availability_version_num',
slotsVersionNum: 'slots_version_num',
playStoreUrl: 'playStoreUrl', playStoreUrl: 'playStoreUrl',
appStoreUrl: 'appStoreUrl', appStoreUrl: 'appStoreUrl',
popularItems: 'popularItems', popularItems: 'popularItems',
@ -39,8 +37,6 @@ export const CONST_LABELS: Record<ConstKey, string> = {
readableOrderId: 'Readable Order ID', readableOrderId: 'Readable Order ID',
versionNum: 'Version Number', versionNum: 'Version Number',
'cache_version': 'Cache Version', 'cache_version': 'Cache Version',
availability_version_num: 'Availability Cache Version',
slots_version_num: 'Slots Cache Version',
playStoreUrl: 'Play Store URL', playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
@ -71,8 +67,6 @@ export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
readableOrderId: 'number', readableOrderId: 'number',
versionNum: 'string', versionNum: 'string',
'cache_version': 'number', 'cache_version': 'number',
availability_version_num: 'number',
slots_version_num: 'number',
playStoreUrl: 'string', playStoreUrl: 'string',
appStoreUrl: 'string', appStoreUrl: 'string',
popularItems: 'string', popularItems: 'string',
@ -97,8 +91,6 @@ export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
readableOrderId: false, readableOrderId: false,
versionNum: true, versionNum: true,
'cache_version': false, 'cache_version': false,
availability_version_num: false,
slots_version_num: false,
playStoreUrl: true, playStoreUrl: true,
appStoreUrl: true, appStoreUrl: true,
popularItems: true, popularItems: true,

View file

@ -5,7 +5,6 @@ import { db } from '../db/db_index'
import { import {
homeBanners, homeBanners,
productInfo, productInfo,
productMarketStats,
productSkus, productSkus,
skuFeatures, skuFeatures,
deliverySlotInfo, deliverySlotInfo,
@ -62,16 +61,6 @@ export interface ProductBasicData {
productType: string productType: string
} }
export interface AvailabilityCacheData {
id: number
price: string
marketPrice: string | null
flashPrice: string | null
isFlashAvailable: boolean
isOutOfStock: boolean
isSuspended: boolean
}
export interface StoreBasicData { export interface StoreBasicData {
id: number id: number
name: string name: string
@ -118,18 +107,15 @@ 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,
marketStats: true,
}, },
}) })
return skus return skus.map((sku) => {
.filter((sku) => !sku.marketStats?.isSuspended)
.map((sku) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
@ -137,37 +123,21 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
longDescription: sku.product?.longDescription ?? null, longDescription: sku.product?.longDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
images: sku.images, images: sku.images,
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
storeId: sku.product?.storeId ?? null, storeId: sku.product?.storeId ?? null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
productQuantity: 1, productQuantity: 1,
isFlashAvailable: marketStats?.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable,
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
productType: sku.product?.productType ?? 'item', productType: sku.product?.productType ?? 'item',
} }
}) })
} }
export async function getAvailabilityForCache(): Promise<AvailabilityCacheData[]> {
const stats = await db.query.productMarketStats.findMany({})
return stats
.filter((stat) => !stat.isSuspended)
.map((stat) => ({
id: stat.skuId,
price: stat.ourPrice ? String(stat.ourPrice) : '0',
marketPrice: stat.marketPrice ? String(stat.marketPrice) : null,
flashPrice: stat.flashPrice ? String(stat.flashPrice) : null,
isFlashAvailable: stat.isFlashAvailable,
isOutOfStock: stat.isOutOfStock,
isSuspended: stat.isSuspended,
}))
}
export async function getAllStoresForCache(): Promise<StoreBasicData[]> { export async function getAllStoresForCache(): Promise<StoreBasicData[]> {
return db.query.storeInfo.findMany({ return db.query.storeInfo.findMany({
columns: { id: true, name: true, description: true }, columns: { id: true, name: true, description: true },
@ -222,21 +192,20 @@ export interface ProductComboCacheData {
images: unknown images: unknown
unitNotation: string unitNotation: string
price: string price: string
isOffer: boolean
} }
export async function getAllProductCombosForCache(): Promise<ProductComboCacheData[]> { export async function getAllProductCombosForCache(): Promise<ProductComboCacheData[]> {
const results = await db.query.productCombos.findMany({ const results = await db.query.productCombos.findMany({
with: { with: {
sku: { with: { product: true, features: true, marketStats: true } }, sku: { with: { product: true, features: true } },
}, },
}) })
const suspendedSkuIds = new Set( const suspendedSkuIds = new Set(
(await db (await db
.select({ id: productMarketStats.skuId }) .select({ id: productSkus.id })
.from(productMarketStats) .from(productSkus)
.where(eq(productMarketStats.isSuspended, true))).map((r) => r.id) .where(eq(productSkus.isSuspended, true))).map((r) => r.id)
) )
return results return results
@ -250,8 +219,7 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
skuName: ci.sku?.name ?? null, skuName: ci.sku?.name ?? null,
images: ci.sku?.images, images: ci.sku?.images,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', price: String(ci.sku?.price ?? '0'),
isOffer: ci.sku?.isOffer ?? false,
} }
}) })
} }
@ -267,7 +235,6 @@ export interface TagBasicData {
imageUrl: string | null imageUrl: string | null
isDashboardTag: boolean isDashboardTag: boolean
relatedStores: unknown relatedStores: unknown
sortOrder: number[] | null
} }
export interface TagProductMapping { export interface TagProductMapping {
@ -284,7 +251,6 @@ export async function getAllTagsForCache(): Promise<TagBasicData[]> {
imageUrl: productTagInfo.imageUrl, imageUrl: productTagInfo.imageUrl,
isDashboardTag: productTagInfo.isDashboardTag, isDashboardTag: productTagInfo.isDashboardTag,
relatedStores: productTagInfo.relatedStores, relatedStores: productTagInfo.relatedStores,
sortOrder: productTagInfo.sortOrder,
}) })
.from(productTagInfo) .from(productTagInfo)
} }
@ -351,15 +317,15 @@ 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 },
}, },
features: true, features: true,
marketStats: true,
}, },
}) })
skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.marketStats?.isSuspended) skusData = skusData.filter((item: any) => skuIdSet.has(item.id))
} }
const skuMap = new Map(skusData.map((s: any) => [s.id, s])) const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
@ -375,7 +341,6 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
.filter((p): p is NonNullable<typeof p> => p != null) .filter((p): p is NonNullable<typeof p> => p != null)
.map((sku: any) => { .map((sku: any) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
@ -383,8 +348,8 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
skuName: sku.name ?? null, skuName: sku.name ?? null,
productQuantity: 1, productQuantity: 1,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
store: sku.product?.store ? { store: sku.product?.store ? {
id: sku.product.store.id, id: sku.product.store.id,
@ -392,10 +357,10 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
description: sku.product.store.description description: sku.product.store.description
} : null, } : null,
images: sku.images, images: sku.images,
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
storeId: sku.product?.storeId ?? null, storeId: sku.product?.storeId ?? null,
isFlashAvailable: marketStats?.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable,
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
} }
}), }),
})) as SlotWithProductsData[] })) as SlotWithProductsData[]

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { deliverySlotInfo, productInfo, productCombos, productSkus, productMarketStats, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema'
import { and, desc, eq, gt, sql } from 'drizzle-orm' import { and, desc, eq, gt, sql } from 'drizzle-orm'
import type { UserProductDetailData, UserProductReview } from '@packages/shared' import type { UserProductDetailData, UserProductReview } from '@packages/shared'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features' import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
@ -11,24 +11,19 @@ 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,
marketStats: true,
}, },
}) })
if (!sku) { if (!sku) {
return null return null
} }
if (sku.marketStats?.isSuspended) {
return null
}
const features = sku.features || [] const features = sku.features || []
const product = sku.product const product = sku.product
const marketStats = sku.marketStats
const storeData = product?.storeId ? await db.query.storeInfo.findFirst({ const storeData = product?.storeId ? await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, product.storeId), where: eq(storeInfo.id, product.storeId),
@ -53,7 +48,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
const comboItemsData = await db.query.productCombos.findMany({ const comboItemsData = await db.query.productCombos.findMany({
where: eq(productCombos.comboSkuId, skuId), where: eq(productCombos.comboSkuId, skuId),
with: { with: {
sku: { with: { product: true, features: true, marketStats: true } }, sku: { with: { product: true, features: true } },
}, },
}) })
@ -65,8 +60,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
unitNotation: composeUnitNotation(ciFeatures), unitNotation: composeUnitNotation(ciFeatures),
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures), productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures),
images: getStringArray(ci.sku?.images), images: getStringArray(ci.sku?.images),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', price: String(ci.sku?.price ?? '0'),
isOffer: ci.sku?.isOffer ?? false,
} }
}) })
@ -76,11 +70,11 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
name: composeSkuName(product?.name ?? 'Unknown', features), name: composeSkuName(product?.name ?? 'Unknown', features),
shortDescription: product?.shortDescription ?? null, shortDescription: product?.shortDescription ?? null,
longDescription: product?.longDescription ?? null, longDescription: product?.longDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
store: storeData ? { store: storeData ? {
id: storeData.id, id: storeData.id,
name: storeData.name, name: storeData.name,
@ -88,8 +82,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
} : null, } : null,
incrementStep: product?.incrementStep ?? 1, incrementStep: product?.incrementStep ?? 1,
productQuantity: 1, productQuantity: 1,
isFlashAvailable: marketStats?.isFlashAvailable ?? false, isFlashAvailable: sku.isFlashAvailable,
flashPrice: marketStats?.flashPrice?.toString() || null, flashPrice: sku.flashPrice?.toString() || null,
deliverySlots: [], deliverySlots: [],
specialDeals: specialDealsData.map((deal) => ({ specialDeals: specialDealsData.map((deal) => ({
quantity: String(deal.quantity ?? '0'), quantity: String(deal.quantity ?? '0'),
@ -208,32 +202,30 @@ 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,
marketStats: true,
}, },
}) })
return skus return skus
.filter((sku) => { .filter((sku) => {
if (sku.marketStats?.isSuspended) return false
if (!tagId) return true if (!tagId) return true
return taggedProductIdSet.has(sku.productId) return taggedProductIdSet.has(sku.productId)
}) })
.map((sku) => { .map((sku) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.product?.id ?? 0, id: sku.product?.id ?? 0,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
skuId: sku.id, skuId: sku.id,
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
images: sku.images, images: sku.images,
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
unitShortNotation: composeUnitNotation(features), unitShortNotation: composeUnitNotation(features),
productQuantity: 1, productQuantity: 1,
features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
@ -246,9 +238,9 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
*/ */
export async function getSuspendedSkuIds(): Promise<number[]> { export async function getSuspendedSkuIds(): Promise<number[]> {
const suspendedSkus = await db const suspendedSkus = await db
.select({ id: productMarketStats.skuId }) .select({ id: productSkus.id })
.from(productMarketStats) .from(productSkus)
.where(eq(productMarketStats.isSuspended, true)) .where(eq(productSkus.isSuspended, true))
return suspendedSkus.map(sp => sp.id) return suspendedSkus.map(sp => sp.id)
} }
@ -287,18 +279,16 @@ 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,
marketStats: true,
product: { product: {
columns: { name: true }, columns: { name: true },
}, },
}, },
}) })
return skus return skus.map((sku) => {
.filter((sku) => !sku.marketStats?.isSuspended)
.map((sku) => {
const featureValues = (sku.features || []).map((f) => f.featureValue) const featureValues = (sku.features || []).map((f) => f.featureValue)
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
return { return {
@ -329,12 +319,10 @@ export interface OffersPageData {
const mapOffersPageProduct = (sku: { const mapOffersPageProduct = (sku: {
id: number id: number
marketStats: { price: string | null
ourPrice: string | null
marketPrice: string | null marketPrice: string | null
isOutOfStock: boolean
} | null
images: unknown images: unknown
isOutOfStock: boolean
product: { name: string; incrementStep: number | null } | null product: { name: string; incrementStep: number | null } | null
features: Array<{ featureValue: string }> features: Array<{ featureValue: string }>
}): OffersPageProductData => { }): OffersPageProductData => {
@ -342,21 +330,21 @@ const mapOffersPageProduct = (sku: {
return { return {
id: sku.id, id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: sku.images, images: sku.images,
isOutOfStock: sku.marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
} }
} }
export async function getOffersAndCombos(): Promise<OffersPageData> { export async function getOffersAndCombos(): Promise<OffersPageData> {
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,
marketStats: true,
}, },
}) })
@ -364,7 +352,6 @@ export async function getOffersAndCombos(): Promise<OffersPageData> {
const offers: OffersPageProductData[] = [] const offers: OffersPageProductData[] = []
for (const sku of skus) { for (const sku of skus) {
if (sku.marketStats?.isSuspended) continue
if (sku.product?.productType === 'combo') { if (sku.product?.productType === 'combo') {
combos.push(mapOffersPageProduct(sku)) combos.push(mapOffersPageProduct(sku))
} }

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { deliverySlotInfo, productMarketStats } from '../db/schema' import { deliverySlotInfo, productSkus } from '../db/schema'
import { asc, eq } from 'drizzle-orm' import { asc, eq } from 'drizzle-orm'
import type { InferSelectModel } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm'
import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared'
@ -27,16 +27,17 @@ export async function getActiveSlotsList(): Promise<UserDeliverySlot[]> {
} }
export async function getProductAvailability(): Promise<UserSlotAvailability[]> { export async function getProductAvailability(): Promise<UserSlotAvailability[]> {
const stats = await db.query.productMarketStats.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productMarketStats.isSuspended, false), where: eq(productSkus.isSuspended, false),
columns: { with: {
skuId: true, product: { columns: { name: true } },
isOutOfStock: true,
}, },
}) })
return stats.map((stat) => ({ return skus.map((sku) => ({
id: stat.skuId, id: sku.id,
isOutOfStock: stat.isOutOfStock, name: sku.product?.name ?? 'Unknown',
isOutOfStock: sku.isOutOfStock,
isFlashAvailable: sku.isFlashAvailable,
})) }))
} }

View file

@ -21,13 +21,13 @@ export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> {
}).from(storeInfo) }).from(storeInfo)
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
with: { product: true, marketStats: true }, where: eq(productSkus.isSuspended, false),
with: { product: true },
orderBy: asc(productSkus.id), orderBy: asc(productSkus.id),
}) })
const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended)
const skusByStore = new Map<number, typeof skus>() const skusByStore = new Map<number, typeof skus>()
for (const sku of activeSkus) { for (const sku of skus) {
const storeId = sku.product?.storeId const storeId = sku.product?.storeId
if (storeId == null) continue if (storeId == null) continue
if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) if (!skusByStore.has(storeId)) skusByStore.set(storeId, [])
@ -77,31 +77,30 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
const skus = productIdArr.length > 0 const skus = productIdArr.length > 0
? await db.query.productSkus.findMany({ ? await db.query.productSkus.findMany({
where: inArray(productSkus.productId, productIdArr), where: and(
inArray(productSkus.productId, productIdArr),
eq(productSkus.isSuspended, false)
),
with: { with: {
product: true, product: true,
features: true, features: true,
marketStats: true,
}, },
}) })
: [] : []
const products: UserStoreProductData[] = skus const products: UserStoreProductData[] = skus.map((sku) => {
.filter((sku) => !sku.marketStats?.isSuspended)
.map((sku) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', price: String(sku.price ?? '0'),
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
unit: composeUnitNotation(features), unit: composeUnitNotation(features),
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: marketStats?.isOutOfStock ?? false, isOutOfStock: sku.isOutOfStock,
productQuantity: 1, productQuantity: 1,
} }
}) })

View file

@ -2,7 +2,6 @@ export const CACHE_FILENAMES = {
products: 'products.json', products: 'products.json',
stores: 'stores.json', stores: 'stores.json',
slots: 'slots.json', slots: 'slots.json',
availability: 'availability.json',
essentialConsts: 'essential-consts.json', essentialConsts: 'essential-consts.json',
banners: 'banners.json', banners: 'banners.json',
} as const } as const

View file

@ -454,7 +454,6 @@ export interface AdminProductTagInfo {
imageUrl: string | null; imageUrl: string | null;
isDashboardTag: boolean; isDashboardTag: boolean;
relatedStores: unknown; relatedStores: unknown;
sortOrder: number[];
createdAt: Date; createdAt: Date;
} }
@ -467,7 +466,6 @@ export interface AdminProductTagAssignment {
export interface AdminProductTagWithProducts extends AdminProductTagInfo { export interface AdminProductTagWithProducts extends AdminProductTagInfo {
products: AdminProductTagAssignment[]; products: AdminProductTagAssignment[];
productIds: number[];
} }
export interface AdminSpecialDeal { export interface AdminSpecialDeal {

View file

@ -267,7 +267,6 @@ export interface UserProductComboItem {
productName: string; productName: string;
images: string[] | null; images: string[] | null;
price: string; price: string;
isOffer: boolean;
} }
export interface UserProductDetailData { export interface UserProductDetailData {
@ -321,34 +320,32 @@ export interface UserCreateReviewResponse {
export interface UserSlotProduct { export interface UserSlotProduct {
id: number; id: number;
images: string[] | null; name: string;
shortDescription: string | null;
productQuantity: number;
price: string;
marketPrice: string | null;
unit: string | null;
images: string[];
isOutOfStock: boolean;
storeId: number | null;
nextDeliveryDate: Date;
} }
export interface UserSlotWithProducts { export interface UserSlotWithProducts {
id: number; id: number;
deliveryTime: Date; deliveryTime: Date;
freezeTime: Date; freezeTime: Date;
isActive: boolean;
isCapacityFull: boolean;
products: UserSlotProduct[]; products: UserSlotProduct[];
} }
export interface UserSlotAvailability { export interface UserSlotAvailability {
id: number; id: number;
name: string;
isOutOfStock: boolean; isOutOfStock: boolean;
}
export interface UserAvailabilityEntry {
id: number;
price: string;
marketPrice: string | null;
flashPrice: string | null;
isFlashAvailable: boolean; isFlashAvailable: boolean;
isOutOfStock: boolean;
isSuspended: boolean;
}
export interface UserAvailabilityResponse {
availability: UserAvailabilityEntry[];
count: number;
} }
export interface UserDeliverySlot { export interface UserDeliverySlot {