From 6a0bc18a8d4671d22f9a75f8e4fd4e1606832f52 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:59:45 +0530 Subject: [PATCH] enh --- .commandcode/settings.json | 5 +- .../app/(drawer)/product-tags/_layout.tsx | 1 + .../app/(drawer)/product-tags/add.tsx | 3 + .../app/(drawer)/product-tags/edit/index.tsx | 7 + .../app/(drawer)/product-tags/index.tsx | 17 +- .../app/(drawer)/product-tags/order.tsx | 369 ++++++++++++++++ apps/admin-ui/src/components/TagForm.tsx | 214 +++++++++- apps/backend/src/lib/const-keys.ts | 4 + apps/backend/src/sqliteImporter.ts | 1 + .../src/trpc/apis/admin-apis/apis/product.ts | 24 +- .../src/trpc/apis/common-apis/common.ts | 39 +- apps/backend/wrangler.dev.toml | 2 +- .../app/(drawer)/(tabs)/home/index.tsx | 402 ++++++++++-------- .../drizzle/0002_sku_split.sql | 3 + packages/db_helper_sqlite/index.ts | 1 + .../src/admin-apis/product.ts | 24 ++ packages/db_helper_sqlite/src/db/schema.ts | 1 + .../src/stores/store-helpers.ts | 2 + packages/shared/types/admin.ts | 2 + 19 files changed, 909 insertions(+), 212 deletions(-) create mode 100644 apps/admin-ui/app/(drawer)/product-tags/order.tsx diff --git a/.commandcode/settings.json b/.commandcode/settings.json index b8291e3..187814d 100644 --- a/.commandcode/settings.json +++ b/.commandcode/settings.json @@ -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" diff --git a/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx b/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx index 4181014..9b37411 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/_layout.tsx @@ -6,6 +6,7 @@ export default function Layout() { + ); } \ No newline at end of file diff --git a/apps/admin-ui/app/(drawer)/product-tags/add.tsx b/apps/admin-ui/app/(drawer)/product-tags/add.tsx index 8de7bd7..32b8c34 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/add.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/add.tsx @@ -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 ( diff --git a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx index f9e0e62..28d7d63 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx @@ -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, }; diff --git a/apps/admin-ui/app/(drawer)/product-tags/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/index.tsx index d2b6d8f..d520c2b 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/index.tsx @@ -53,11 +53,18 @@ const TagItem: React.FC = ({ item, onDeleteSuccess }) => ( interface TagHeaderProps { onAddNewTag: () => void; + onOrderTags: () => void; } -const TagHeader: React.FC = ({ onAddNewTag }) => ( +const TagHeader: React.FC = ({ onAddNewTag, onOrderTags }) => ( - Product Tags + + + Tag Orders + { + router.push('/product-tags/order'); + }; + if (isLoading) { @@ -127,7 +138,7 @@ export default function ProductTags() { refreshControl={ } - ListHeaderComponent={} + ListHeaderComponent={} contentContainerStyle={tw`pb-4`} ListEmptyComponent={ diff --git a/apps/admin-ui/app/(drawer)/product-tags/order.tsx b/apps/admin-ui/app/(drawer)/product-tags/order.tsx new file mode 100644 index 0000000..7b542f4 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/product-tags/order.tsx @@ -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 = ({ item, drag, isActive }) => { + return ( + + + {/* Drag Handle */} + + + + + {/* Tag Image */} + {item.imageUrl ? ( + + ) : ( + + + + )} + + {/* Tag Info */} + + + {item.tagName} + + + + + ); +}; + +export default function TagOrders() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [tags, setTags] = useState([]); + 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 ( + + ); + }, []); + + 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 ( + + + + router.back()} + style={tw`p-2 -ml-4`} + > + + + Tag Orders + + + + + + {isLoadingConstants ? 'Loading order...' : 'Loading tags...'} + + + + + ); + } + + // Show error state if queries failed + if (constantsError || tagsError) { + return ( + + + + router.back()} + style={tw`p-2 -ml-4`} + > + + + Tag Orders + + + + + Error + + {constantsError ? 'Failed to load order' : 'Failed to load tags'} + + router.back()} + style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`} + > + Go Back + + + + + ); + } + + return ( + + {/* Header */} + + router.back()} + style={tw`p-2 -ml-4`} + > + + + + Tag Orders + + + + {updateConstants.isPending ? 'Saving...' : 'Save'} + + + + + {/* Content */} + {tags.length === 0 ? ( + + + + No tags available + + + ) : ( + + + + Long press and drag to reorder • {tags.length} items + + + + + item.id.toString()} + onDragEnd={handleDragEnd} + showsVerticalScrollIndicator={true} + contentContainerStyle={{ paddingBottom: 20 }} + containerStyle={tw`flex-1`} + keyboardShouldPersistTaps="handled" + activationDistance={10} + /> + + + )} + + ); +} + +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, + }, +}); diff --git a/apps/admin-ui/src/components/TagForm.tsx b/apps/admin-ui/src/components/TagForm.tsx index 481a3f0..6e4e769 100644 --- a/apps/admin-ui/src/components/TagForm.tsx +++ b/apps/admin-ui/src/components/TagForm.tsx @@ -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(({ mode, initialValues, @@ -37,10 +50,35 @@ const TagForm = forwardRef(({ const [images, setImages] = useState([]) const [removedExisting, setRemovedExisting] = useState(false) const [isDashboardTagChecked, setIsDashboardTagChecked] = useState(Boolean(initialValues.isDashboardTag)); + const [selectedProducts, setSelectedProducts] = useState([]); 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(); + 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)); @@ -51,7 +89,6 @@ const TagForm = forwardRef(({ } setRemovedExisting(false) }, [existingImageUrlRaw, initialValues.isDashboardTag]); - const validationSchema = Yup.object().shape({ tagName: Yup.string() @@ -62,21 +99,74 @@ const TagForm = forwardRef(({ .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 }) => ( + + + + + + {item.imageUrl ? ( + + ) : ( + + + + )} + + {item.label} + + handleRemoveProduct(item.productId)} style={styles.removeButton}> + + + + + ), []); + return ( 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 - const clearForm = useCallback(() => { - setImages([]) - setRemovedExisting(false) - setIsDashboardTagChecked(false); - resetForm(); - }, [resetForm]); + {({ 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, mode]); useFocusCallback(clearForm); @@ -106,7 +196,6 @@ const TagForm = forwardRef(({ Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'} - { @@ -132,7 +221,7 @@ const TagForm = forwardRef(({ onPress={() => { const newValue = !isDashboardTagChecked; setIsDashboardTagChecked(newValue); - formikSetFieldValue('isDashboardTag', newValue); + setFieldValue('isDashboardTag', newValue); }} /> Mark as Dashboard Tag @@ -153,12 +242,53 @@ const TagForm = forwardRef(({ }))} onValueChange={(selectedValues) => { const numericValues = (selectedValues as string[]).map(v => parseInt(v)); - formikSetFieldValue('relatedStores', numericValues); + setFieldValue('relatedStores', numericValues); }} multiple={true} /> + {/* Products Section: selector + reorderable list */} + + + Products + + + + {selectedProducts.length > 0 && ( + + + Long press and drag to reorder • {selectedProducts.length} items + + + )} + {selectedProducts.length > 0 ? ( + item.productId.toString()} + onDragEnd={handleDragEnd} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingBottom: 20 }} + keyboardShouldPersistTaps="handled" + activationDistance={10} + /> + ) : ( + + + No products selected yet + + )} + + + handleSubmit()} disabled={isLoading} @@ -175,6 +305,62 @@ const TagForm = forwardRef(({ ); }); +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; diff --git a/apps/backend/src/lib/const-keys.ts b/apps/backend/src/lib/const-keys.ts index 7ea1dbf..0f710db 100644 --- a/apps/backend/src/lib/const-keys.ts +++ b/apps/backend/src/lib/const-keys.ts @@ -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 = { 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 = { appStoreUrl: 'string', popularItems: 'string', allItemsOrder: 'string', + tagsOrder: 'string', isFlashDeliveryEnabled: 'boolean', supportMobile: 'string', supportEmail: 'string', @@ -95,6 +98,7 @@ export const CONST_VISIBILITY: Record = { appStoreUrl: true, popularItems: true, allItemsOrder: true, + tagsOrder: false, isFlashDeliveryEnabled: true, supportMobile: true, supportEmail: true, diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 322fc09..eb74ffa 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -72,6 +72,7 @@ export { createSpecialDealsForSku, updateSkuDeals, replaceProductTags, + replaceTagProducts, mergeSkus, updateSlotProducts, getSlotsProductIds, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index eac06d3..3657d87 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -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))) } diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 2f2cece..fb39a72 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -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() 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 { diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index 1048178..b0f643c 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -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" diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx index e277b1a..39fe454 100755 --- a/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx @@ -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} > {item.signedImageUrl ? ( ) : ( - + )} - + {item.name.replace(/^The\s+/i, "")} @@ -125,7 +132,7 @@ const SlotCard = memo(({ slot }: SlotCardProps) => { { ))} - View all {slot.products.length} items - + + View all {slot.products.length} items + + ); }); -interface PopularProductItemProps { - item: any; - onPress: (id: number) => void; -} - -const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) => { - const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); - - return ( - - - - ); -}); - 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', }} /> @@ -247,28 +234,141 @@ const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => { ); }); -interface ExploreTabsRowProps { +interface TagTabViewProps { + dashboardTags: any[]; + activeTagId: number | null; + productsByTagId: Record; + onSelectTag: (id: number) => void; + onProductPress: (id: number) => void; +} + +const TagTabView = memo(({ + dashboardTags, + activeTagId, + productsByTagId, + onSelectTag, + onProductPress, +}: TagTabViewProps) => { + const [expandedByTagId, setExpandedByTagId] = useState>({}); + + 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) => ( + + ), []); + + 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 ( + + {products.length > 0 ? ( + + {visible.map((product: any) => ( + + ))} + + ) : ( + + + No products in this category yet + + + )} + {hasMore && ( + setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))} + > + Show More + + )} + + ); + }, [productsByTagId, expandedByTagId, onProductPress]); + + return ( + 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) => ( - - {dashboardTags.map((tag) => ( - onSelectTag(tag.id)} - /> - ))} - -)); +const StickyTabsRow = memo(({ dashboardTags, activeTagId, onSelectTag }: StickyTabsRowProps) => { + const scrollRef = useRef(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 ( + + {dashboardTags.map((tag) => ( + handleSelect(tag.id)} /> + ))} + + ); +}); 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; 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 }) => ( - - ), [onProductPress]); - const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); @@ -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 ( <> - + @@ -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 }, ]} > - - {activeTagProducts.length > 0 ? ( - - - {visibleActiveTagProducts.map((product: any) => ( - - ))} - - {hasMoreActiveTagProducts && ( - setShowAllActiveProducts(true)} - > - Show More - - )} - - ) : ( - - - No products in this category yet - - - )} )} - - - - {storesData?.stores && storesData.stores.length > 0 && ( - + @@ -467,36 +524,15 @@ const ListHeader = memo(({ - - Popular Items - Trending fresh picks just for you - - - - item.id.toString()} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={staticStyles.popularListContent} - renderItem={renderPopularItem} - removeClippedSubviews={true} - /> - - - {sortedSlots.length > 0 && ( - Upcoming Delivery Slots - Plan your fresh deliveries ahead + + + Upcoming Delivery Slots + + Plan your fresh deliveries ahead - All Available Products - Browse our complete selection + + + All Available Products + + Browse our complete selection @@ -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 => product != null); - }, [popularItemIds, products]); + const productsByTagId = useMemo(() => { + const map: Record = {}; + for (const tag of dashboardTags) { + const productById = new Map(); + 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) - .sort((a: any, b: any) => { - const slotA = getQuickestSlot(a.id) - const slotB = getQuickestSlot(b.id) + // 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 + }); - 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() { ); } - let str = '' - displayedProducts.forEach(product => str += `${product.id}-`) - // console.log(str) return ( - + 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() { } 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() { - ({ 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 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 { @@ -625,11 +628,13 @@ export async function createProductTag(input: CreateProductTagInput): Promise 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 { @@ -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 { + 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: {} } diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index a767840..e3ad403 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -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('related_stores').$defaultFn(() => []), + sortOrder: jsonText('sort_order').$defaultFn(() => []), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 03758b6..5bfa5d0 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -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 { imageUrl: productTagInfo.imageUrl, isDashboardTag: productTagInfo.isDashboardTag, relatedStores: productTagInfo.relatedStores, + sortOrder: productTagInfo.sortOrder, }) .from(productTagInfo) } diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index f1b4932..f653d6d 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -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 {