This commit is contained in:
shafi54 2026-08-14 23:45:40 +05:30
parent d9a61818de
commit d1ffdfbc21
24 changed files with 463 additions and 216 deletions

View file

@ -463,7 +463,7 @@ export default function OrderDetails() {
{item.name}
</MyText>
<MyText style={tw`text-xs text-gray-500`}>
{Number(item.quantity)} x {item.productSize}{item.unit} × {item.price}
{Number(item.quantity)} x {item.unit} × {item.price}
</MyText>
<View style={tw`flex-row items-center mt-2 gap-3`}>
<TouchableOpacity

View file

@ -413,7 +413,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
<View style={tw`flex-row items-center`}>
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
<MyText style={tw`text-xs font-bold text-gray-600`}>{`${item.quantity} x ${item.productSize}${item.unit}`}</MyText>
<MyText style={tw`text-xs font-bold text-gray-600`}>{`${item.quantity} x ${item.unit}`}</MyText>
</View>
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}

View file

@ -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 }[],
},
],

View file

@ -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,

View file

@ -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<ProductFormRef, ProductFormProps>(({
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 (
<Formik
initialValues={initialValues}
initialValues={formInitialValues}
validationSchema={productValidationSchema}
onSubmit={(values) => {
const images = variantImages.map((imgs) =>
@ -252,18 +275,37 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</TouchableOpacity>
</View>
{values.variants.map((variant, vIndex) => (
<View key={vIndex} style={tw`border border-gray-300 rounded-xl p-4 mb-4`}>
{values.variants.map((variant, vIndex) => {
const isExistingSku = variant.id != null
const isMarkedDeleted = !!variant.isDeleted
return (
<View key={vIndex} style={[tw`border border-gray-300 rounded-xl p-4 mb-4`, isMarkedDeleted && tw`border-red-300 bg-red-50/50 opacity-80`]}>
<View style={tw`flex-row justify-between items-center mb-3`}>
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
{values.variants.length > 1 && (
<View style={tw`flex-row items-center`}>
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
{isMarkedDeleted && (
<View style={tw`ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200`}>
<MyText style={tw`text-[10px] font-bold text-red-700 uppercase`}>Deleted</MyText>
</View>
)}
</View>
{(values.variants.length > 1 || isExistingSku) && (
<TouchableOpacity
onPress={() => {
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))
}
}}
>
<MaterialIcons name="delete" size={20} color="#EF4444" />
<MaterialIcons
name={isMarkedDeleted ? 'restore' : 'delete'}
size={20}
color={isMarkedDeleted ? '#10B981' : '#EF4444'}
/>
</TouchableOpacity>
)}
</View>
@ -288,29 +330,37 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
placeholder="Name"
value={attr.featureName ?? ''}
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureName`)}
editable={!isQuantityFeature(attr)}
style={isQuantityFeature(attr) ? tw`text-gray-400` : undefined}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
placeholder="Value"
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue}
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
/>
</View>
{variant.attributes.length > 1 && (
{variant.attributes.length > 1 && !isQuantityFeature(attr) && (
<TouchableOpacity onPress={() => removeAttr(aIndex)}>
<MaterialIcons name="close" size={18} color="#EF4444" />
</TouchableOpacity>
)}
</View>
))}
<TouchableOpacity
onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
</TouchableOpacity>
<View style={tw`flex-row items-center self-start`}>
<TouchableOpacity
onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
</TouchableOpacity>
<InfoDialog
message="Quantity is mandatory feature. Add other features optionally with name. Features become part of name — ex: a feature with Mini as value becomes <ItemName> Mini"
style={tw`ml-1`}
/>
</View>
</View>
)}
</FieldArray>
@ -429,8 +479,24 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<BottomDropdown
label="SKU"
value={ci.skuId}
options={skuOptions}
onValueChange={(val) => 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"
/>
</View>
@ -447,7 +513,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</View>
)}
</View>
))}
)
})}
<TouchableOpacity
onPress={() => {

View file

@ -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<Product[]> {
})),
productTags: productTags,
productType: product.productType || 'item',
isComboOnly: product.isComboOnly,
comboItems: comboItems,
})
}

View file

@ -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,

View file

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

View file

@ -9,7 +9,7 @@ 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"
migrations_dir="../../packages/db_helper_sqlite/drizzle"

View file

@ -56,7 +56,7 @@ export default function FlashDeliveryOrderSuccess() {
<View style={tw`w-full gap-3`}>
<MyButton
textContent="Continue Shopping"
onPress={() => 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"

View file

@ -56,7 +56,7 @@ export default function OrderSuccess() {
<View style={tw`w-full gap-3`}>
<MyButton
textContent="Continue Shopping"
onPress={() => 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"

View file

@ -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<number, AvailabilityEntry> = {}
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
availabilityById[entry.id] = entry

View file

@ -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
);

View file

@ -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<number>()
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<AdminPro
throw new Error('Every SKU must have a quantity feature')
}
const { skus, ...productData } = input
try {
return await db.transaction(async (tx) => {
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<AdminProduc
const { skus, ...productData } = input
await db.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))
// Input validation outside the transaction so specific messages reach the UI.
if (skus !== undefined) {
if (skus.length === 0) {
throw new Error('At least one SKU is required')
}
const featuresHaveQuantity = skus.every((sku: any) =>
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<AdminProduc
}
}
}
}
for (const sku of skus) {
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
// Update existing SKU
await db.update(productSkus)
.set({
name: sku.name ?? null,
images: sku.images ?? null,
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
})
.where(eq(productSkus.id, sku.id))
try {
return await db.transaction(async (tx) => {
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.')
}
}

View file

@ -122,7 +122,7 @@ export async function getActiveSlotsWithProducts(limit: number = 20): Promise<Ad
let skusData = await db.query.productSkus.findMany({
with: { features: true, product: true },
})
skusData = skusData.filter((item: any) => 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<AdminSlotWit
columns: { id: true, images: true, name: true },
})
: []
skusData = skusData.filter((item: any) => skuIdSet.has(item.id))
skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.isDeleted)
return {
...mapDeliverySlot(slot),
deliverySequence: getNumberArray(slot.deliverySequence),

View file

@ -133,13 +133,15 @@ export async function getProductsByIds(skuIds: number[]): Promise<AdminVendorSni
const skus = await db.query.productSkus.findMany({
where: inArray(productSkus.id, skuIds),
with: { product: { columns: { name: true } } },
columns: { id: true },
columns: { id: true, isDeleted: true },
})
return skus.map((sku) => ({
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<AdminDeliverySlot | null> {

View file

@ -204,6 +204,7 @@ export const productSkus = sqliteTable('product_skus', {
images: jsonText<string[] | null>('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`),
})

View file

@ -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<ProductBasicData[]> {
})
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<ProductBasicData[]> {
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<AvailabilityCacheData[]> {
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<SlotWithProduct
marketStats: true,
},
})
skusData = skusData.filter((item: any) => 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]))

View file

@ -22,7 +22,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
if (!sku) {
return null
}
if (sku.marketStats?.isSuspended) {
if (sku.marketStats?.isSuspended || sku.isDeleted) {
return null
}
@ -152,6 +152,9 @@ export async function getProductById(skuId: number) {
if (!sku) {
return null
}
if (sku.isDeleted) {
return null
}
const marketStats = sku.marketStats
@ -237,6 +240,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
return skus
.filter((sku) => {
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<SkuSummary[]> {
})
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<OffersPageData> {
for (const sku of skus) {
if (sku.marketStats?.isSuspended) continue
if (sku.isDeleted) continue
if (sku.product?.productType === 'combo') {
combos.push(mapOffersPageProduct(sku))
}

View file

@ -24,7 +24,7 @@ export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> {
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<number, typeof skus>()
for (const sku of activeSkus) {
@ -87,7 +87,7 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
: []
const products: UserStoreProductData[] = skus
.filter((sku) => !sku.marketStats?.isSuspended)
.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted)
.map((sku) => {
const features = sku.features || []
const marketStats = sku.marketStats

View file

@ -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

View file

@ -236,10 +236,10 @@ const BottomDropdown: React.FC<BottomDropdownProps> = ({
const labelStr = option.label as string;
const [code, rest] = labelStr.split(' - ', 2);
return (
<>
<Text>
<Text style={tw`font-bold`}>{code}</Text>
{rest ? ` - ${rest}` : ''}
</>
</Text>
);
})()}
</Text>

View file

@ -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 {

View file

@ -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<InfoDialogProps> = ({
message,
component,
iconSize = 20,
iconColor = '#6B7280',
style,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<TouchableOpacity
onPress={() => setIsOpen(true)}
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={[styles.iconButton, style]}
>
<MaterialIcons name="info-outline" size={iconSize} color={iconColor} />
</TouchableOpacity>
<BottomDialog open={isOpen} onClose={() => setIsOpen(false)}>
<View style={tw`py-4 px-2`}>
{component ? (
component
) : (
<MyText style={tw`text-gray-700 text-sm leading-6`}>
{message ?? ''}
</MyText>
)}
</View>
</BottomDialog>
</>
);
};
const styles = StyleSheet.create({
iconButton: {
padding: 2,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});