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',
+ },
+});