Compare commits
2 commits
b41980736a
...
6a0bc18a8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0bc18a8d | ||
|
|
0ca746d3c6 |
43 changed files with 1678 additions and 442 deletions
|
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -463,8 +463,8 @@ 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) * item.productSize} {item.unit} × ₹{item.price}
|
{Number(item.quantity)} x {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
|
||||||
style={tw`flex-row items-center`}
|
style={tw`flex-row items-center`}
|
||||||
|
|
|
||||||
|
|
@ -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 * item.productSize } {item.unit}</MyText>
|
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity} x {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}
|
||||||
|
|
|
||||||
|
|
@ -402,15 +402,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ interface TagFormData {
|
||||||
tagDescription: string;
|
tagDescription: string;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores: number[];
|
relatedStores: number[];
|
||||||
|
productIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddTag() {
|
export default function AddTag() {
|
||||||
|
|
@ -45,6 +46,7 @@ export default function AddTag() {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
isDashboardTag: values.isDashboardTag,
|
isDashboardTag: values.isDashboardTag,
|
||||||
relatedStores: values.relatedStores,
|
relatedStores: values.relatedStores,
|
||||||
|
productIds: values.productIds,
|
||||||
uploadUrls,
|
uploadUrls,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -66,6 +68,7 @@ export default function AddTag() {
|
||||||
tagDescription: '',
|
tagDescription: '',
|
||||||
isDashboardTag: false,
|
isDashboardTag: false,
|
||||||
relatedStores: [],
|
relatedStores: [],
|
||||||
|
productIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface TagFormData {
|
||||||
tagDescription: string;
|
tagDescription: string;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores: number[];
|
relatedStores: number[];
|
||||||
|
productIds: number[];
|
||||||
existingImageUrl?: string;
|
existingImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +59,7 @@ export default function EditTag() {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
isDashboardTag: values.isDashboardTag,
|
isDashboardTag: values.isDashboardTag,
|
||||||
relatedStores: values.relatedStores,
|
relatedStores: values.relatedStores,
|
||||||
|
productIds: values.productIds,
|
||||||
uploadUrls,
|
uploadUrls,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -95,11 +97,16 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,18 @@ const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
|
||||||
|
|
||||||
interface TagHeaderProps {
|
interface TagHeaderProps {
|
||||||
onAddNewTag: () => void;
|
onAddNewTag: () => void;
|
||||||
|
onOrderTags: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag, onOrderTags }) => (
|
||||||
<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`}>
|
||||||
<MyText style={tw`text-xl font-bold text-gray-800`}>Product Tags</MyText>
|
<TouchableOpacity
|
||||||
|
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`}
|
||||||
|
|
@ -91,6 +98,10 @@ 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) {
|
||||||
|
|
@ -127,7 +138,7 @@ export default function ProductTags() {
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
}
|
}
|
||||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} />}
|
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />}
|
||||||
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`}>
|
||||||
|
|
|
||||||
369
apps/admin-ui/app/(drawer)/product-tags/order.tsx
Normal file
369
apps/admin-ui/app/(drawer)/product-tags/order.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet } 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;
|
||||||
|
|
@ -15,6 +20,7 @@ interface TagFormData {
|
||||||
tagDescription: string;
|
tagDescription: string;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores: number[];
|
relatedStores: number[];
|
||||||
|
productIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagFormProps {
|
interface TagFormProps {
|
||||||
|
|
@ -26,6 +32,13 @@ 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,
|
||||||
|
|
@ -37,10 +50,35 @@ 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));
|
||||||
|
|
@ -51,7 +89,6 @@ 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()
|
||||||
|
|
@ -62,21 +99,74 @@ 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, images, removedExisting)}
|
onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((p) => p.productId) }, images, removedExisting)}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
// Clear form when screen comes into focus (create mode only — edit keeps its loaded products)
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setImages([])
|
setImages([])
|
||||||
setRemovedExisting(false)
|
setRemovedExisting(false)
|
||||||
setIsDashboardTagChecked(false);
|
setIsDashboardTagChecked(false);
|
||||||
resetForm();
|
if (mode === 'create') {
|
||||||
}, [resetForm]);
|
setSelectedProducts([]);
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
}, [resetForm, mode]);
|
||||||
|
|
||||||
useFocusCallback(clearForm);
|
useFocusCallback(clearForm);
|
||||||
|
|
||||||
|
|
@ -106,7 +196,6 @@ 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[]) => {
|
||||||
|
|
@ -132,7 +221,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const newValue = !isDashboardTagChecked;
|
const newValue = !isDashboardTagChecked;
|
||||||
setIsDashboardTagChecked(newValue);
|
setIsDashboardTagChecked(newValue);
|
||||||
formikSetFieldValue('isDashboardTag', newValue);
|
setFieldValue('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>
|
||||||
|
|
@ -153,12 +242,53 @@ 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));
|
||||||
formikSetFieldValue('relatedStores', numericValues);
|
setFieldValue('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}
|
||||||
|
|
@ -175,6 +305,62 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Buffer } from 'buffer'
|
import { Buffer } from 'buffer'
|
||||||
import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common'
|
import { scaffoldProducts, scaffoldAvailability } 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 } from '@/src/dbService'
|
import { getStoresSummary, incrementCacheVersion, incrementAvailabilityVersionNum, incrementSlotsVersionNum } 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,16 +13,29 @@ 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
|
||||||
slots: string
|
slotsVersion: number
|
||||||
|
availabilityVersion: number
|
||||||
banners: string
|
banners: string
|
||||||
individualStores: string[]
|
individualStores: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -37,14 +50,16 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
productsKey,
|
productsKey,
|
||||||
essentialConstsKey,
|
essentialConstsKey,
|
||||||
storesKey,
|
storesKey,
|
||||||
slotsKey,
|
slotsVersion,
|
||||||
|
availabilityVersion,
|
||||||
bannersKey,
|
bannersKey,
|
||||||
individualStoreKeys,
|
individualStoreKeys,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
createProductsFileInternal(cacheVersion),
|
createProductsFileInternal(cacheVersion),
|
||||||
createEssentialConstsFileInternal(cacheVersion),
|
createEssentialConstsFileInternal(cacheVersion),
|
||||||
createStoresFileInternal(cacheVersion),
|
createStoresFileInternal(cacheVersion),
|
||||||
createSlotsFileInternal(cacheVersion),
|
createSlotsCacheFile(),
|
||||||
|
createAvailabilityCacheFile(),
|
||||||
createBannersFileInternal(cacheVersion),
|
createBannersFileInternal(cacheVersion),
|
||||||
createAllStoresFilesInternal(cacheVersion),
|
createAllStoresFilesInternal(cacheVersion),
|
||||||
])
|
])
|
||||||
|
|
@ -56,7 +71,8 @@ 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),
|
||||||
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
|
constructSlotsUrl(slotsVersion),
|
||||||
|
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)),
|
||||||
]
|
]
|
||||||
|
|
@ -76,7 +92,8 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
products: productsKey,
|
products: productsKey,
|
||||||
essentialConsts: essentialConstsKey,
|
essentialConsts: essentialConstsKey,
|
||||||
stores: storesKey,
|
stores: storesKey,
|
||||||
slots: slotsKey,
|
slotsVersion,
|
||||||
|
availabilityVersion,
|
||||||
banners: bannersKey,
|
banners: bannersKey,
|
||||||
individualStores: individualStoreKeys,
|
individualStores: individualStoreKeys,
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +115,23 @@ 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)
|
||||||
|
|
@ -120,15 +154,21 @@ async function createStoresFileInternal(version: number): Promise<string> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSlotsFileInternal(version: number): Promise<string> {
|
export async function createSlotsCacheFile(): Promise<number> {
|
||||||
|
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')
|
||||||
return await imageUploadS3(
|
const filePath = buildSlotsPath(version)
|
||||||
|
|
||||||
|
console.log(filePath)
|
||||||
|
await imageUploadS3(
|
||||||
buffer,
|
buffer,
|
||||||
'application/json',
|
'application/json',
|
||||||
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}`
|
filePath
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBannersFileInternal(version: number): Promise<string> {
|
async function createBannersFileInternal(version: number): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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',
|
||||||
|
|
@ -41,6 +42,7 @@ 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',
|
||||||
|
|
@ -71,6 +73,7 @@ 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',
|
||||||
|
|
@ -95,6 +98,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export {
|
||||||
createSpecialDealsForSku,
|
createSpecialDealsForSku,
|
||||||
updateSkuDeals,
|
updateSkuDeals,
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
|
replaceTagProducts,
|
||||||
mergeSkus,
|
mergeSkus,
|
||||||
updateSlotProducts,
|
updateSlotProducts,
|
||||||
getSlotsProductIds,
|
getSlotsProductIds,
|
||||||
|
|
@ -240,6 +241,7 @@ export {
|
||||||
// Store Helpers
|
// Store Helpers
|
||||||
getAllBannersForCache,
|
getAllBannersForCache,
|
||||||
getAllProductsForCache,
|
getAllProductsForCache,
|
||||||
|
getAvailabilityForCache,
|
||||||
getAllStoresForCache,
|
getAllStoresForCache,
|
||||||
getAllDeliverySlotsForCache,
|
getAllDeliverySlotsForCache,
|
||||||
getAllSpecialDealsForCache,
|
getAllSpecialDealsForCache,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ interface Product {
|
||||||
productName: string
|
productName: string
|
||||||
images: string[] | null
|
images: string[] | null
|
||||||
price: string
|
price: string
|
||||||
|
isOffer: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,6 +251,7 @@ 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({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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,
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
checkUnitExists,
|
checkUnitExists,
|
||||||
createProduct as createProductInDb,
|
createProduct as createProductInDb,
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
|
replaceTagProducts,
|
||||||
getProductImagesById,
|
getProductImagesById,
|
||||||
updateProduct as updateProductInDb,
|
updateProduct as updateProductInDb,
|
||||||
checkProductTagExistsByName,
|
checkProductTagExistsByName,
|
||||||
|
|
@ -28,6 +30,7 @@ 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,
|
||||||
|
|
@ -196,6 +199,7 @@ 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),
|
||||||
|
|
@ -225,6 +229,7 @@ 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,
|
||||||
|
|
@ -284,6 +289,7 @@ 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),
|
||||||
|
|
@ -315,6 +321,7 @@ 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,
|
||||||
|
|
@ -842,11 +849,13 @@ export const productRouter = router({
|
||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (result.invalidIds.length > 0) {
|
if (result.invalidIds.length > 0) {
|
||||||
throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400)
|
throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
await createAvailabilityCacheFile().catch((err) => {
|
||||||
|
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)`,
|
||||||
|
|
@ -873,8 +882,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; createdAt: Date }; message: string }> => {
|
.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 }> => {
|
||||||
const tag = await getProductTagInfoByIdInDb(input.id)
|
const tag = await getProductTagByIdInDb(input.id)
|
||||||
|
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
throw new ApiError('Tag not found', 404)
|
throw new ApiError('Tag not found', 404)
|
||||||
|
|
@ -898,10 +907,11 @@ 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; createdAt: Date }; message: string }> => {
|
.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 }> => {
|
||||||
const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input
|
||||||
|
|
||||||
const existingTag = await checkProductTagExistsByName(tagName.trim())
|
const existingTag = await checkProductTagExistsByName(tagName.trim())
|
||||||
if (existingTag) {
|
if (existingTag) {
|
||||||
|
|
@ -914,8 +924,11 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
@ -941,10 +954,11 @@ 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; createdAt: Date }; message: string }> => {
|
.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 }> => {
|
||||||
const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, productIds, uploadUrls } = input
|
||||||
|
|
||||||
const currentTag = await getProductTagInfoByIdInDb(id)
|
const currentTag = await getProductTagInfoByIdInDb(id)
|
||||||
|
|
||||||
|
|
@ -964,8 +978,13 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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,
|
||||||
|
|
@ -266,7 +267,9 @@ export const slotsRouter = router({
|
||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
await createSlotsCacheFile().catch((err) => {
|
||||||
|
console.error('Failed to regenerate slots cache after product update:', err)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
@ -360,8 +363,10 @@ export const slotsRouter = router({
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes (outside transaction)
|
// Regenerate slots cache file (availability/products stay as-is)
|
||||||
await scheduleStoreInitialization()
|
await createSlotsCacheFile().catch((error) => {
|
||||||
|
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) => {
|
||||||
|
|
@ -548,8 +553,10 @@ export const slotsRouter = router({
|
||||||
throw new ApiError('Slot not found', 404)
|
throw new ApiError('Slot not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes (outside transaction)
|
// Regenerate slots cache file (availability/products stay as-is)
|
||||||
await scheduleStoreInitialization()
|
await createSlotsCacheFile().catch((error) => {
|
||||||
|
console.error('Failed to regenerate slots cache after slot update:', error)
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -587,8 +594,10 @@ export const slotsRouter = router({
|
||||||
throw new ApiError('Slot not found', 404)
|
throw new ApiError('Slot not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes
|
// Regenerate slots cache file (availability/products stay as-is)
|
||||||
await scheduleStoreInitialization()
|
await createSlotsCacheFile().catch((error) => {
|
||||||
|
console.error('Failed to regenerate slots cache after slot delete:', error)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Slot deleted successfully',
|
message: 'Slot deleted successfully',
|
||||||
|
|
@ -736,7 +745,9 @@ export const slotsRouter = router({
|
||||||
throw new ApiError('Slot not found', 404)
|
throw new ApiError('Slot not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
await scheduleStoreInitialization()
|
await createSlotsCacheFile().catch((error) => {
|
||||||
|
console.error('Failed to regenerate slots cache after capacity update:', error)
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ 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';
|
||||||
|
|
@ -21,6 +23,8 @@ 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,
|
||||||
|
|
@ -40,6 +44,8 @@ export async function scaffoldEssentialConsts() {
|
||||||
assetsDomain: getAssetsDomain(),
|
assetsDomain: getAssetsDomain(),
|
||||||
apiCacheKey: getApiCacheKey(),
|
apiCacheKey: getApiCacheKey(),
|
||||||
cacheVersion,
|
cacheVersion,
|
||||||
|
availabilityVersionNum,
|
||||||
|
slotsVersionNum,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ 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
|
||||||
|
|
@ -45,18 +48,14 @@ 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'
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
@ -68,6 +67,28 @@ 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)) {
|
||||||
|
|
@ -76,14 +97,25 @@ export async function scaffoldProducts() {
|
||||||
productIdsByTag.get(mapping.tagId)!.push(mapping.productId)
|
productIdsByTag.get(mapping.tagId)!.push(mapping.productId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({
|
// Reorder each tag's product ids by the admin-defined sortOrder (unknown products go to the end).
|
||||||
|
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: productIdsByTag.get(tag.id) || [],
|
productIds: reorderProductIds(tag.id, tag.sortOrder ?? undefined),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -93,6 +125,15 @@ 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 () => {
|
||||||
|
|
|
||||||
|
|
@ -17,28 +17,16 @@ export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProducts
|
||||||
|
|
||||||
const productAvailability = await getUserProductAvailabilityInDb()
|
const productAvailability = await getUserProductAvailabilityInDb()
|
||||||
|
|
||||||
/*
|
|
||||||
// Old implementation - direct DB query:
|
|
||||||
const allProducts = await db
|
|
||||||
.select({
|
|
||||||
id: productInfo.id,
|
|
||||||
name: productInfo.name,
|
|
||||||
isOutOfStock: productInfo.isOutOfStock,
|
|
||||||
isFlashAvailable: productInfo.isFlashAvailable,
|
|
||||||
})
|
|
||||||
.from(productInfo)
|
|
||||||
.where(eq(productInfo.isSuspended, false));
|
|
||||||
|
|
||||||
const productAvailability = allProducts.map(product => ({
|
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
|
||||||
isOutOfStock: product.isOutOfStock,
|
|
||||||
isFlashAvailable: product.isFlashAvailable,
|
|
||||||
}));
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots: validSlots,
|
slots: validSlots.map((slot) => ({
|
||||||
|
id: slot.id,
|
||||||
|
deliveryTime: slot.deliveryTime,
|
||||||
|
freezeTime: slot.freezeTime,
|
||||||
|
products: (slot.products || []).map((product) => ({
|
||||||
|
id: product.id,
|
||||||
|
images: product.images,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
productAvailability,
|
productAvailability,
|
||||||
count: validSlots.length,
|
count: validSlots.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 } from './apis/common-apis/common';
|
import { scaffoldProducts, scaffoldAvailability } 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,6 +26,7 @@ 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>>;
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,16 @@ 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`:
|
This should exit 0 with no error. (Example already applied to `latest_1.sql` and
|
||||||
`product_info` was moved above `product_skus`.)
|
`local_8_aug.sql`: `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.
|
||||||
|
|
|
||||||
|
|
@ -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 = "0814d709-5278-4311-8978-c36c0f05875d"
|
database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa"
|
||||||
#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"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from "react";
|
import React, { useState, useCallback, useMemo, memo, useRef } 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 {
|
||||||
|
|
@ -25,7 +26,6 @@ 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,6 +37,14 @@ 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');
|
||||||
|
|
@ -53,7 +61,6 @@ 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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -91,15 +98,15 @@ const RenderStore = memo(({ item }: RenderStoreProps) => {
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
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`}
|
style={tw`w-16 h-16 rounded-2xl bg-brand50 border border-brand100 items-center justify-center mb-2 shadow-sm 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="#FFF" />
|
<MaterialIcons name="storefront" size={28} color={theme.colors.brand600} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<MyText style={tw`font-bold text-xs text-center tracking-wide drop-shadow-sm text-neutral-800`} numberOfLines={1}>
|
<MyText style={tw`font-bold text-xs text-center tracking-wide text-neutral-800`} numberOfLines={1}>
|
||||||
{item.name.replace(/^The\s+/i, "")}
|
{item.name.replace(/^The\s+/i, "")}
|
||||||
</MyText>
|
</MyText>
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
|
|
@ -125,7 +132,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-xl shadow-slate-200 border border-slate-100 min-w-[280px]`,
|
tw`bg-white rounded-[24px] p-5 mr-4 shadow-sm border border-gray-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}
|
||||||
|
|
@ -179,35 +186,15 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<MyText style={tw`text-[11px] font-bold text-brand600`}>View all {slot.products.length} items</MyText>
|
<View style={tw`flex-row items-center bg-brand50 rounded-full px-3 py-1.5`}>
|
||||||
<MaterialIcons name="chevron-right" size={16} color={theme.colors.brand600} />
|
<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} 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;
|
||||||
|
|
@ -226,7 +213,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
||||||
style={[
|
style={[
|
||||||
tw`text-base tracking-tight`,
|
tw`text-base tracking-tight`,
|
||||||
{
|
{
|
||||||
color: isSelected ? '#111827' : '#64748B',
|
color: isSelected ? theme.colors.brand600 : '#64748B',
|
||||||
fontWeight: isSelected ? '700' : '500',
|
fontWeight: isSelected ? '700' : '500',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
@ -239,7 +226,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
backgroundColor: isSelected ? '#111827' : 'transparent',
|
backgroundColor: isSelected ? theme.colors.brand500 : 'transparent',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -247,28 +234,141 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ExploreTabsRowProps {
|
interface TagTabViewProps {
|
||||||
|
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 ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => (
|
const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => {
|
||||||
<ScrollView
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
const handleSelect = (id: number) => {
|
||||||
contentContainerStyle={tw`gap-3 py-1 px-1`}
|
onSelectTag(id);
|
||||||
>
|
const idx = dashboardTags.findIndex((tag) => tag.id === id);
|
||||||
{dashboardTags.map((tag) => (
|
if (idx >= 0) {
|
||||||
<ExploreTab
|
scrollRef.current?.scrollTo({ x: idx * 96, animated: true });
|
||||||
key={tag.id}
|
}
|
||||||
tag={tag}
|
};
|
||||||
isSelected={activeTagId === tag.id}
|
|
||||||
onPress={() => onSelectTag(tag.id)}
|
return (
|
||||||
/>
|
<ScrollView
|
||||||
))}
|
ref={scrollRef}
|
||||||
</ScrollView>
|
horizontal
|
||||||
));
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={tw`gap-3 py-1 px-1`}
|
||||||
|
>
|
||||||
|
{dashboardTags.map((tag) => (
|
||||||
|
<ExploreTab key={tag.id} tag={tag} isSelected={activeTagId === tag.id} onPress={() => handleSelect(tag.id)} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface ExploreProductItemProps {
|
interface ExploreProductItemProps {
|
||||||
item: any;
|
item: any;
|
||||||
|
|
@ -323,12 +423,11 @@ 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;
|
||||||
activeTagProducts: any[];
|
productsByTagId: Record<number, any[]>;
|
||||||
onSelectTag: (id: number) => void;
|
onSelectTag: (id: number) => void;
|
||||||
onTabsSectionLayout: (layout: { y: number; height: number }) => void;
|
onTabsSectionLayout: (layout: { y: number; height: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -337,29 +436,19 @@ const ListHeader = memo(({
|
||||||
gradientHeight,
|
gradientHeight,
|
||||||
onGradientLayout,
|
onGradientLayout,
|
||||||
storesData,
|
storesData,
|
||||||
popularProducts,
|
|
||||||
sortedSlots,
|
sortedSlots,
|
||||||
onProductPress,
|
onProductPress,
|
||||||
dashboardTags,
|
dashboardTags,
|
||||||
activeTagId,
|
activeTagId,
|
||||||
activeTagProducts,
|
productsByTagId,
|
||||||
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} />
|
||||||
), []);
|
), []);
|
||||||
|
|
@ -370,16 +459,14 @@ 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: pageTint }}>
|
<View onLayout={handleLayout} style={{ backgroundColor: '#FFFFFF' }}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[pageTint, pageTint]}
|
colors={['#FFFFFF', '#FFFFFF']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0.5 }}
|
end={{ x: 0, y: 1 }}
|
||||||
style={gradientStyle}
|
style={gradientStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -390,59 +477,29 @@ const ListHeader = memo(({
|
||||||
onTabsSectionLayout({ y, height });
|
onTabsSectionLayout({ y, height });
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-2`,
|
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`,
|
||||||
{ backgroundColor: pageTint },
|
{ backgroundColor: theme.colors.brand25 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ExploreTabsRow
|
<TagTabView
|
||||||
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: pageTint },
|
{ backgroundColor: '#FFFFFF' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{storesData?.stores && storesData.stores.length > 0 && (
|
{storesData?.stores && storesData.stores.length > 0 && (
|
||||||
<View style={tw`mt-4 mb-5 rounded-[28px] bg-white/70 border border-white px-3 pt-4 pb-2`}>
|
<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`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`}>
|
||||||
|
|
@ -467,36 +524,15 @@ 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>
|
||||||
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Upcoming Delivery Slots</MyText>
|
<View style={tw`flex-row items-center mb-1.5`}>
|
||||||
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Plan your fresh deliveries ahead</MyText>
|
<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>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Plan your fresh deliveries ahead</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<MyFlatList
|
<MyFlatList
|
||||||
|
|
@ -516,8 +552,11 @@ 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>
|
||||||
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>All Available Products</MyText>
|
<View style={tw`flex-row items-center mb-1.5`}>
|
||||||
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse our complete selection</MyText>
|
<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>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Browse our complete selection</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -587,21 +626,6 @@ 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();
|
||||||
|
|
@ -614,31 +638,43 @@ export default function Dashboard() {
|
||||||
});
|
});
|
||||||
}, [slotsData]);
|
}, [slotsData]);
|
||||||
|
|
||||||
const popularProducts = useMemo(() => {
|
const productsByTagId = useMemo(() => {
|
||||||
return popularItemIds
|
const map: Record<number, any[]> = {};
|
||||||
.map(id => products.find(product => product.id === id))
|
for (const tag of dashboardTags) {
|
||||||
.filter((product): product is NonNullable<typeof product> => product != null);
|
const productById = new Map<number, any>();
|
||||||
}, [popularItemIds, products]);
|
for (const product of products) {
|
||||||
|
productById.set(product.id, product);
|
||||||
|
}
|
||||||
|
|
||||||
const activeTagProducts = useMemo(() => {
|
// tag.productIds is already in the admin-curated order (backend sorts by sortOrder).
|
||||||
if (activeTagId == null) return [];
|
const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id));
|
||||||
const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId);
|
const ordered: any[] = [];
|
||||||
if (!activeTag) return [];
|
const rest: any[] = [];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
return products
|
// Products in the tag's productIds but not in the curated order (added later) — availability sort.
|
||||||
.filter((product: any) => activeTag.productIds?.includes(product.id) ?? false)
|
const seen = new Set(orderedIds);
|
||||||
.sort((a: any, b: any) => {
|
const extra = products
|
||||||
const slotA = getQuickestSlot(a.id)
|
.filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.id))
|
||||||
const slotB = getQuickestSlot(b.id)
|
.sort((a: any, b: any) => {
|
||||||
|
const slotA = getQuickestSlot(a.id)
|
||||||
|
const slotB = getQuickestSlot(b.id)
|
||||||
|
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
|
||||||
|
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
|
||||||
|
if (aOutOfStock && !bOutOfStock) return 1
|
||||||
|
if (!aOutOfStock && bOutOfStock) return -1
|
||||||
|
return 0
|
||||||
|
});
|
||||||
|
|
||||||
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
|
map[tag.id] = [...ordered, ...rest, ...extra];
|
||||||
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
|
}
|
||||||
|
return map;
|
||||||
if (aOutOfStock && !bOutOfStock) return 1
|
}, [dashboardTags, products, getQuickestSlot, productSlotsMap]);
|
||||||
if (!aOutOfStock && bOutOfStock) return -1
|
|
||||||
return 0
|
|
||||||
});
|
|
||||||
}, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
|
|
@ -703,26 +739,25 @@ 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}
|
||||||
activeTagProducts={activeTagProducts}
|
productsByTagId={productsByTagId}
|
||||||
onSelectTag={setSelectedTagId}
|
onSelectTag={setSelectedTagId}
|
||||||
onTabsSectionLayout={handleTabsSectionLayout}
|
onTabsSectionLayout={handleTabsSectionLayout}
|
||||||
/>
|
/>
|
||||||
), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts, handleTabsSectionLayout]);
|
), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId, 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: pageTint }
|
{ backgroundColor: '#FFFFFF' }
|
||||||
], [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: pageTint }
|
{ backgroundColor: '#FFFFFF' }
|
||||||
], [pageTint]);
|
], [pageTint]);
|
||||||
|
|
||||||
const listContentContainerStyle = useMemo(() => [
|
const listContentContainerStyle = useMemo(() => [
|
||||||
|
|
@ -751,11 +786,8 @@ export default function Dashboard() {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let str = ''
|
|
||||||
displayedProducts.forEach(product => str += `${product.id}-`)
|
|
||||||
// console.log(str)
|
|
||||||
return (
|
return (
|
||||||
<TabLayoutWrapper style={{ backgroundColor: pageTint }}>
|
<TabLayoutWrapper style={{ backgroundColor: '#FFFFFF' }}>
|
||||||
<View style={pageTintStyle}>
|
<View style={pageTintStyle}>
|
||||||
<View
|
<View
|
||||||
style={searchBarContainerStyle}
|
style={searchBarContainerStyle}
|
||||||
|
|
@ -780,7 +812,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: pageTint }}
|
style={{ backgroundColor: '#FFFFFF' }}
|
||||||
onScroll={handleListScroll}
|
onScroll={handleListScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
contentContainerStyle={listContentContainerStyle}
|
contentContainerStyle={listContentContainerStyle}
|
||||||
|
|
@ -791,8 +823,8 @@ export default function Dashboard() {
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isRefreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
tintColor="#3b82f6"
|
tintColor={theme.colors.brand500}
|
||||||
colors={["#3b82f6"]}
|
colors={[theme.colors.brand500]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
|
|
@ -814,7 +846,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: pageTint,
|
backgroundColor: '#FFFFFF',
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
@ -822,10 +854,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: pageTint },
|
{ backgroundColor: theme.colors.brand25 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ExploreTabsRow
|
<StickyTabsRow
|
||||||
dashboardTags={dashboardTags}
|
dashboardTags={dashboardTags}
|
||||||
activeTagId={activeTagId}
|
activeTagId={activeTagId}
|
||||||
onSelectTag={setSelectedTagId}
|
onSelectTag={setSelectedTagId}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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';
|
||||||
|
|
@ -377,6 +378,54 @@ 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`}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useAllProducts } from '@/src/hooks/prominent-api-hooks';
|
import { useCentralProductStore } from '@/src/store/centralProductStore';
|
||||||
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 { data: products } = useAllProducts();
|
const productsById = useCentralProductStore((state) => state.productsById);
|
||||||
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(
|
||||||
products?.products?.map((p) => [
|
Object.values(productsById).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) && !!products,
|
enabled: (options?.enabled ?? true) && Object.keys(productsById).length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
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 } from "@backend/trpc/router";
|
import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType, AvailabilityApiType } from "@backend/trpc/router";
|
||||||
import { CACHE_FILENAMES } from "@packages/shared";
|
import { CACHE_FILENAMES } from "@packages/shared";
|
||||||
|
|
||||||
// Local useGetEssentialConsts hook
|
// Local useGetEssentialConsts hook
|
||||||
|
|
@ -18,6 +19,19 @@ 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()
|
||||||
|
|
@ -33,11 +47,37 @@ 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) {
|
||||||
|
|
@ -49,6 +89,57 @@ 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() {
|
||||||
|
|
@ -69,7 +160,7 @@ export function useStores() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSlots() {
|
export function useSlots() {
|
||||||
const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots)
|
const cacheUrl = useSlotsCacheUrl()
|
||||||
|
|
||||||
return useQuery<SlotsResponse>({
|
return useQuery<SlotsResponse>({
|
||||||
queryKey: ['slots', cacheUrl],
|
queryKey: ['slots', cacheUrl],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useAllProducts } from '@/src/hooks/prominent-api-hooks'
|
import { useAllProducts, type MergedProduct } from '@/src/hooks/prominent-api-hooks'
|
||||||
import { AllProductsApiType } from '@backend/trpc/router'
|
|
||||||
|
|
||||||
type Product = AllProductsApiType['products'][number]
|
export type Product = MergedProduct
|
||||||
|
|
||||||
interface CentralProductState {
|
interface CentralProductState {
|
||||||
products: Product[]
|
products: Product[]
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useSlots } from '@/src/hooks/prominent-api-hooks';
|
import { useSlots, useAvailability } from '@/src/hooks/prominent-api-hooks';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { SlotsApiType } from "@backend/trpc/router";
|
import { SlotsApiType, AvailabilityApiType } 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[]) => void;
|
setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[], availability: AvailabilityEntry[]) => void;
|
||||||
clearSlotsData: () => void;
|
clearSlotsData: () => void;
|
||||||
setRefetchSlots: (refetch: () => Promise<void>) => void;
|
setRefetchSlots: (refetch: () => Promise<void>) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -25,15 +27,20 @@ export const useCentralSlotStore = create<CentralSlotState>((set) => ({
|
||||||
slots: [],
|
slots: [],
|
||||||
productSlotsMap: {},
|
productSlotsMap: {},
|
||||||
refetchSlots: null,
|
refetchSlots: null,
|
||||||
setSlotsData: (slots, productAvailability) => {
|
setSlotsData: (slots, productAvailability, availability) => {
|
||||||
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: product.isOutOfStock,
|
isOutOfStock: availabilityById[product.id]?.isOutOfStock ?? false,
|
||||||
isFlashAvailable: product.isFlashAvailable,
|
isFlashAvailable: availabilityById[product.id]?.isFlashAvailable ?? false,
|
||||||
|
isSuspended: availabilityById[product.id]?.isSuspended ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,14 +61,15 @@ 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 || []);
|
setSlotsData(slotsData.slots, slotsData.productAvailability || [], availabilityData?.availability || []);
|
||||||
}
|
}
|
||||||
}, [slotsData, setSlotsData]);
|
}, [slotsData, availabilityData, setSlotsData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRefetchSlots(async () => {
|
setRefetchSlots(async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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'
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ 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')
|
||||||
|
|
@ -76,7 +78,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 = slotsData?.productAvailability?.find((pa: any) => pa.id === product?.id)
|
const productAvailability = productSlotsMap[product?.id]
|
||||||
const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled
|
const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
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,
|
||||||
|
|
@ -23,6 +25,17 @@ 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()
|
||||||
|
|
@ -43,10 +56,37 @@ 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()
|
||||||
|
|
||||||
return useQuery<ProductsResponse>({
|
const productsQuery = useQuery<ProductsResponse>({
|
||||||
queryKey: ['all-products', cacheUrl],
|
queryKey: ['all-products', cacheUrl],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!cacheUrl) {
|
if (!cacheUrl) {
|
||||||
|
|
@ -58,6 +98,55 @@ 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() {
|
||||||
|
|
@ -78,7 +167,7 @@ export function useStores() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSlots() {
|
export function useSlots() {
|
||||||
const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots)
|
const cacheUrl = useSlotsCacheUrl()
|
||||||
|
|
||||||
return useQuery<SlotsResponse>({
|
return useQuery<SlotsResponse>({
|
||||||
queryKey: ['slots', cacheUrl],
|
queryKey: ['slots', cacheUrl],
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,7 @@ 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,
|
||||||
|
|
@ -34,19 +28,12 @@ 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`, `price`, `market_price`, `images`, `is_out_of_stock`,
|
`product_id`, `name`, `images`, `created_at`
|
||||||
`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`;
|
||||||
|
|
||||||
|
|
@ -59,6 +46,33 @@ 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,
|
||||||
|
|
@ -247,5 +261,8 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ export {
|
||||||
upsertConstants,
|
upsertConstants,
|
||||||
getCacheVersion,
|
getCacheVersion,
|
||||||
incrementCacheVersion,
|
incrementCacheVersion,
|
||||||
|
getAvailabilityVersionNum,
|
||||||
|
incrementAvailabilityVersionNum,
|
||||||
|
getSlotsVersionNum,
|
||||||
|
incrementSlotsVersionNum,
|
||||||
} from './src/admin-apis/const'
|
} from './src/admin-apis/const'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -82,6 +86,7 @@ export {
|
||||||
createSpecialDealsForSku,
|
createSpecialDealsForSku,
|
||||||
updateSkuDeals,
|
updateSkuDeals,
|
||||||
replaceProductTags,
|
replaceProductTags,
|
||||||
|
replaceTagProducts,
|
||||||
mergeSkus,
|
mergeSkus,
|
||||||
updateSlotProducts,
|
updateSlotProducts,
|
||||||
getSlotsProductIds,
|
getSlotsProductIds,
|
||||||
|
|
@ -314,12 +319,14 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,69 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +45,42 @@ 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>
|
||||||
|
|
@ -94,18 +129,23 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
|
||||||
featureValue: feature.featureValue,
|
featureValue: feature.featureValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({
|
const mapSku = (
|
||||||
|
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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
||||||
images: getStringArray(sku.images),
|
images: getStringArray(sku.images),
|
||||||
imageKeys: getStringArray(sku.images),
|
imageKeys: getStringArray(sku.images),
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
isSuspended: sku.isSuspended,
|
isSuspended: marketStats?.isSuspended ?? false,
|
||||||
isFlashAvailable: sku.isFlashAvailable,
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
||||||
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
createdAt: sku.createdAt,
|
createdAt: sku.createdAt,
|
||||||
|
|
@ -128,13 +168,14 @@ 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[] }>
|
skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[]; marketStats: MarketStatsRow | null }>
|
||||||
}
|
}
|
||||||
const products = await db.query.productInfo.findMany({
|
const products = await db.query.productInfo.findMany({
|
||||||
orderBy: productInfo.name,
|
orderBy: productInfo.name,
|
||||||
|
|
@ -143,9 +184,10 @@ 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 } },
|
sku: { with: { product: true, features: true, marketStats: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -163,9 +205,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: String(ci.sku?.price ?? '0'),
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
||||||
}))
|
}))
|
||||||
return mapSku(sku, sku.features, comboItems)
|
return mapSku(sku, sku.features, comboItems, sku.marketStats)
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -178,9 +220,10 @@ 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 } },
|
sku: { with: { product: true, features: true, marketStats: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -212,12 +255,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) => ({ 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: String(ci.sku?.price ?? '0'),
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
||||||
}))
|
}))
|
||||||
return mapSku(sku, sku.features, comboItems)
|
return mapSku(sku, sku.features, comboItems, sku.marketStats)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -272,20 +315,26 @@ 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,
|
||||||
|
|
@ -306,13 +355,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 },
|
with: { features: true, marketStats: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mapProduct(product),
|
...mapProduct(product),
|
||||||
store: null,
|
store: null,
|
||||||
skus: createdSkus.map((s) => mapSku(s, s.features)),
|
skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,11 +417,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.productSkus.findMany({
|
const combos = await db.query.productMarketStats.findMany({
|
||||||
where: inArray(productSkus.id, comboIds),
|
where: inArray(productMarketStats.skuId, comboIds),
|
||||||
columns: { id: true, isSuspended: true },
|
columns: { skuId: true, isSuspended: true },
|
||||||
})
|
})
|
||||||
const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id)
|
const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.skuId)
|
||||||
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`
|
||||||
|
|
@ -387,17 +436,35 @@ 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,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
.where(eq(productSkus.id, sku.id))
|
.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,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if (existingMarketStats) {
|
||||||
|
await db.update(productMarketStats)
|
||||||
|
.set(marketStatsValues)
|
||||||
|
.where(eq(productMarketStats.skuId, sku.id))
|
||||||
|
} else {
|
||||||
|
await db.insert(productMarketStats).values({
|
||||||
|
skuId: sku.id,
|
||||||
|
...marketStatsValues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
||||||
sku.features.map((f: any) => ({
|
sku.features.map((f: any) => ({
|
||||||
|
|
@ -421,16 +488,21 @@ 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,
|
||||||
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()
|
||||||
|
|
||||||
|
await db.insert(productMarketStats).values({
|
||||||
|
skuId: newSku.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: any) => ({
|
sku.features.map((f: any) => ({
|
||||||
skuId: newSku.id,
|
skuId: newSku.id,
|
||||||
|
|
@ -447,7 +519,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
with: {
|
with: {
|
||||||
store: true,
|
store: true,
|
||||||
skus: {
|
skus: {
|
||||||
with: { features: true },
|
with: { features: true, marketStats: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -459,7 +531,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)),
|
skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,6 +588,7 @@ 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),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,6 +618,7 @@ 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> {
|
||||||
|
|
@ -554,11 +628,13 @@ 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: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,6 +662,7 @@ 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,6 +672,7 @@ 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> {
|
||||||
|
|
@ -604,6 +682,7 @@ 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({
|
||||||
|
|
@ -625,6 +704,7 @@ 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) || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,15 +978,32 @@ 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.price = price.toString()
|
if (price !== undefined) updateData.ourPrice = 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
|
||||||
|
|
||||||
await tx
|
if (Object.keys(updateData).length === 0) continue
|
||||||
.update(productSkus)
|
|
||||||
.set(updateData)
|
const existingMarketStats = await tx.query.productMarketStats.findFirst({
|
||||||
.where(eq(productSkus.id, productId))
|
where: eq(productMarketStats.skuId, productId),
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingMarketStats) {
|
||||||
|
await tx
|
||||||
|
.update(productMarketStats)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(productMarketStats.skuId, 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -956,7 +1053,7 @@ export interface CreateSpecialDealInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSpecialDealsForSku(
|
export async function createSpecialDealsForSku(
|
||||||
productId: number,
|
skuId: number,
|
||||||
deals: CreateSpecialDealInput[]
|
deals: CreateSpecialDealInput[]
|
||||||
): Promise<AdminSpecialDeal[]> {
|
): Promise<AdminSpecialDeal[]> {
|
||||||
if (deals.length === 0) {
|
if (deals.length === 0) {
|
||||||
|
|
@ -964,7 +1061,7 @@ export async function createSpecialDealsForSku(
|
||||||
}
|
}
|
||||||
|
|
||||||
const dealInserts = deals.map((deal) => ({
|
const dealInserts = deals.map((deal) => ({
|
||||||
productId,
|
skuId,
|
||||||
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),
|
||||||
|
|
@ -1025,7 +1122,7 @@ export async function updateSkuDeals(
|
||||||
|
|
||||||
if (dealsToAdd.length > 0) {
|
if (dealsToAdd.length > 0) {
|
||||||
const dealInserts = dealsToAdd.map((deal) => ({
|
const dealInserts = dealsToAdd.map((deal) => ({
|
||||||
productId,
|
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),
|
||||||
|
|
@ -1059,6 +1156,21 @@ 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: {} }
|
||||||
|
|
|
||||||
|
|
@ -201,18 +201,23 @@ 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),
|
||||||
|
|
@ -286,6 +291,7 @@ 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`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -590,6 +596,7 @@ 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),
|
||||||
|
|
@ -597,6 +604,10 @@ 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] }),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ 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',
|
||||||
|
|
@ -37,6 +39,8 @@ 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',
|
||||||
|
|
@ -67,6 +71,8 @@ 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',
|
||||||
|
|
@ -91,6 +97,8 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { db } from '../db/db_index'
|
||||||
import {
|
import {
|
||||||
homeBanners,
|
homeBanners,
|
||||||
productInfo,
|
productInfo,
|
||||||
|
productMarketStats,
|
||||||
productSkus,
|
productSkus,
|
||||||
skuFeatures,
|
skuFeatures,
|
||||||
deliverySlotInfo,
|
deliverySlotInfo,
|
||||||
|
|
@ -61,6 +62,16 @@ 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
|
||||||
|
|
@ -107,15 +118,18 @@ 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.map((sku) => {
|
return skus
|
||||||
|
.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,
|
||||||
|
|
@ -123,21 +137,37 @@ 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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
||||||
images: sku.images,
|
images: sku.images,
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
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: sku.isFlashAvailable,
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
||||||
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
|
flashPrice: marketStats?.flashPrice ? String(marketStats.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 },
|
||||||
|
|
@ -192,20 +222,21 @@ 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 } },
|
sku: { with: { product: true, features: true, marketStats: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const suspendedSkuIds = new Set(
|
const suspendedSkuIds = new Set(
|
||||||
(await db
|
(await db
|
||||||
.select({ id: productSkus.id })
|
.select({ id: productMarketStats.skuId })
|
||||||
.from(productSkus)
|
.from(productMarketStats)
|
||||||
.where(eq(productSkus.isSuspended, true))).map((r) => r.id)
|
.where(eq(productMarketStats.isSuspended, true))).map((r) => r.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
@ -219,7 +250,8 @@ 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: String(ci.sku?.price ?? '0'),
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
||||||
|
isOffer: ci.sku?.isOffer ?? false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +267,7 @@ 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 {
|
||||||
|
|
@ -251,6 +284,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -317,15 +351,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))
|
skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.marketStats?.isSuspended)
|
||||||
}
|
}
|
||||||
|
|
||||||
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
||||||
|
|
@ -341,6 +375,7 @@ 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,
|
||||||
|
|
@ -348,8 +383,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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.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,
|
||||||
|
|
@ -357,10 +392,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: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
storeId: sku.product?.storeId ?? null,
|
storeId: sku.product?.storeId ?? null,
|
||||||
isFlashAvailable: sku.isFlashAvailable,
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
||||||
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})) as SlotWithProductsData[]
|
})) as SlotWithProductsData[]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index'
|
import { db } from '../db/db_index'
|
||||||
import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema'
|
import { deliverySlotInfo, productInfo, productCombos, productSkus, productMarketStats, 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,19 +11,24 @@ 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: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)),
|
where: eq(productSkus.id, skuId),
|
||||||
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),
|
||||||
|
|
@ -48,7 +53,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 } },
|
sku: { with: { product: true, features: true, marketStats: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -60,7 +65,8 @@ 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: String(ci.sku?.price ?? '0'),
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
||||||
|
isOffer: ci.sku?.isOffer ?? false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -70,11 +76,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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
||||||
unitNotation: composeUnitNotation(features),
|
unitNotation: composeUnitNotation(features),
|
||||||
images: getStringArray(sku.images),
|
images: getStringArray(sku.images),
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
store: storeData ? {
|
store: storeData ? {
|
||||||
id: storeData.id,
|
id: storeData.id,
|
||||||
name: storeData.name,
|
name: storeData.name,
|
||||||
|
|
@ -82,8 +88,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
||||||
} : null,
|
} : null,
|
||||||
incrementStep: product?.incrementStep ?? 1,
|
incrementStep: product?.incrementStep ?? 1,
|
||||||
productQuantity: 1,
|
productQuantity: 1,
|
||||||
isFlashAvailable: sku.isFlashAvailable,
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
||||||
flashPrice: sku.flashPrice?.toString() || null,
|
flashPrice: marketStats?.flashPrice?.toString() || null,
|
||||||
deliverySlots: [],
|
deliverySlots: [],
|
||||||
specialDeals: specialDealsData.map((deal) => ({
|
specialDeals: specialDealsData.map((deal) => ({
|
||||||
quantity: String(deal.quantity ?? '0'),
|
quantity: String(deal.quantity ?? '0'),
|
||||||
|
|
@ -202,30 +208,32 @@ 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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
||||||
images: sku.images,
|
images: sku.images,
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
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 })),
|
||||||
|
|
@ -238,9 +246,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: productSkus.id })
|
.select({ id: productMarketStats.skuId })
|
||||||
.from(productSkus)
|
.from(productMarketStats)
|
||||||
.where(eq(productSkus.isSuspended, true))
|
.where(eq(productMarketStats.isSuspended, true))
|
||||||
|
|
||||||
return suspendedSkus.map(sp => sp.id)
|
return suspendedSkus.map(sp => sp.id)
|
||||||
}
|
}
|
||||||
|
|
@ -279,16 +287,18 @@ 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.map((sku) => {
|
return skus
|
||||||
|
.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 {
|
||||||
|
|
@ -319,10 +329,12 @@ export interface OffersPageData {
|
||||||
|
|
||||||
const mapOffersPageProduct = (sku: {
|
const mapOffersPageProduct = (sku: {
|
||||||
id: number
|
id: number
|
||||||
price: string | null
|
marketStats: {
|
||||||
marketPrice: string | null
|
ourPrice: 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 => {
|
||||||
|
|
@ -330,21 +342,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: String(sku.price ?? '0'),
|
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
|
||||||
unitNotation: composeUnitNotation(features),
|
unitNotation: composeUnitNotation(features),
|
||||||
images: sku.images,
|
images: sku.images,
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: sku.marketStats?.isOutOfStock ?? false,
|
||||||
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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -352,6 +364,7 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index'
|
import { db } from '../db/db_index'
|
||||||
import { deliverySlotInfo, productSkus } from '../db/schema'
|
import { deliverySlotInfo, productMarketStats } 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,17 +27,16 @@ export async function getActiveSlotsList(): Promise<UserDeliverySlot[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductAvailability(): Promise<UserSlotAvailability[]> {
|
export async function getProductAvailability(): Promise<UserSlotAvailability[]> {
|
||||||
const skus = await db.query.productSkus.findMany({
|
const stats = await db.query.productMarketStats.findMany({
|
||||||
where: eq(productSkus.isSuspended, false),
|
where: eq(productMarketStats.isSuspended, false),
|
||||||
with: {
|
columns: {
|
||||||
product: { columns: { name: true } },
|
skuId: true,
|
||||||
|
isOutOfStock: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return skus.map((sku) => ({
|
return stats.map((stat) => ({
|
||||||
id: sku.id,
|
id: stat.skuId,
|
||||||
name: sku.product?.name ?? 'Unknown',
|
isOutOfStock: stat.isOutOfStock,
|
||||||
isOutOfStock: sku.isOutOfStock,
|
|
||||||
isFlashAvailable: sku.isFlashAvailable,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
where: eq(productSkus.isSuspended, false),
|
with: { product: true, marketStats: true },
|
||||||
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 skus) {
|
for (const sku of activeSkus) {
|
||||||
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,30 +77,31 @@ 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: and(
|
where: inArray(productSkus.productId, productIdArr),
|
||||||
inArray(productSkus.productId, productIdArr),
|
|
||||||
eq(productSkus.isSuspended, false)
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
product: true,
|
product: true,
|
||||||
features: true,
|
features: true,
|
||||||
|
marketStats: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const products: UserStoreProductData[] = skus.map((sku) => {
|
const products: UserStoreProductData[] = skus
|
||||||
|
.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: String(sku.price ?? '0'),
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
||||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
marketPrice: marketStats?.marketPrice ? String(marketStats.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: sku.isOutOfStock,
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
||||||
productQuantity: 1,
|
productQuantity: 1,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,7 @@ export interface AdminProductTagInfo {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores: unknown;
|
relatedStores: unknown;
|
||||||
|
sortOrder: number[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -466,6 +467,7 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,7 @@ 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 {
|
||||||
|
|
@ -320,32 +321,34 @@ export interface UserCreateReviewResponse {
|
||||||
|
|
||||||
export interface UserSlotProduct {
|
export interface UserSlotProduct {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
images: string[] | null;
|
||||||
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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue