enh
This commit is contained in:
parent
0ca746d3c6
commit
6a0bc18a8d
19 changed files with 909 additions and 212 deletions
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"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": [],
|
||||
"defaultMode": "default"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export default function Layout() {
|
|||
<Stack.Screen name="index" options={{ title: "Product Tags" }} />
|
||||
<Stack.Screen name="add" options={{ title: "Add Tag" }} />
|
||||
<Stack.Screen name="edit/index" options={{ title: "Edit Tag" }} />
|
||||
<Stack.Screen name="order" options={{ title: "Tag Orders" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
productIds: number[];
|
||||
}
|
||||
|
||||
export default function AddTag() {
|
||||
|
|
@ -45,6 +46,7 @@ export default function AddTag() {
|
|||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ export default function AddTag() {
|
|||
tagDescription: '',
|
||||
isDashboardTag: false,
|
||||
relatedStores: [],
|
||||
productIds: [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
productIds: number[];
|
||||
existingImageUrl?: string;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ export default function EditTag() {
|
|||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
|
|
@ -95,11 +97,16 @@ export default function EditTag() {
|
|||
}
|
||||
|
||||
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 = {
|
||||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
productIds: [...orderedProductIds, ...remainingProductIds],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -53,11 +53,18 @@ const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
|
|||
|
||||
interface TagHeaderProps {
|
||||
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`}>
|
||||
<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
|
||||
onPress={onAddNewTag}
|
||||
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');
|
||||
};
|
||||
|
||||
const handleOrderTags = () => {
|
||||
router.push('/product-tags/order');
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -127,7 +138,7 @@ export default function ProductTags() {
|
|||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} />}
|
||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />}
|
||||
contentContainerStyle={tw`pb-4`}
|
||||
ListEmptyComponent={
|
||||
<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 { View, TouchableOpacity } from 'react-native';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
|
||||
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 {
|
||||
id: number;
|
||||
|
|
@ -15,6 +20,7 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
productIds: number[];
|
||||
}
|
||||
|
||||
interface TagFormProps {
|
||||
|
|
@ -26,6 +32,13 @@ interface TagFormProps {
|
|||
stores?: StoreOption[];
|
||||
}
|
||||
|
||||
interface SelectedProduct {
|
||||
skuId: number;
|
||||
productId: number;
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
const TagForm = forwardRef<any, TagFormProps>(({
|
||||
mode,
|
||||
initialValues,
|
||||
|
|
@ -37,10 +50,35 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
|
||||
const [removedExisting, setRemovedExisting] = useState(false)
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([]);
|
||||
|
||||
const existingImageUrl = existingImageUrlRaw || ''
|
||||
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
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
|
|
@ -52,7 +90,6 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
setRemovedExisting(false)
|
||||
}, [existingImageUrlRaw, initialValues.isDashboardTag]);
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
tagName: Yup.string()
|
||||
.required('Tag name is required')
|
||||
|
|
@ -62,21 +99,74 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
.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 (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit(values, images, removedExisting)}
|
||||
onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((p) => p.productId) }, images, removedExisting)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||
// Clear form when screen comes into focus (create mode only — edit keeps its loaded products)
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([])
|
||||
setRemovedExisting(false)
|
||||
setIsDashboardTagChecked(false);
|
||||
if (mode === 'create') {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
}, [resetForm, mode]);
|
||||
|
||||
useFocusCallback(clearForm);
|
||||
|
||||
|
|
@ -106,7 +196,6 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||
</MyText>
|
||||
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
|
||||
|
|
@ -132,7 +221,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
onPress={() => {
|
||||
const newValue = !isDashboardTagChecked;
|
||||
setIsDashboardTagChecked(newValue);
|
||||
formikSetFieldValue('isDashboardTag', newValue);
|
||||
setFieldValue('isDashboardTag', newValue);
|
||||
}}
|
||||
/>
|
||||
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
||||
|
|
@ -153,12 +242,53 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
||||
formikSetFieldValue('relatedStores', numericValues);
|
||||
setFieldValue('relatedStores', numericValues);
|
||||
}}
|
||||
multiple={true}
|
||||
/>
|
||||
</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
|
||||
onPress={() => handleSubmit()}
|
||||
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';
|
||||
|
||||
export default TagForm;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const CONST_KEYS = {
|
|||
appStoreUrl: 'appStoreUrl',
|
||||
popularItems: 'popularItems',
|
||||
allItemsOrder: 'allItemsOrder',
|
||||
tagsOrder: 'tagsOrder',
|
||||
isFlashDeliveryEnabled: 'isFlashDeliveryEnabled',
|
||||
supportMobile: 'supportMobile',
|
||||
supportEmail: 'supportEmail',
|
||||
|
|
@ -41,6 +42,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
|||
appStoreUrl: 'App Store URL',
|
||||
popularItems: 'Popular Items',
|
||||
allItemsOrder: 'All Items Order',
|
||||
tagsOrder: 'Tags Order',
|
||||
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
||||
supportMobile: 'Support Mobile',
|
||||
supportEmail: 'Support Email',
|
||||
|
|
@ -71,6 +73,7 @@ export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
|
|||
appStoreUrl: 'string',
|
||||
popularItems: 'string',
|
||||
allItemsOrder: 'string',
|
||||
tagsOrder: 'string',
|
||||
isFlashDeliveryEnabled: 'boolean',
|
||||
supportMobile: 'string',
|
||||
supportEmail: 'string',
|
||||
|
|
@ -95,6 +98,7 @@ export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
|
|||
appStoreUrl: true,
|
||||
popularItems: true,
|
||||
allItemsOrder: true,
|
||||
tagsOrder: false,
|
||||
isFlashDeliveryEnabled: true,
|
||||
supportMobile: true,
|
||||
supportEmail: true,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export {
|
|||
createSpecialDealsForSku,
|
||||
updateSkuDeals,
|
||||
replaceProductTags,
|
||||
replaceTagProducts,
|
||||
mergeSkus,
|
||||
updateSlotProducts,
|
||||
getSlotsProductIds,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
checkUnitExists,
|
||||
createProduct as createProductInDb,
|
||||
replaceProductTags,
|
||||
replaceTagProducts,
|
||||
getProductImagesById,
|
||||
updateProduct as updateProductInDb,
|
||||
checkProductTagExistsByName,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
deleteProductTag as deleteProductTagInDb,
|
||||
getAllProductTagInfos as getAllProductTagInfosInDb,
|
||||
getProductTagInfoById as getProductTagInfoByIdInDb,
|
||||
getProductTagById as getProductTagByIdInDb,
|
||||
} from '@/src/dbService'
|
||||
import type {
|
||||
AdminProduct,
|
||||
|
|
@ -880,8 +882,8 @@ export const productRouter = router({
|
|||
|
||||
getProductTagById: protectedProcedure
|
||||
.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 }> => {
|
||||
const tag = await getProductTagInfoByIdInDb(input.id)
|
||||
.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 getProductTagByIdInDb(input.id)
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError('Tag not found', 404)
|
||||
|
|
@ -905,10 +907,11 @@ export const productRouter = router({
|
|||
imageUrl: z.string().optional().nullable(),
|
||||
isDashboardTag: z.boolean().optional().default(false),
|
||||
relatedStores: z.array(z.number()).optional().default([]),
|
||||
productIds: z.array(z.number()).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 }> => {
|
||||
const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
||||
.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, productIds, uploadUrls } = input
|
||||
|
||||
const existingTag = await checkProductTagExistsByName(tagName.trim())
|
||||
if (existingTag) {
|
||||
|
|
@ -921,8 +924,11 @@ export const productRouter = router({
|
|||
imageUrl: imageUrl ?? null,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
sortOrder: productIds,
|
||||
})
|
||||
|
||||
await replaceTagProducts(createdTag.id, productIds)
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
|
||||
}
|
||||
|
|
@ -948,10 +954,11 @@ export const productRouter = router({
|
|||
imageUrl: z.string().optional().nullable(),
|
||||
isDashboardTag: z.boolean().optional(),
|
||||
relatedStores: z.array(z.number()).optional(),
|
||||
productIds: z.array(z.number()).optional(),
|
||||
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 }> => {
|
||||
const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
||||
.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, productIds, uploadUrls } = input
|
||||
|
||||
const currentTag = await getProductTagInfoByIdInDb(id)
|
||||
|
||||
|
|
@ -971,8 +978,13 @@ export const productRouter = router({
|
|||
imageUrl: imageUrl ?? undefined,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
sortOrder: productIds,
|
||||
})
|
||||
|
||||
if (productIds !== undefined) {
|
||||
await replaceTagProducts(id, productIds)
|
||||
}
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
||||
import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store'
|
||||
import { getConstant } from '@/src/lib/const-store'
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
|
||||
// Re-export with original name for backwards compatibility
|
||||
export const getNextDeliveryDate = getNextDeliveryDateWithCapacity
|
||||
|
|
@ -65,6 +67,28 @@ export async function scaffoldProducts() {
|
|||
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[]>()
|
||||
for (const mapping of tagMappings) {
|
||||
if (!productIdsByTag.has(mapping.tagId)) {
|
||||
|
|
@ -73,14 +97,25 @@ export async function scaffoldProducts() {
|
|||
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,
|
||||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: (tag.relatedStores as number[]) || [],
|
||||
productIds: productIdsByTag.get(tag.id) || [],
|
||||
productIds: reorderProductIds(tag.id, tag.sortOrder ?? undefined),
|
||||
}))
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ routes = [
|
|||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "freshyo-backend-dev"
|
||||
database_id = "a976d1fb-c6a7-4948-b303-81c6433ed265"
|
||||
database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa"
|
||||
#database_name = "freshyo-dev"
|
||||
#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de"
|
||||
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 { TabView, TabBar } from "react-native-tab-view";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
|
|
@ -25,7 +26,6 @@ import { useProductSlotIdentifier } from "@/hooks/useProductSlotIdentifier";
|
|||
import { useCentralSlotStore } from "@/src/store/centralSlotStore";
|
||||
import { useCentralProductStore } from "@/src/store/centralProductStore";
|
||||
import FloatingCartBar from "@/components/floating-cart-bar";
|
||||
import BannerCarousel from "@/components/BannerCarousel";
|
||||
import { useUserDetails } from "@/src/contexts/AuthContext";
|
||||
import TabLayoutWrapper from "@/components/TabLayoutWrapper";
|
||||
import { useNavigationStore } from "@/src/store/navigationStore";
|
||||
|
|
@ -37,6 +37,14 @@ const itemWidth = screenWidth * 0.45;
|
|||
const heroItemWidth = (screenWidth - 72) / 3;
|
||||
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 time = dayjs(deliveryTime);
|
||||
const endTime = time.add(1, 'hour');
|
||||
|
|
@ -53,7 +61,6 @@ const formatTimeRange = (deliveryTime: string) => {
|
|||
const staticStyles = {
|
||||
flatListContent: { gap: 16 },
|
||||
columnWrapper: { gap: 16, paddingHorizontal: 16 },
|
||||
popularListContent: { paddingBottom: 16 },
|
||||
slotsListContent: { paddingBottom: 24 },
|
||||
};
|
||||
|
||||
|
|
@ -91,15 +98,15 @@ const RenderStore = memo(({ item }: RenderStoreProps) => {
|
|||
activeOpacity={0.7}
|
||||
>
|
||||
<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 ? (
|
||||
<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>
|
||||
<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, "")}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
|
|
@ -125,7 +132,7 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
|
|||
<MyTouchableOpacity
|
||||
testID={`slot-card-${slot.id}`}
|
||||
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`,
|
||||
]}
|
||||
onPress={handlePress}
|
||||
|
|
@ -179,35 +186,15 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
|
|||
</View>
|
||||
))}
|
||||
</View>
|
||||
<MyText style={tw`text-[11px] font-bold text-brand600`}>View all {slot.products.length} items</MyText>
|
||||
<MaterialIcons name="chevron-right" size={16} color={theme.colors.brand600} />
|
||||
<View style={tw`flex-row items-center bg-brand50 rounded-full px-3 py-1.5`}>
|
||||
<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>
|
||||
</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 {
|
||||
tag: any;
|
||||
isSelected: boolean;
|
||||
|
|
@ -226,7 +213,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
|||
style={[
|
||||
tw`text-base tracking-tight`,
|
||||
{
|
||||
color: isSelected ? '#111827' : '#64748B',
|
||||
color: isSelected ? theme.colors.brand600 : '#64748B',
|
||||
fontWeight: isSelected ? '700' : '500',
|
||||
},
|
||||
]}
|
||||
|
|
@ -239,7 +226,7 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
|||
height: 4,
|
||||
borderRadius: 999,
|
||||
marginTop: 8,
|
||||
backgroundColor: isSelected ? '#111827' : 'transparent',
|
||||
backgroundColor: isSelected ? theme.colors.brand500 : 'transparent',
|
||||
}}
|
||||
/>
|
||||
</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[];
|
||||
activeTagId: number | null;
|
||||
onSelectTag: (id: number) => void;
|
||||
}
|
||||
|
||||
const ExploreTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: ExploreTabsRowProps) => (
|
||||
const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
onSelectTag(id);
|
||||
const idx = dashboardTags.findIndex((tag) => tag.id === id);
|
||||
if (idx >= 0) {
|
||||
scrollRef.current?.scrollTo({ x: idx * 96, animated: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
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={() => onSelectTag(tag.id)}
|
||||
/>
|
||||
<ExploreTab key={tag.id} tag={tag} isSelected={activeTagId === tag.id} onPress={() => handleSelect(tag.id)} />
|
||||
))}
|
||||
</ScrollView>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
interface ExploreProductItemProps {
|
||||
item: any;
|
||||
|
|
@ -323,12 +423,11 @@ interface ListHeaderProps {
|
|||
gradientHeight: number;
|
||||
onGradientLayout: (height: number) => void;
|
||||
storesData: any;
|
||||
popularProducts: any[];
|
||||
sortedSlots: any[];
|
||||
onProductPress: (id: number) => void;
|
||||
dashboardTags: any[];
|
||||
activeTagId: number | null;
|
||||
activeTagProducts: any[];
|
||||
productsByTagId: Record<number, any[]>;
|
||||
onSelectTag: (id: number) => void;
|
||||
onTabsSectionLayout: (layout: { y: number; height: number }) => void;
|
||||
}
|
||||
|
|
@ -337,29 +436,19 @@ const ListHeader = memo(({
|
|||
gradientHeight,
|
||||
onGradientLayout,
|
||||
storesData,
|
||||
popularProducts,
|
||||
sortedSlots,
|
||||
onProductPress,
|
||||
dashboardTags,
|
||||
activeTagId,
|
||||
activeTagProducts,
|
||||
productsByTagId,
|
||||
onSelectTag,
|
||||
onTabsSectionLayout,
|
||||
}: ListHeaderProps) => {
|
||||
const [showAllActiveProducts, setShowAllActiveProducts] = useState(false);
|
||||
const handleLayout = useCallback((event: any) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
onGradientLayout(y + height);
|
||||
}, [onGradientLayout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowAllActiveProducts(false);
|
||||
}, [activeTagId]);
|
||||
|
||||
const renderPopularItem = useCallback(({ item }: { item: any }) => (
|
||||
<PopularProductItem item={item} onPress={onProductPress} />
|
||||
), [onProductPress]);
|
||||
|
||||
const renderSlotItem = useCallback(({ item }: { item: any }) => (
|
||||
<SlotItem item={item} />
|
||||
), []);
|
||||
|
|
@ -370,16 +459,14 @@ const ListHeader = memo(({
|
|||
], [gradientHeight]);
|
||||
const activeColor = activeTagId == null ? null : getTagColor(activeTagId);
|
||||
const pageTint = activeColor?.pageBg ?? '#FFFFFF';
|
||||
const visibleActiveTagProducts = showAllActiveProducts ? activeTagProducts : activeTagProducts.slice(0, 6);
|
||||
const hasMoreActiveTagProducts = activeTagProducts.length > visibleActiveTagProducts.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View onLayout={handleLayout} style={{ backgroundColor: pageTint }}>
|
||||
<View onLayout={handleLayout} style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<LinearGradient
|
||||
colors={[pageTint, pageTint]}
|
||||
colors={['#FFFFFF', '#FFFFFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={gradientStyle}
|
||||
/>
|
||||
|
||||
|
|
@ -390,59 +477,29 @@ const ListHeader = memo(({
|
|||
onTabsSectionLayout({ y, height });
|
||||
}}
|
||||
style={[
|
||||
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-2`,
|
||||
{ backgroundColor: pageTint },
|
||||
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`,
|
||||
{ backgroundColor: theme.colors.brand25 },
|
||||
]}
|
||||
>
|
||||
<ExploreTabsRow
|
||||
<TagTabView
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
productsByTagId={productsByTagId}
|
||||
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 style={tw`py-4`}>
|
||||
<BannerCarousel />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
tw`rounded-t-3xl px-4`,
|
||||
{ backgroundColor: pageTint },
|
||||
{ backgroundColor: '#FFFFFF' },
|
||||
]}
|
||||
>
|
||||
{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>
|
||||
<MyText style={tw`text-xl font-extrabold text-gray-900 tracking-tight`}>
|
||||
|
|
@ -467,36 +524,15 @@ const ListHeader = memo(({
|
|||
<NextOrderGlimpse />
|
||||
</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 && (
|
||||
<View style={tw`mt-2 mb-4`}>
|
||||
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
|
||||
<View>
|
||||
<View style={tw`flex-row items-center mb-1.5`}>
|
||||
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
|
||||
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Upcoming Delivery Slots</MyText>
|
||||
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Plan your fresh deliveries ahead</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Plan your fresh deliveries ahead</MyText>
|
||||
</View>
|
||||
</View>
|
||||
<MyFlatList
|
||||
|
|
@ -516,8 +552,11 @@ const ListHeader = memo(({
|
|||
<View style={tw`mt-2 mb-4`}>
|
||||
<View style={tw`flex-row items-center justify-between px-1 mb-4`}>
|
||||
<View>
|
||||
<View style={tw`flex-row items-center mb-1.5`}>
|
||||
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
|
||||
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>All Available Products</MyText>
|
||||
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse our complete selection</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Browse our complete selection</MyText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -587,21 +626,6 @@ export default function Dashboard() {
|
|||
setHasMore(products.length > 10)
|
||||
}, [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(() => {
|
||||
if (!slotsData?.slots) return [];
|
||||
const now = dayjs();
|
||||
|
|
@ -614,31 +638,43 @@ export default function Dashboard() {
|
|||
});
|
||||
}, [slotsData]);
|
||||
|
||||
const popularProducts = useMemo(() => {
|
||||
return popularItemIds
|
||||
.map(id => products.find(product => product.id === id))
|
||||
.filter((product): product is NonNullable<typeof product> => product != null);
|
||||
}, [popularItemIds, products]);
|
||||
const productsByTagId = useMemo(() => {
|
||||
const map: Record<number, any[]> = {};
|
||||
for (const tag of dashboardTags) {
|
||||
const productById = new Map<number, any>();
|
||||
for (const product of products) {
|
||||
productById.set(product.id, product);
|
||||
}
|
||||
|
||||
const activeTagProducts = useMemo(() => {
|
||||
if (activeTagId == null) return [];
|
||||
const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId);
|
||||
if (!activeTag) return [];
|
||||
// tag.productIds is already in the admin-curated order (backend sorts by sortOrder).
|
||||
const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id));
|
||||
const ordered: any[] = [];
|
||||
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
|
||||
.filter((product: any) => activeTag.productIds?.includes(product.id) ?? false)
|
||||
// Products in the tag's productIds but not in the curated order (added later) — availability sort.
|
||||
const seen = new Set(orderedIds);
|
||||
const extra = products
|
||||
.filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.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
|
||||
});
|
||||
}, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]);
|
||||
|
||||
map[tag.id] = [...ordered, ...rest, ...extra];
|
||||
}
|
||||
return map;
|
||||
}, [dashboardTags, products, getQuickestSlot, productSlotsMap]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
|
|
@ -703,26 +739,25 @@ export default function Dashboard() {
|
|||
gradientHeight={gradientHeight}
|
||||
onGradientLayout={handleGradientLayout}
|
||||
storesData={storesData}
|
||||
popularProducts={popularProducts}
|
||||
sortedSlots={sortedSlots}
|
||||
onProductPress={handleProductPress}
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
activeTagProducts={activeTagProducts}
|
||||
productsByTagId={productsByTagId}
|
||||
onSelectTag={setSelectedTagId}
|
||||
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 pageTintStyle = useMemo(() => [
|
||||
tw`flex-1`,
|
||||
{ backgroundColor: pageTint }
|
||||
{ backgroundColor: '#FFFFFF' }
|
||||
], [pageTint]);
|
||||
|
||||
const searchBarContainerStyle = useMemo(() => [
|
||||
tw`w-full px-4 pt-4 pb-0`,
|
||||
{ backgroundColor: pageTint }
|
||||
{ backgroundColor: '#FFFFFF' }
|
||||
], [pageTint]);
|
||||
|
||||
const listContentContainerStyle = useMemo(() => [
|
||||
|
|
@ -751,11 +786,8 @@ export default function Dashboard() {
|
|||
</View>
|
||||
);
|
||||
}
|
||||
let str = ''
|
||||
displayedProducts.forEach(product => str += `${product.id}-`)
|
||||
// console.log(str)
|
||||
return (
|
||||
<TabLayoutWrapper style={{ backgroundColor: pageTint }}>
|
||||
<TabLayoutWrapper style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<View style={pageTintStyle}>
|
||||
<View
|
||||
style={searchBarContainerStyle}
|
||||
|
|
@ -780,7 +812,7 @@ export default function Dashboard() {
|
|||
data={displayedProducts}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
numColumns={2}
|
||||
style={{ backgroundColor: pageTint }}
|
||||
style={{ backgroundColor: '#FFFFFF' }}
|
||||
onScroll={handleListScroll}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
|
|
@ -791,8 +823,8 @@ export default function Dashboard() {
|
|||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#3b82f6"
|
||||
colors={["#3b82f6"]}
|
||||
tintColor={theme.colors.brand500}
|
||||
colors={[theme.colors.brand500]}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
|
|
@ -814,7 +846,7 @@ export default function Dashboard() {
|
|||
tw`absolute left-0 right-0 z-20 px-4 pb-2`,
|
||||
{
|
||||
top: searchBarHeight,
|
||||
backgroundColor: pageTint,
|
||||
backgroundColor: '#FFFFFF',
|
||||
elevation: 8,
|
||||
},
|
||||
]}
|
||||
|
|
@ -822,10 +854,10 @@ export default function Dashboard() {
|
|||
<View
|
||||
style={[
|
||||
tw`rounded-[28px] px-3 pt-2 pb-1`,
|
||||
{ backgroundColor: pageTint },
|
||||
{ backgroundColor: theme.colors.brand25 },
|
||||
]}
|
||||
>
|
||||
<ExploreTabsRow
|
||||
<StickyTabsRow
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
onSelectTag={setSelectedTagId}
|
||||
|
|
|
|||
|
|
@ -261,5 +261,8 @@ CREATE TABLE `product_combos` (
|
|||
|
||||
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 defer_foreign_keys = off;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export {
|
|||
createSpecialDealsForSku,
|
||||
updateSkuDeals,
|
||||
replaceProductTags,
|
||||
replaceTagProducts,
|
||||
mergeSkus,
|
||||
updateSlotProducts,
|
||||
getSlotsProductIds,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({
|
|||
imageUrl: tag.imageUrl ?? null,
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: tag.relatedStores,
|
||||
sortOrder: tag.sortOrder ?? [],
|
||||
createdAt: tag.createdAt,
|
||||
})
|
||||
|
||||
|
|
@ -587,6 +588,7 @@ export async function getAllProductTags(): Promise<AdminProductTagWithProducts[]
|
|||
assignedAt: assignment.assignedAt,
|
||||
product: mapProduct(assignment.product),
|
||||
})),
|
||||
productIds: tag.products.map((assignment: ProductTagRow) => assignment.productId),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -616,6 +618,7 @@ export interface CreateProductTagInput {
|
|||
imageUrl?: string | null
|
||||
isDashboardTag?: boolean
|
||||
relatedStores?: number[]
|
||||
sortOrder?: number[]
|
||||
}
|
||||
|
||||
export async function createProductTag(input: CreateProductTagInput): Promise<AdminProductTagWithProducts> {
|
||||
|
|
@ -625,11 +628,13 @@ export async function createProductTag(input: CreateProductTagInput): Promise<Ad
|
|||
imageUrl: input.imageUrl || null,
|
||||
isDashboardTag: input.isDashboardTag || false,
|
||||
relatedStores: input.relatedStores || [],
|
||||
sortOrder: input.sortOrder || [],
|
||||
}).returning()
|
||||
|
||||
return {
|
||||
...mapTagInfo(tag),
|
||||
products: [],
|
||||
productIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,6 +662,7 @@ export async function getProductTagById(tagId: number): Promise<AdminProductTagW
|
|||
assignedAt: assignment.assignedAt,
|
||||
product: mapProduct(assignment.product),
|
||||
})),
|
||||
productIds: tag.products.map((assignment: ProductTagRow) => assignment.productId),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -666,6 +672,7 @@ export interface UpdateProductTagInput {
|
|||
imageUrl?: string | null
|
||||
isDashboardTag?: boolean
|
||||
relatedStores?: number[]
|
||||
sortOrder?: number[]
|
||||
}
|
||||
|
||||
export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise<AdminProductTagWithProducts> {
|
||||
|
|
@ -675,6 +682,7 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp
|
|||
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
|
||||
...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }),
|
||||
...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }),
|
||||
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
|
||||
}).where(eq(productTagInfo.id, tagId)).returning()
|
||||
|
||||
const fullTag = await db.query.productTagInfo.findFirst({
|
||||
|
|
@ -696,6 +704,7 @@ export async function updateProductTag(tagId: number, input: UpdateProductTagInp
|
|||
assignedAt: assignment.assignedAt,
|
||||
product: mapProduct(assignment.product),
|
||||
})) || [],
|
||||
productIds: fullTag?.products.map((assignment: ProductTagRow) => assignment.productId) || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1147,6 +1156,21 @@ export async function replaceProductTags(productId: number, tagIds: number[]): P
|
|||
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) {
|
||||
if (fromSkuId === toSkuId) {
|
||||
return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} }
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ export const productTagInfo = sqliteTable('product_tag_info', {
|
|||
imageUrl: text('image_url'),
|
||||
isDashboardTag: integer('is_dashboard_tag', { mode: 'boolean' }).notNull().default(false),
|
||||
relatedStores: jsonText<number[]>('related_stores').$defaultFn(() => []),
|
||||
sortOrder: jsonText<number[]>('sort_order').$defaultFn(() => []),
|
||||
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ export interface TagBasicData {
|
|||
imageUrl: string | null
|
||||
isDashboardTag: boolean
|
||||
relatedStores: unknown
|
||||
sortOrder: number[] | null
|
||||
}
|
||||
|
||||
export interface TagProductMapping {
|
||||
|
|
@ -283,6 +284,7 @@ export async function getAllTagsForCache(): Promise<TagBasicData[]> {
|
|||
imageUrl: productTagInfo.imageUrl,
|
||||
isDashboardTag: productTagInfo.isDashboardTag,
|
||||
relatedStores: productTagInfo.relatedStores,
|
||||
sortOrder: productTagInfo.sortOrder,
|
||||
})
|
||||
.from(productTagInfo)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,7 @@ export interface AdminProductTagInfo {
|
|||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: unknown;
|
||||
sortOrder: number[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
|
@ -466,6 +467,7 @@ export interface AdminProductTagAssignment {
|
|||
|
||||
export interface AdminProductTagWithProducts extends AdminProductTagInfo {
|
||||
products: AdminProductTagAssignment[];
|
||||
productIds: number[];
|
||||
}
|
||||
|
||||
export interface AdminSpecialDeal {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue