diff --git a/apps/admin-ui/app/(drawer)/dashboard/manage-orders/order-details/[id].tsx b/apps/admin-ui/app/(drawer)/dashboard/manage-orders/order-details/[id].tsx index 0b174d6..84e17f6 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/manage-orders/order-details/[id].tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/manage-orders/order-details/[id].tsx @@ -463,7 +463,7 @@ export default function OrderDetails() { {item.name} - {Number(item.quantity)} x {item.productSize}{item.unit} × ₹{item.price} + {Number(item.quantity)} x {item.unit} × ₹{item.price} void } - {`${item.quantity} x ${item.productSize}${item.unit}`} + {`${item.quantity} x ${item.unit}`} {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx index df118c3..d1b3979 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx @@ -75,6 +75,7 @@ export default function AddProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isDeleted: variant.isDeleted || false, isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, @@ -119,8 +120,9 @@ export default function AddProduct() { flashPrice: '', isOffer: false, isComboOnly: false, + isDeleted: false, isSuspended: false, - attributes: [{ featureName: '', featureValue: '' }], + attributes: [{ featureName: 'quantity', featureValue: '' }], comboItems: [] as { skuId: number | string }[], }, ], diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx index a6e06d2..27011b7 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx @@ -51,6 +51,7 @@ export default function EditProduct() { flashPrice: sku.flashPrice || '', isOffer: sku.isOffer || false, isComboOnly: sku.isComboOnly || false, + isDeleted: sku.isDeleted || false, isSuspended: sku.isSuspended || false, attributes: (sku.features || []).map((f) => ({ featureName: f.featureName, @@ -152,6 +153,7 @@ export default function EditProduct() { flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined, isOffer: variant.isOffer || false, isComboOnly: variant.isComboOnly || false, + isDeleted: variant.isDeleted || false, isSuspended: variant.isSuspended || false, features: variant.attributes.map((attr: any) => ({ featureName: attr.featureName, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 336c706..091b84d 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -1,8 +1,8 @@ -import React, { useState, useImperativeHandle, forwardRef } from 'react' +import React, { useState, useImperativeHandle, forwardRef, useMemo } from 'react' import { View, TouchableOpacity, ScrollView, Alert } from 'react-native' import { Formik, FieldArray } from 'formik' import * as Yup from 'yup' -import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui' +import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox, InfoDialog } from 'common-ui' import MaterialIcons from '@expo/vector-icons/MaterialIcons' import { trpc } from '../trpc-client' @@ -20,6 +20,7 @@ interface Variant { flashPrice: string isOffer: boolean isComboOnly: boolean + isDeleted?: boolean isSuspended: boolean attributes: Attribute[] comboItems: { skuId: number | string }[] @@ -49,6 +50,12 @@ interface ProductFormProps { const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' }) +// The quantity feature is auto-added as the first attribute of every SKU. +const quantityAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) + +const isQuantityFeature = (attr: Attribute): boolean => + (attr.featureName ?? '').trim().toLowerCase() === 'quantity' + const defaultVariant = (): Variant => ({ id: undefined, name: '', @@ -58,8 +65,9 @@ const defaultVariant = (): Variant => ({ flashPrice: '', isOffer: false, isComboOnly: false, + isDeleted: false, isSuspended: false, - attributes: [defaultAttribute()], + attributes: [quantityAttribute()], comboItems: [], }) @@ -160,9 +168,24 @@ const ProductForm = forwardRef(({ value: sku.id.toString(), })) + // Make sure every SKU starts with a 'quantity' feature (auto-added at the + // beginning if the provided initial values don't have one). + const formInitialValues = useMemo(() => { + return { + ...initialValues, + variants: (initialValues.variants || []).map((v) => { + const hasQuantity = (v.attributes || []).some( + (a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity' + ) + if (hasQuantity) return v + return { ...v, attributes: [quantityAttribute(), ...(v.attributes || [])] } + }), + } + }, [initialValues]) + return ( { const images = variantImages.map((imgs) => @@ -252,18 +275,37 @@ const ProductForm = forwardRef(({ - {values.variants.map((variant, vIndex) => ( - + {values.variants.map((variant, vIndex) => { + const isExistingSku = variant.id != null + const isMarkedDeleted = !!variant.isDeleted + return ( + - Variant {vIndex + 1} - {values.variants.length > 1 && ( + + Variant {vIndex + 1} + {isMarkedDeleted && ( + + Deleted + + )} + + {(values.variants.length > 1 || isExistingSku) && ( { - remove(vIndex) - setVariantImages((prev) => prev.filter((_, i) => i !== vIndex)) + if (isExistingSku) { + // Mark existing SKU as deleted (sent to backend on save). + setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted) + } else { + remove(vIndex) + setVariantImages((prev) => prev.filter((_, i) => i !== vIndex)) + } }} > - + )} @@ -288,29 +330,37 @@ const ProductForm = forwardRef(({ placeholder="Name" value={attr.featureName ?? ''} onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureName`)} + editable={!isQuantityFeature(attr)} + style={isQuantityFeature(attr) ? tw`text-gray-400` : undefined} /> - {variant.attributes.length > 1 && ( + {variant.attributes.length > 1 && !isQuantityFeature(attr) && ( removeAttr(aIndex)}> )} ))} - pushAttr(defaultAttribute())} - style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`} - > - - Add Feature - + + pushAttr(defaultAttribute())} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} + > + + Add Feature + + + )} @@ -429,8 +479,24 @@ const ProductForm = forwardRef(({ setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)} + options={skuOptions.map((opt: { label: string; value: string }) => ({ + ...opt, + // Disable SKUs already picked in another row of this combo. + disabled: (variant.comboItems || []).some( + (item, idx) => idx !== cIndex && String(item.skuId) === String(opt.value) + ), + }))} + onValueChange={(val) => { + const current = variant.comboItems || [] + const isDuplicate = current.some( + (item, idx) => idx !== cIndex && String(item.skuId) === String(val) + ) + if (isDuplicate) { + Alert.alert('Duplicate', 'This product is already in the combo') + return + } + setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val) + }} placeholder="Select SKU" /> @@ -447,7 +513,8 @@ const ProductForm = forwardRef(({ )} - ))} + ) + })} { diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index db04482..8c55021 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -37,6 +37,7 @@ interface Product { specialDeals: Array<{ quantity: string; price: string; validTill: Date }> productTags: string[] productType: string + isComboOnly: boolean comboItems: Array<{ skuId: number skuName: string | null @@ -285,6 +286,7 @@ export async function getAllProducts(): Promise { })), productTags: productTags, productType: product.productType || 'item', + isComboOnly: product.isComboOnly, comboItems: comboItems, }) } 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 3657d87..396dc75 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -202,6 +202,7 @@ export const productRouter = router({ isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isDeleted: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), @@ -232,6 +233,7 @@ export const productRouter = router({ isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isDeleted: sku.isDeleted ?? false, isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, @@ -292,6 +294,7 @@ export const productRouter = router({ isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), + isDeleted: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), features: z.array(z.object({ featureName: z.string().nullable().optional(), @@ -324,6 +327,7 @@ export const productRouter = router({ isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isDeleted: sku.isDeleted ?? false, isSuspended: sku.isSuspended, features: sku.features.map((f) => ({ featureName: f.featureName?.trim() || null, diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index fb39a72..8352def 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -56,7 +56,8 @@ export async function scaffoldProducts() { isOutOfStock: product.isOutOfStock, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, images: product.images, - productType: product.productType || 'item' + productType: product.productType || 'item', + isComboOnly: product.isComboOnly, }; }) ); diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index dfba29c..a86b89b 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -9,9 +9,9 @@ routes = [ [[d1_databases]] binding = "DB" database_name = "freshyo-backend-dev" -database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa" +database_id = "b0c7a47d-3807-4e13-8b3e-fb43a45b02b2" #database_name = "freshyo-dev" -#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" +#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_pattern="migration.sql" [durable_objects] @@ -108,4 +108,4 @@ upload_source_maps = true [[kv_namespaces]] binding="freshyo_otp_dev" -id="6f6dbada52584d7fb744c9c85bd0c1e5" \ No newline at end of file +id="6f6dbada52584d7fb744c9c85bd0c1e5" diff --git a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/order-success.tsx b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/order-success.tsx index 80fbbc2..ede37b2 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/order-success.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/flash-delivery/order-success.tsx @@ -56,7 +56,7 @@ export default function FlashDeliveryOrderSuccess() { router.replace("/(drawer)/(tabs)/flash-delivery")} + onPress={() => router.dismissTo("/(drawer)/(tabs)/flash-delivery")} fillColor="brand500" textColor="white1" fullWidth @@ -66,7 +66,7 @@ export default function FlashDeliveryOrderSuccess() { textContent="View My Orders" onPress={() => { router.dismissAll(); - router.replace("/(drawer)/(tabs)/me/my-orders"); + router.push("/(drawer)/(tabs)/me/my-orders"); }} fillColor="gray1" textColor="black1" diff --git a/apps/user-ui/app/(drawer)/(tabs)/home/order-success.tsx b/apps/user-ui/app/(drawer)/(tabs)/home/order-success.tsx index 1a1bb94..f6f2fff 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/home/order-success.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/home/order-success.tsx @@ -56,7 +56,7 @@ export default function OrderSuccess() { router.replace("/(drawer)/(tabs)/home")} + onPress={() => router.dismissTo("/(drawer)/(tabs)/home")} fillColor="brand500" textColor="white1" fullWidth @@ -66,7 +66,7 @@ export default function OrderSuccess() { textContent="View My Orders" onPress={() => { router.dismissAll(); - router.replace("/(drawer)/(tabs)/me/my-orders"); + router.push("/(drawer)/(tabs)/me/my-orders"); }} fillColor="gray1" textColor="black1" diff --git a/apps/user-ui/src/hooks/prominent-api-hooks.ts b/apps/user-ui/src/hooks/prominent-api-hooks.ts index fc94084..47ac112 100644 --- a/apps/user-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/user-ui/src/hooks/prominent-api-hooks.ts @@ -66,6 +66,7 @@ export type MergedProduct = BaseProduct & { isFlashAvailable: boolean isOutOfStock: boolean isSuspended: boolean + isComboOnly?: boolean } function useCacheUrl(filename: string): string | null { @@ -127,7 +128,10 @@ export function useAllProducts() { }) const mergedProducts = React.useMemo(() => { - const rawProducts = productsQuery.data?.products || [] + // Combo-only SKUs stay in products.json but are hidden from the user app. + const rawProducts = (productsQuery.data?.products || []).filter( + (p: any) => !p.isComboOnly + ) const availabilityById: Record = {} availabilityData?.availability?.forEach((entry: AvailabilityEntry) => { availabilityById[entry.id] = entry diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index 3d49023..5f681dc 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -12,6 +12,7 @@ CREATE TABLE `product_skus` ( `images` text, `is_offer` integer DEFAULT false NOT NULL, `is_combo_only` integer DEFAULT false NOT NULL, + `is_deleted` integer DEFAULT false NOT NULL, `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action ); diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 29915f0..e7e64f8 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -27,6 +27,24 @@ import { import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { runBatched } from '../lib/run-batched' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +// Chunk sizes for multi-row inserts so no single statement exceeds SQLite's +// 999-bind-parameter limit (chunk size × params-per-row must stay under 999). +const SKU_INSERT_CHUNK_SIZE = 100 // 6 params per SKU row +const FEATURE_INSERT_CHUNK_SIZE = 200 // 3 params per feature row +const COMBO_ITEM_INSERT_CHUNK_SIZE = 400 // 2 params per combo-item row + +// product_combos has a UNIQUE index on (comboSkuId, skuId) — drop duplicates +// (by skuId) before inserting so a repeated pick doesn't fail the insert. +const dedupeComboItems = (items: any[]): any[] => { + const seen = new Set() + return (items || []).filter((ci: any) => { + const id = Number(ci.skuId) + if (!id || seen.has(id)) return false + seen.add(id) + return true + }) +} import type { AdminProduct, AdminProductGroupInfo, @@ -68,6 +86,7 @@ interface CreateSkuInput { isSuspended?: boolean isOffer?: boolean isComboOnly?: boolean + isDeleted?: boolean features: CreateSkuFeatureInput[] comboItems?: CreateComboItemInput[] } @@ -148,6 +167,7 @@ const mapSku = ( flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, + isDeleted: sku.isDeleted ?? false, createdAt: sku.createdAt, features: features.map(mapSkuFeature), comboItems: comboItems, @@ -300,68 +320,88 @@ export async function createProduct(input: CreateProductInput): Promise { + const { skus, ...productData } = input - const [product] = await db.insert(productInfo).values({ - name: productData.name, - shortDescription: productData.shortDescription ?? null, - longDescription: productData.longDescription ?? null, - storeId: productData.storeId ?? null, - incrementStep: productData.incrementStep ?? 1, - productType: productData.productType ?? 'item', - }).returning() + const [product] = await tx.insert(productInfo).values({ + name: productData.name, + shortDescription: productData.shortDescription ?? null, + longDescription: productData.longDescription ?? null, + storeId: productData.storeId ?? null, + incrementStep: productData.incrementStep ?? 1, + productType: productData.productType ?? 'item', + }).returning() - const skuRows = await db.insert(productSkus).values( - skus.map((sku) => ({ - productId: product.id, - name: sku.name ?? null, - images: sku.images ?? null, - isOffer: sku.isOffer ?? false, - isComboOnly: sku.isComboOnly ?? false, - })) - ).returning() + const skuChunks = await runBatched(tx, skus, SKU_INSERT_CHUNK_SIZE, async (t, chunk) => { + return t.insert(productSkus).values( + chunk.map((sku) => ({ + productId: product.id, + name: sku.name ?? null, + images: sku.images ?? null, + isOffer: sku.isOffer ?? false, + isComboOnly: sku.isComboOnly ?? false, + isDeleted: sku.isDeleted ?? false, + })) + ).returning() + }) + const skuRows = skuChunks.flat() - for (let i = 0; i < skuRows.length; i++) { - const skuRow = skuRows[i] - const sku = skus[i] + for (let i = 0; i < skuRows.length; i++) { + const skuRow = skuRows[i] + const sku = skus[i] - await db.insert(productMarketStats).values({ - skuId: skuRow.id, - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, - ourPrice: sku.price != null ? String(sku.price) : '0', - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, - isOutOfStock: sku.isOutOfStock ?? false, - isSuspended: sku.isSuspended ?? false, + await tx.insert(productMarketStats).values({ + skuId: skuRow.id, + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + ourPrice: sku.price != null ? String(sku.price) : '0', + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + isOutOfStock: sku.isOutOfStock ?? false, + isSuspended: sku.isSuspended ?? false, + }) + + if (sku.features && sku.features.length > 0) { + await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(skuFeatures).values( + chunk.map((f) => ({ + skuId: skuRow.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + }) + } + + if (sku.comboItems && sku.comboItems.length > 0) { + const comboItems = dedupeComboItems(sku.comboItems) + if (comboItems.length > 0) { + await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(productCombos).values( + chunk.map((ci: any) => ({ + comboSkuId: skuRow.id, + skuId: ci.skuId, + })) + ) + }) + } + } + } + + const createdSkus = await tx.query.productSkus.findMany({ + where: eq(productSkus.productId, product.id), + with: { features: true, marketStats: true }, + }) + + return { + ...mapProduct(product), + store: null, + skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), + } }) - - await db.insert(skuFeatures).values( - sku.features.map((f) => ({ - skuId: skuRow.id, - featureName: f.featureName, - featureValue: f.featureValue, - })) - ) - - if (sku.comboItems && sku.comboItems.length > 0) { - await db.insert(productCombos).values( - sku.comboItems.map((ci: any) => ({ - comboSkuId: skuRow.id, - skuId: ci.skuId, - })) - ) - } - } - - const createdSkus = await db.query.productSkus.findMany({ - where: eq(productSkus.productId, product.id), - with: { features: true, marketStats: true }, - }) - - return { - ...mapProduct(product), - store: null, - skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), + } catch (error) { + console.error('createProduct failed:', error) + throw new Error('Failed to create product. Please check the SKU details and try again.') } } @@ -376,24 +416,14 @@ export async function updateProduct(id: number, input: any): Promise - sku.features.some((f: any) => f.featureName === 'quantity') + sku.features?.some((f: any) => f.featureName === 'quantity') ) if (!featuresHaveQuantity) { throw new Error('Every SKU must have a quantity feature') @@ -429,109 +459,165 @@ export async function updateProduct(id: number, input: any): Promise { + await tx.update(productInfo) + .set({ + name: productData.name, + shortDescription: productData.shortDescription ?? null, + longDescription: productData.longDescription ?? null, + storeId: productData.storeId ?? null, + incrementStep: productData.incrementStep ?? 1, + productType: productData.productType, + }) + .where(eq(productInfo.id, id)) - const existingMarketStats = await db.query.productMarketStats.findFirst({ - where: eq(productMarketStats.skuId, sku.id), + if (skus !== undefined) { + const existingSkus = await tx.query.productSkus.findMany({ + where: eq(productSkus.productId, id), columns: { id: true }, }) - const marketStatsValues = { - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, - ourPrice: sku.price != null ? String(sku.price) : '0', - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, - isOutOfStock: sku.isOutOfStock ?? false, - isSuspended: sku.isSuspended ?? false, + const existingSkuIdSet = new Set(existingSkus.map((s) => s.id)) + + for (const sku of skus) { + if (sku.id != null && existingSkuIdSet.has(sku.id)) { + // Update existing SKU + await tx.update(productSkus) + .set({ + name: sku.name ?? null, + images: sku.images ?? null, + isOffer: sku.isOffer ?? false, + isComboOnly: sku.isComboOnly ?? false, + ...(sku.isDeleted !== undefined && { isDeleted: sku.isDeleted }), + }) + .where(eq(productSkus.id, sku.id)) + + const existingMarketStats = await tx.query.productMarketStats.findFirst({ + where: eq(productMarketStats.skuId, sku.id), + columns: { id: true }, + }) + const marketStatsValues = { + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + ourPrice: sku.price != null ? String(sku.price) : '0', + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + isOutOfStock: sku.isOutOfStock ?? false, + isSuspended: sku.isSuspended ?? false, + } + if (existingMarketStats) { + await tx.update(productMarketStats) + .set(marketStatsValues) + .where(eq(productMarketStats.skuId, sku.id)) + } else { + await tx.insert(productMarketStats).values({ + skuId: sku.id, + ...marketStatsValues, + }) + } + + await tx.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) + if (sku.features && sku.features.length > 0) { + await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(skuFeatures).values( + chunk.map((f: any) => ({ + skuId: sku.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + }) + } + + await tx.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id)) + if (sku.comboItems && sku.comboItems.length > 0) { + const comboItems = dedupeComboItems(sku.comboItems) + if (comboItems.length > 0) { + await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(productCombos).values( + chunk.map((ci: any) => ({ + comboSkuId: sku.id, + skuId: ci.skuId, + })) + ) + }) + } + } + } else { + // Insert new SKU + const [newSku] = await tx.insert(productSkus).values({ + productId: id, + name: sku.name ?? null, + images: sku.images ?? null, + isOffer: sku.isOffer ?? false, + isComboOnly: sku.isComboOnly ?? false, + isDeleted: sku.isDeleted ?? false, + }).returning() + + await tx.insert(productMarketStats).values({ + skuId: newSku.id, + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + ourPrice: sku.price != null ? String(sku.price) : '0', + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + isOutOfStock: sku.isOutOfStock ?? false, + isSuspended: sku.isSuspended ?? false, + }) + + if (sku.features && sku.features.length > 0) { + await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(skuFeatures).values( + chunk.map((f: any) => ({ + skuId: newSku.id, + featureName: f.featureName, + featureValue: f.featureValue, + })) + ) + }) + } + + // New SKUs also save their combo items (with dedupe). + if (sku.comboItems && sku.comboItems.length > 0) { + const comboItems = dedupeComboItems(sku.comboItems) + if (comboItems.length > 0) { + await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => { + await t.insert(productCombos).values( + chunk.map((ci: any) => ({ + comboSkuId: newSku.id, + skuId: ci.skuId, + })) + ) + }) + } + } + } } - if (existingMarketStats) { - await db.update(productMarketStats) - .set(marketStatsValues) - .where(eq(productMarketStats.skuId, sku.id)) - } else { - await db.insert(productMarketStats).values({ - skuId: sku.id, - ...marketStatsValues, - }) - } - - await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) - await db.insert(skuFeatures).values( - sku.features.map((f: any) => ({ - skuId: sku.id, - featureName: f.featureName, - featureValue: f.featureValue, - })) - ) - - await db.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id)) - if (sku.comboItems && sku.comboItems.length > 0) { - await db.insert(productCombos).values( - sku.comboItems.map((ci: any) => ({ - comboSkuId: sku.id, - skuId: ci.skuId, - })) - ) - } - } else { - // Insert new SKU - const [newSku] = await db.insert(productSkus).values({ - productId: id, - name: sku.name ?? null, - images: sku.images ?? null, - isOffer: sku.isOffer ?? false, - isComboOnly: sku.isComboOnly ?? false, - }).returning() - - await db.insert(productMarketStats).values({ - skuId: newSku.id, - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, - ourPrice: sku.price != null ? String(sku.price) : '0', - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, - isOutOfStock: sku.isOutOfStock ?? false, - isSuspended: sku.isSuspended ?? false, - }) - - await db.insert(skuFeatures).values( - sku.features.map((f: any) => ({ - skuId: newSku.id, - featureName: f.featureName, - featureValue: f.featureValue, - })) - ) } - } - } - const updatedProduct = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), - with: { - store: true, - skus: { - with: { features: true, marketStats: true }, - }, - }, - }) + const updatedProduct = await tx.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + store: true, + skus: { + with: { features: true, marketStats: true }, + }, + }, + }) - if (!updatedProduct) { - return null - } + if (!updatedProduct) { + return null + } - return { - ...mapProduct(updatedProduct), - store: updatedProduct.store ? mapStore(updatedProduct.store) : null, - skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)), + return { + ...mapProduct(updatedProduct), + store: updatedProduct.store ? mapStore(updatedProduct.store) : null, + skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)), + } + }) + } catch (error) { + console.error('updateProduct failed:', error) + throw new Error('Failed to update product. Please check the SKU details and try again.') } } diff --git a/packages/db_helper_sqlite/src/admin-apis/slots.ts b/packages/db_helper_sqlite/src/admin-apis/slots.ts index fe2cc78..7c56140 100644 --- a/packages/db_helper_sqlite/src/admin-apis/slots.ts +++ b/packages/db_helper_sqlite/src/admin-apis/slots.ts @@ -122,7 +122,7 @@ export async function getActiveSlotsWithProducts(limit: number = 20): Promise skuIdsSet.has(item.id)) + skusData = skusData.filter((item: any) => skuIdsSet.has(item.id) && !item.isDeleted) // Create a map for quick lookup const skuMap = new Map(skusData.map((s: any) => [s.id, s])) @@ -202,7 +202,7 @@ export async function getSlotByIdWithRelations(id: number): Promise skuIdSet.has(item.id)) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.isDeleted) return { ...mapDeliverySlot(slot), deliverySequence: getNumberArray(slot.deliverySequence), diff --git a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts index 07a58ef..3a4c76a 100644 --- a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts +++ b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts @@ -133,13 +133,15 @@ export async function getProductsByIds(skuIds: number[]): Promise ({ - id: sku.id, - name: sku.product?.name ?? 'Unknown', - })) as AdminVendorSnippetProduct[] + return skus + .filter((sku) => !sku.isDeleted) + .map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? 'Unknown', + })) as AdminVendorSnippetProduct[] } export async function getVendorSlotById(slotId: number): Promise { diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index e3ad403..a6a5c63 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -204,6 +204,7 @@ export const productSkus = sqliteTable('product_skus', { images: jsonText('images'), isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false), isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false), + isDeleted: integer('is_deleted', { mode: 'boolean' }).notNull().default(false), 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 5bfa5d0..b89bd94 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -60,6 +60,7 @@ export interface ProductBasicData { isFlashAvailable: boolean flashPrice: string | null productType: string + isComboOnly: boolean } export interface AvailabilityCacheData { @@ -126,7 +127,7 @@ export async function getAllProductsForCache(): Promise { }) return skus - .filter((sku) => !sku.marketStats?.isSuspended) + .filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted) .map((sku) => { const features = sku.features || [] const marketStats = sku.marketStats @@ -148,15 +149,18 @@ export async function getAllProductsForCache(): Promise { isFlashAvailable: marketStats?.isFlashAvailable ?? false, flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, productType: sku.product?.productType ?? 'item', + isComboOnly: sku.isComboOnly ?? false, } }) } export async function getAvailabilityForCache(): Promise { - const stats = await db.query.productMarketStats.findMany({}) + const stats = await db.query.productMarketStats.findMany({ + with: { sku: { columns: { isDeleted: true } } }, + }) return stats - .filter((stat) => !stat.isSuspended) + .filter((stat) => !stat.isSuspended && !stat.sku?.isDeleted) .map((stat) => ({ id: stat.skuId, price: stat.ourPrice ? String(stat.ourPrice) : '0', @@ -359,7 +363,7 @@ export async function getAllSlotsWithProductsForCache(): Promise skuIdSet.has(item.id) && !item.marketStats?.isSuspended) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.marketStats?.isSuspended && !item.isDeleted) } const skuMap = new Map(skusData.map((s: any) => [s.id, s])) diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index d49ac3c..8cd373b 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -22,7 +22,7 @@ export async function getProductDetailById(skuId: number): Promise { if (sku.marketStats?.isSuspended) return false + if (sku.isDeleted) return false if (!tagId) return true return taggedProductIdSet.has(sku.productId) }) @@ -316,7 +320,7 @@ export async function getAllSkusSummary(): Promise { }) return skus - .filter((sku) => !sku.marketStats?.isSuspended) + .filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted) .map((sku) => { const featureValues = (sku.features || []).map((f) => f.featureValue) const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') @@ -384,6 +388,7 @@ export async function getOffersAndCombos(): Promise { for (const sku of skus) { if (sku.marketStats?.isSuspended) continue + if (sku.isDeleted) continue if (sku.product?.productType === 'combo') { combos.push(mapOffersPageProduct(sku)) } diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index b31680d..ad75020 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -24,7 +24,7 @@ export async function getStoreSummaries(): Promise { with: { product: true, marketStats: true }, orderBy: asc(productSkus.id), }) - const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended) + const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted) const skusByStore = new Map() for (const sku of activeSkus) { @@ -87,7 +87,7 @@ export async function getStoreDetail(storeId: number): Promise !sku.marketStats?.isSuspended) + .filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted) .map((sku) => { const features = sku.features || [] const marketStats = sku.marketStats diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index f653d6d..a1d122b 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -390,6 +390,7 @@ export interface AdminSku { flashPrice: string | null isOffer: boolean isComboOnly: boolean + isDeleted: boolean createdAt: Date features: AdminSkuFeature[] comboItems: AdminProductComboItem[] @@ -429,6 +430,7 @@ export interface CreateSkuInput { images?: string[] | null isOutOfStock?: boolean isSuspended?: boolean + isDeleted?: boolean isFlashAvailable?: boolean flashPrice?: number | string | null isOffer?: boolean diff --git a/packages/ui/src/components/bottom-dropdown.tsx b/packages/ui/src/components/bottom-dropdown.tsx index 3d70e14..2d4fbd9 100644 --- a/packages/ui/src/components/bottom-dropdown.tsx +++ b/packages/ui/src/components/bottom-dropdown.tsx @@ -236,10 +236,10 @@ const BottomDropdown: React.FC = ({ const labelStr = option.label as string; const [code, rest] = labelStr.split(' - ', 2); return ( - <> + {code} {rest ? ` - ${rest}` : ''} - + ); })()} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 00ee6cf..f2c2ca9 100755 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, useState } from 'react'; import { Modal, View, TouchableOpacity, StyleSheet, Animated, Easing, Dimensions, TextInput, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import MyText from './text'; -import { MyButton } from 'common-ui'; +import MyButton from './button'; import tw from "../lib/tailwind"; interface DialogProps { diff --git a/packages/ui/src/components/info-dialog.tsx b/packages/ui/src/components/info-dialog.tsx new file mode 100644 index 0000000..cb422c0 --- /dev/null +++ b/packages/ui/src/components/info-dialog.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { BottomDialog } from './dialog'; +import MyText from './text'; +import tw from '../lib/tailwind'; + +interface InfoDialogProps { + /** Info text shown in the bottom dialog. */ + message?: string; + /** Custom content shown instead of the message. */ + component?: React.ReactNode; + iconSize?: number; + iconColor?: string; + style?: any; +} + +/** + * Renders a small info icon. Tapping it opens a bottom dialog + * showing either `message` (as text) or `component` (custom content). + */ +export const InfoDialog: React.FC = ({ + message, + component, + iconSize = 20, + iconColor = '#6B7280', + style, +}) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + setIsOpen(true)} + activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={[styles.iconButton, style]} + > + + + + setIsOpen(false)}> + + {component ? ( + component + ) : ( + + {message ?? ''} + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + iconButton: { + padding: 2, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, +});