enh
This commit is contained in:
parent
d9a61818de
commit
d1ffdfbc21
24 changed files with 463 additions and 216 deletions
|
|
@ -463,7 +463,7 @@ export default function OrderDetails() {
|
||||||
{item.name}
|
{item.name}
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-xs text-gray-500`}>
|
<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>
|
</MyText>
|
||||||
<View style={tw`flex-row items-center mt-2 gap-3`}>
|
<View style={tw`flex-row items-center mt-2 gap-3`}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
|
||||||
|
|
@ -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 key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
|
<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>
|
</View>
|
||||||
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
|
<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}
|
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export default function AddProduct() {
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
|
isDeleted: variant.isDeleted || false,
|
||||||
isSuspended: variant.isSuspended || false,
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.attributes.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
|
|
@ -119,8 +120,9 @@ export default function AddProduct() {
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
|
isDeleted: false,
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
attributes: [{ featureName: '', featureValue: '' }],
|
attributes: [{ featureName: 'quantity', featureValue: '' }],
|
||||||
comboItems: [] as { skuId: number | string }[],
|
comboItems: [] as { skuId: number | string }[],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export default function EditProduct() {
|
||||||
flashPrice: sku.flashPrice || '',
|
flashPrice: sku.flashPrice || '',
|
||||||
isOffer: sku.isOffer || false,
|
isOffer: sku.isOffer || false,
|
||||||
isComboOnly: sku.isComboOnly || false,
|
isComboOnly: sku.isComboOnly || false,
|
||||||
|
isDeleted: sku.isDeleted || false,
|
||||||
isSuspended: sku.isSuspended || false,
|
isSuspended: sku.isSuspended || false,
|
||||||
attributes: (sku.features || []).map((f) => ({
|
attributes: (sku.features || []).map((f) => ({
|
||||||
featureName: f.featureName,
|
featureName: f.featureName,
|
||||||
|
|
@ -152,6 +153,7 @@ export default function EditProduct() {
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
|
isDeleted: variant.isDeleted || false,
|
||||||
isSuspended: variant.isSuspended || false,
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.attributes.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
|
|
|
||||||
|
|
@ -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 { View, TouchableOpacity, ScrollView, Alert } from 'react-native'
|
||||||
import { Formik, FieldArray } from 'formik'
|
import { Formik, FieldArray } from 'formik'
|
||||||
import * as Yup from 'yup'
|
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 MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||||
import { trpc } from '../trpc-client'
|
import { trpc } from '../trpc-client'
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface Variant {
|
||||||
flashPrice: string
|
flashPrice: string
|
||||||
isOffer: boolean
|
isOffer: boolean
|
||||||
isComboOnly: boolean
|
isComboOnly: boolean
|
||||||
|
isDeleted?: boolean
|
||||||
isSuspended: boolean
|
isSuspended: boolean
|
||||||
attributes: Attribute[]
|
attributes: Attribute[]
|
||||||
comboItems: { skuId: number | string }[]
|
comboItems: { skuId: number | string }[]
|
||||||
|
|
@ -49,6 +50,12 @@ interface ProductFormProps {
|
||||||
|
|
||||||
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
|
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 => ({
|
const defaultVariant = (): Variant => ({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -58,8 +65,9 @@ const defaultVariant = (): Variant => ({
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
|
isDeleted: false,
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
attributes: [defaultAttribute()],
|
attributes: [quantityAttribute()],
|
||||||
comboItems: [],
|
comboItems: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -160,9 +168,24 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
value: sku.id.toString(),
|
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 (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={formInitialValues}
|
||||||
validationSchema={productValidationSchema}
|
validationSchema={productValidationSchema}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
const images = variantImages.map((imgs) =>
|
const images = variantImages.map((imgs) =>
|
||||||
|
|
@ -252,18 +275,37 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{values.variants.map((variant, vIndex) => (
|
{values.variants.map((variant, vIndex) => {
|
||||||
<View key={vIndex} style={tw`border border-gray-300 rounded-xl p-4 mb-4`}>
|
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`}>
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
|
<View style={tw`flex-row items-center`}>
|
||||||
{values.variants.length > 1 && (
|
<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
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
remove(vIndex)
|
if (isExistingSku) {
|
||||||
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
|
// 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>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -288,29 +330,37 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
value={attr.featureName ?? ''}
|
value={attr.featureName ?? ''}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureName`)}
|
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureName`)}
|
||||||
|
editable={!isQuantityFeature(attr)}
|
||||||
|
style={isQuantityFeature(attr) ? tw`text-gray-400` : undefined}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
placeholder="Value"
|
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
|
||||||
value={attr.featureValue}
|
value={attr.featureValue}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
|
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{variant.attributes.length > 1 && (
|
{variant.attributes.length > 1 && !isQuantityFeature(attr) && (
|
||||||
<TouchableOpacity onPress={() => removeAttr(aIndex)}>
|
<TouchableOpacity onPress={() => removeAttr(aIndex)}>
|
||||||
<MaterialIcons name="close" size={18} color="#EF4444" />
|
<MaterialIcons name="close" size={18} color="#EF4444" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity
|
<View style={tw`flex-row items-center self-start`}>
|
||||||
onPress={() => pushAttr(defaultAttribute())}
|
<TouchableOpacity
|
||||||
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`}
|
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>
|
<MaterialIcons name="add" size={14} color="#4B5563" />
|
||||||
</TouchableOpacity>
|
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
|
@ -429,8 +479,24 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
label="SKU"
|
label="SKU"
|
||||||
value={ci.skuId}
|
value={ci.skuId}
|
||||||
options={skuOptions}
|
options={skuOptions.map((opt: { label: string; value: string }) => ({
|
||||||
onValueChange={(val) => setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)}
|
...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"
|
placeholder="Select SKU"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -447,7 +513,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ interface Product {
|
||||||
specialDeals: Array<{ quantity: string; price: string; validTill: Date }>
|
specialDeals: Array<{ quantity: string; price: string; validTill: Date }>
|
||||||
productTags: string[]
|
productTags: string[]
|
||||||
productType: string
|
productType: string
|
||||||
|
isComboOnly: boolean
|
||||||
comboItems: Array<{
|
comboItems: Array<{
|
||||||
skuId: number
|
skuId: number
|
||||||
skuName: string | null
|
skuName: string | null
|
||||||
|
|
@ -285,6 +286,7 @@ export async function getAllProducts(): Promise<Product[]> {
|
||||||
})),
|
})),
|
||||||
productTags: productTags,
|
productTags: productTags,
|
||||||
productType: product.productType || 'item',
|
productType: product.productType || 'item',
|
||||||
|
isComboOnly: product.isComboOnly,
|
||||||
comboItems: comboItems,
|
comboItems: comboItems,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ export const productRouter = router({
|
||||||
isOutOfStock: z.boolean().optional().default(false),
|
isOutOfStock: z.boolean().optional().default(false),
|
||||||
isOffer: z.boolean().optional().default(false),
|
isOffer: z.boolean().optional().default(false),
|
||||||
isComboOnly: z.boolean().optional().default(false),
|
isComboOnly: z.boolean().optional().default(false),
|
||||||
|
isDeleted: z.boolean().optional().default(false),
|
||||||
isSuspended: z.boolean().optional().default(false),
|
isSuspended: z.boolean().optional().default(false),
|
||||||
features: z.array(z.object({
|
features: z.array(z.object({
|
||||||
featureName: z.string().nullable().optional(),
|
featureName: z.string().nullable().optional(),
|
||||||
|
|
@ -232,6 +233,7 @@ export const productRouter = router({
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: sku.isOutOfStock,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
|
isDeleted: sku.isDeleted ?? false,
|
||||||
isSuspended: sku.isSuspended,
|
isSuspended: sku.isSuspended,
|
||||||
features: sku.features.map((f) => ({
|
features: sku.features.map((f) => ({
|
||||||
featureName: f.featureName?.trim() || null,
|
featureName: f.featureName?.trim() || null,
|
||||||
|
|
@ -292,6 +294,7 @@ export const productRouter = router({
|
||||||
isOutOfStock: z.boolean().optional().default(false),
|
isOutOfStock: z.boolean().optional().default(false),
|
||||||
isOffer: z.boolean().optional().default(false),
|
isOffer: z.boolean().optional().default(false),
|
||||||
isComboOnly: z.boolean().optional().default(false),
|
isComboOnly: z.boolean().optional().default(false),
|
||||||
|
isDeleted: z.boolean().optional().default(false),
|
||||||
isSuspended: z.boolean().optional().default(false),
|
isSuspended: z.boolean().optional().default(false),
|
||||||
features: z.array(z.object({
|
features: z.array(z.object({
|
||||||
featureName: z.string().nullable().optional(),
|
featureName: z.string().nullable().optional(),
|
||||||
|
|
@ -324,6 +327,7 @@ export const productRouter = router({
|
||||||
isOutOfStock: sku.isOutOfStock,
|
isOutOfStock: sku.isOutOfStock,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
|
isDeleted: sku.isDeleted ?? false,
|
||||||
isSuspended: sku.isSuspended,
|
isSuspended: sku.isSuspended,
|
||||||
features: sku.features.map((f) => ({
|
features: sku.features.map((f) => ({
|
||||||
featureName: f.featureName?.trim() || null,
|
featureName: f.featureName?.trim() || null,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ export async function scaffoldProducts() {
|
||||||
isOutOfStock: product.isOutOfStock,
|
isOutOfStock: product.isOutOfStock,
|
||||||
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||||
images: product.images,
|
images: product.images,
|
||||||
productType: product.productType || 'item'
|
productType: product.productType || 'item',
|
||||||
|
isComboOnly: product.isComboOnly,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ routes = [
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "freshyo-backend-dev"
|
database_name = "freshyo-backend-dev"
|
||||||
database_id = "b05f2d65-5496-45bc-9780-ad3cd6f83afa"
|
database_id = "b0c7a47d-3807-4e13-8b3e-fb43a45b02b2"
|
||||||
#database_name = "freshyo-dev"
|
#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_dir="../../packages/db_helper_sqlite/drizzle"
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default function FlashDeliveryOrderSuccess() {
|
||||||
<View style={tw`w-full gap-3`}>
|
<View style={tw`w-full gap-3`}>
|
||||||
<MyButton
|
<MyButton
|
||||||
textContent="Continue Shopping"
|
textContent="Continue Shopping"
|
||||||
onPress={() => router.replace("/(drawer)/(tabs)/flash-delivery")}
|
onPress={() => router.dismissTo("/(drawer)/(tabs)/flash-delivery")}
|
||||||
fillColor="brand500"
|
fillColor="brand500"
|
||||||
textColor="white1"
|
textColor="white1"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -66,7 +66,7 @@ export default function FlashDeliveryOrderSuccess() {
|
||||||
textContent="View My Orders"
|
textContent="View My Orders"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.dismissAll();
|
router.dismissAll();
|
||||||
router.replace("/(drawer)/(tabs)/me/my-orders");
|
router.push("/(drawer)/(tabs)/me/my-orders");
|
||||||
}}
|
}}
|
||||||
fillColor="gray1"
|
fillColor="gray1"
|
||||||
textColor="black1"
|
textColor="black1"
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default function OrderSuccess() {
|
||||||
<View style={tw`w-full gap-3`}>
|
<View style={tw`w-full gap-3`}>
|
||||||
<MyButton
|
<MyButton
|
||||||
textContent="Continue Shopping"
|
textContent="Continue Shopping"
|
||||||
onPress={() => router.replace("/(drawer)/(tabs)/home")}
|
onPress={() => router.dismissTo("/(drawer)/(tabs)/home")}
|
||||||
fillColor="brand500"
|
fillColor="brand500"
|
||||||
textColor="white1"
|
textColor="white1"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -66,7 +66,7 @@ export default function OrderSuccess() {
|
||||||
textContent="View My Orders"
|
textContent="View My Orders"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.dismissAll();
|
router.dismissAll();
|
||||||
router.replace("/(drawer)/(tabs)/me/my-orders");
|
router.push("/(drawer)/(tabs)/me/my-orders");
|
||||||
}}
|
}}
|
||||||
fillColor="gray1"
|
fillColor="gray1"
|
||||||
textColor="black1"
|
textColor="black1"
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export type MergedProduct = BaseProduct & {
|
||||||
isFlashAvailable: boolean
|
isFlashAvailable: boolean
|
||||||
isOutOfStock: boolean
|
isOutOfStock: boolean
|
||||||
isSuspended: boolean
|
isSuspended: boolean
|
||||||
|
isComboOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCacheUrl(filename: string): string | null {
|
function useCacheUrl(filename: string): string | null {
|
||||||
|
|
@ -127,7 +128,10 @@ export function useAllProducts() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const mergedProducts = React.useMemo(() => {
|
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> = {}
|
const availabilityById: Record<number, AvailabilityEntry> = {}
|
||||||
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
|
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
|
||||||
availabilityById[entry.id] = entry
|
availabilityById[entry.id] = entry
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ CREATE TABLE `product_skus` (
|
||||||
`images` text,
|
`images` text,
|
||||||
`is_offer` integer DEFAULT false NOT NULL,
|
`is_offer` integer DEFAULT false NOT NULL,
|
||||||
`is_combo_only` 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,
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,24 @@ import {
|
||||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm'
|
import { and, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||||
import { runBatched } from '../lib/run-batched'
|
import { runBatched } from '../lib/run-batched'
|
||||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
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 {
|
import type {
|
||||||
AdminProduct,
|
AdminProduct,
|
||||||
AdminProductGroupInfo,
|
AdminProductGroupInfo,
|
||||||
|
|
@ -68,6 +86,7 @@ interface CreateSkuInput {
|
||||||
isSuspended?: boolean
|
isSuspended?: boolean
|
||||||
isOffer?: boolean
|
isOffer?: boolean
|
||||||
isComboOnly?: boolean
|
isComboOnly?: boolean
|
||||||
|
isDeleted?: boolean
|
||||||
features: CreateSkuFeatureInput[]
|
features: CreateSkuFeatureInput[]
|
||||||
comboItems?: CreateComboItemInput[]
|
comboItems?: CreateComboItemInput[]
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +167,7 @@ const mapSku = (
|
||||||
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
|
isDeleted: sku.isDeleted ?? false,
|
||||||
createdAt: sku.createdAt,
|
createdAt: sku.createdAt,
|
||||||
features: features.map(mapSkuFeature),
|
features: features.map(mapSkuFeature),
|
||||||
comboItems: comboItems,
|
comboItems: comboItems,
|
||||||
|
|
@ -300,68 +320,88 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
|
||||||
throw new Error('Every SKU must have a quantity feature')
|
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({
|
const [product] = await tx.insert(productInfo).values({
|
||||||
name: productData.name,
|
name: productData.name,
|
||||||
shortDescription: productData.shortDescription ?? null,
|
shortDescription: productData.shortDescription ?? null,
|
||||||
longDescription: productData.longDescription ?? null,
|
longDescription: productData.longDescription ?? null,
|
||||||
storeId: productData.storeId ?? null,
|
storeId: productData.storeId ?? null,
|
||||||
incrementStep: productData.incrementStep ?? 1,
|
incrementStep: productData.incrementStep ?? 1,
|
||||||
productType: productData.productType ?? 'item',
|
productType: productData.productType ?? 'item',
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
const skuRows = await db.insert(productSkus).values(
|
const skuChunks = await runBatched(tx, skus, SKU_INSERT_CHUNK_SIZE, async (t, chunk) => {
|
||||||
skus.map((sku) => ({
|
return t.insert(productSkus).values(
|
||||||
productId: product.id,
|
chunk.map((sku) => ({
|
||||||
name: sku.name ?? null,
|
productId: product.id,
|
||||||
images: sku.images ?? null,
|
name: sku.name ?? null,
|
||||||
isOffer: sku.isOffer ?? false,
|
images: sku.images ?? null,
|
||||||
isComboOnly: sku.isComboOnly ?? false,
|
isOffer: sku.isOffer ?? false,
|
||||||
}))
|
isComboOnly: sku.isComboOnly ?? false,
|
||||||
).returning()
|
isDeleted: sku.isDeleted ?? false,
|
||||||
|
}))
|
||||||
|
).returning()
|
||||||
|
})
|
||||||
|
const skuRows = skuChunks.flat()
|
||||||
|
|
||||||
for (let i = 0; i < skuRows.length; i++) {
|
for (let i = 0; i < skuRows.length; i++) {
|
||||||
const skuRow = skuRows[i]
|
const skuRow = skuRows[i]
|
||||||
const sku = skus[i]
|
const sku = skus[i]
|
||||||
|
|
||||||
await db.insert(productMarketStats).values({
|
await tx.insert(productMarketStats).values({
|
||||||
skuId: skuRow.id,
|
skuId: skuRow.id,
|
||||||
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
||||||
ourPrice: sku.price != null ? String(sku.price) : '0',
|
ourPrice: sku.price != null ? String(sku.price) : '0',
|
||||||
isFlashAvailable: sku.isFlashAvailable ?? false,
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
||||||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
isOutOfStock: sku.isOutOfStock ?? false,
|
isOutOfStock: sku.isOutOfStock ?? false,
|
||||||
isSuspended: sku.isSuspended ?? 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)),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
await db.insert(skuFeatures).values(
|
console.error('createProduct failed:', error)
|
||||||
sku.features.map((f) => ({
|
throw new Error('Failed to create product. Please check the SKU details and try again.')
|
||||||
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)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,24 +416,14 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
|
|
||||||
const { skus, ...productData } = input
|
const { skus, ...productData } = input
|
||||||
|
|
||||||
await db.update(productInfo)
|
// Input validation outside the transaction so specific messages reach the UI.
|
||||||
.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))
|
|
||||||
|
|
||||||
if (skus !== undefined) {
|
if (skus !== undefined) {
|
||||||
if (skus.length === 0) {
|
if (skus.length === 0) {
|
||||||
throw new Error('At least one SKU is required')
|
throw new Error('At least one SKU is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuresHaveQuantity = skus.every((sku: any) =>
|
const featuresHaveQuantity = skus.every((sku: any) =>
|
||||||
sku.features.some((f: any) => f.featureName === 'quantity')
|
sku.features?.some((f: any) => f.featureName === 'quantity')
|
||||||
)
|
)
|
||||||
if (!featuresHaveQuantity) {
|
if (!featuresHaveQuantity) {
|
||||||
throw new Error('Every SKU must have a quantity feature')
|
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) {
|
try {
|
||||||
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
|
return await db.transaction(async (tx) => {
|
||||||
// Update existing SKU
|
await tx.update(productInfo)
|
||||||
await db.update(productSkus)
|
.set({
|
||||||
.set({
|
name: productData.name,
|
||||||
name: sku.name ?? null,
|
shortDescription: productData.shortDescription ?? null,
|
||||||
images: sku.images ?? null,
|
longDescription: productData.longDescription ?? null,
|
||||||
isOffer: sku.isOffer ?? false,
|
storeId: productData.storeId ?? null,
|
||||||
isComboOnly: sku.isComboOnly ?? false,
|
incrementStep: productData.incrementStep ?? 1,
|
||||||
})
|
productType: productData.productType,
|
||||||
.where(eq(productSkus.id, sku.id))
|
})
|
||||||
|
.where(eq(productInfo.id, id))
|
||||||
|
|
||||||
const existingMarketStats = await db.query.productMarketStats.findFirst({
|
if (skus !== undefined) {
|
||||||
where: eq(productMarketStats.skuId, sku.id),
|
const existingSkus = await tx.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.productId, id),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
})
|
})
|
||||||
const marketStatsValues = {
|
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id))
|
||||||
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
|
||||||
ourPrice: sku.price != null ? String(sku.price) : '0',
|
for (const sku of skus) {
|
||||||
isFlashAvailable: sku.isFlashAvailable ?? false,
|
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
|
||||||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
// Update existing SKU
|
||||||
isOutOfStock: sku.isOutOfStock ?? false,
|
await tx.update(productSkus)
|
||||||
isSuspended: sku.isSuspended ?? false,
|
.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({
|
const updatedProduct = await tx.query.productInfo.findFirst({
|
||||||
where: eq(productInfo.id, id),
|
where: eq(productInfo.id, id),
|
||||||
with: {
|
with: {
|
||||||
store: true,
|
store: true,
|
||||||
skus: {
|
skus: {
|
||||||
with: { features: true, marketStats: true },
|
with: { features: true, marketStats: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updatedProduct) {
|
if (!updatedProduct) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mapProduct(updatedProduct),
|
...mapProduct(updatedProduct),
|
||||||
store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
|
store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
|
||||||
skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)),
|
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.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export async function getActiveSlotsWithProducts(limit: number = 20): Promise<Ad
|
||||||
let skusData = await db.query.productSkus.findMany({
|
let skusData = await db.query.productSkus.findMany({
|
||||||
with: { features: true, product: true },
|
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
|
// Create a map for quick lookup
|
||||||
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
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 },
|
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 {
|
return {
|
||||||
...mapDeliverySlot(slot),
|
...mapDeliverySlot(slot),
|
||||||
deliverySequence: getNumberArray(slot.deliverySequence),
|
deliverySequence: getNumberArray(slot.deliverySequence),
|
||||||
|
|
|
||||||
|
|
@ -133,13 +133,15 @@ export async function getProductsByIds(skuIds: number[]): Promise<AdminVendorSni
|
||||||
const skus = await db.query.productSkus.findMany({
|
const skus = await db.query.productSkus.findMany({
|
||||||
where: inArray(productSkus.id, skuIds),
|
where: inArray(productSkus.id, skuIds),
|
||||||
with: { product: { columns: { name: true } } },
|
with: { product: { columns: { name: true } } },
|
||||||
columns: { id: true },
|
columns: { id: true, isDeleted: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return skus.map((sku) => ({
|
return skus
|
||||||
id: sku.id,
|
.filter((sku) => !sku.isDeleted)
|
||||||
name: sku.product?.name ?? 'Unknown',
|
.map((sku) => ({
|
||||||
})) as AdminVendorSnippetProduct[]
|
id: sku.id,
|
||||||
|
name: sku.product?.name ?? 'Unknown',
|
||||||
|
})) as AdminVendorSnippetProduct[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVendorSlotById(slotId: number): Promise<AdminDeliverySlot | null> {
|
export async function getVendorSlotById(slotId: number): Promise<AdminDeliverySlot | null> {
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ export const productSkus = sqliteTable('product_skus', {
|
||||||
images: jsonText<string[] | null>('images'),
|
images: jsonText<string[] | null>('images'),
|
||||||
isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false),
|
isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false),
|
||||||
isComboOnly: integer('is_combo_only', { 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`),
|
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export interface ProductBasicData {
|
||||||
isFlashAvailable: boolean
|
isFlashAvailable: boolean
|
||||||
flashPrice: string | null
|
flashPrice: string | null
|
||||||
productType: string
|
productType: string
|
||||||
|
isComboOnly: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailabilityCacheData {
|
export interface AvailabilityCacheData {
|
||||||
|
|
@ -126,7 +127,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
||||||
})
|
})
|
||||||
|
|
||||||
return skus
|
return skus
|
||||||
.filter((sku) => !sku.marketStats?.isSuspended)
|
.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted)
|
||||||
.map((sku) => {
|
.map((sku) => {
|
||||||
const features = sku.features || []
|
const features = sku.features || []
|
||||||
const marketStats = sku.marketStats
|
const marketStats = sku.marketStats
|
||||||
|
|
@ -148,15 +149,18 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
||||||
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
||||||
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
||||||
productType: sku.product?.productType ?? 'item',
|
productType: sku.product?.productType ?? 'item',
|
||||||
|
isComboOnly: sku.isComboOnly ?? false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAvailabilityForCache(): Promise<AvailabilityCacheData[]> {
|
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
|
return stats
|
||||||
.filter((stat) => !stat.isSuspended)
|
.filter((stat) => !stat.isSuspended && !stat.sku?.isDeleted)
|
||||||
.map((stat) => ({
|
.map((stat) => ({
|
||||||
id: stat.skuId,
|
id: stat.skuId,
|
||||||
price: stat.ourPrice ? String(stat.ourPrice) : '0',
|
price: stat.ourPrice ? String(stat.ourPrice) : '0',
|
||||||
|
|
@ -359,7 +363,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
||||||
marketStats: true,
|
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]))
|
const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
||||||
if (!sku) {
|
if (!sku) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (sku.marketStats?.isSuspended) {
|
if (sku.marketStats?.isSuspended || sku.isDeleted) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +152,9 @@ export async function getProductById(skuId: number) {
|
||||||
if (!sku) {
|
if (!sku) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (sku.isDeleted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const marketStats = sku.marketStats
|
const marketStats = sku.marketStats
|
||||||
|
|
||||||
|
|
@ -237,6 +240,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
|
||||||
return skus
|
return skus
|
||||||
.filter((sku) => {
|
.filter((sku) => {
|
||||||
if (sku.marketStats?.isSuspended) return false
|
if (sku.marketStats?.isSuspended) return false
|
||||||
|
if (sku.isDeleted) return false
|
||||||
if (!tagId) return true
|
if (!tagId) return true
|
||||||
return taggedProductIdSet.has(sku.productId)
|
return taggedProductIdSet.has(sku.productId)
|
||||||
})
|
})
|
||||||
|
|
@ -316,7 +320,7 @@ export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
||||||
})
|
})
|
||||||
|
|
||||||
return skus
|
return skus
|
||||||
.filter((sku) => !sku.marketStats?.isSuspended)
|
.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted)
|
||||||
.map((sku) => {
|
.map((sku) => {
|
||||||
const featureValues = (sku.features || []).map((f) => f.featureValue)
|
const featureValues = (sku.features || []).map((f) => f.featureValue)
|
||||||
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
|
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
|
||||||
|
|
@ -384,6 +388,7 @@ export async function getOffersAndCombos(): Promise<OffersPageData> {
|
||||||
|
|
||||||
for (const sku of skus) {
|
for (const sku of skus) {
|
||||||
if (sku.marketStats?.isSuspended) continue
|
if (sku.marketStats?.isSuspended) continue
|
||||||
|
if (sku.isDeleted) continue
|
||||||
if (sku.product?.productType === 'combo') {
|
if (sku.product?.productType === 'combo') {
|
||||||
combos.push(mapOffersPageProduct(sku))
|
combos.push(mapOffersPageProduct(sku))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> {
|
||||||
with: { product: true, marketStats: true },
|
with: { product: true, marketStats: true },
|
||||||
orderBy: asc(productSkus.id),
|
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>()
|
const skusByStore = new Map<number, typeof skus>()
|
||||||
for (const sku of activeSkus) {
|
for (const sku of activeSkus) {
|
||||||
|
|
@ -87,7 +87,7 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const products: UserStoreProductData[] = skus
|
const products: UserStoreProductData[] = skus
|
||||||
.filter((sku) => !sku.marketStats?.isSuspended)
|
.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted)
|
||||||
.map((sku) => {
|
.map((sku) => {
|
||||||
const features = sku.features || []
|
const features = sku.features || []
|
||||||
const marketStats = sku.marketStats
|
const marketStats = sku.marketStats
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,7 @@ export interface AdminSku {
|
||||||
flashPrice: string | null
|
flashPrice: string | null
|
||||||
isOffer: boolean
|
isOffer: boolean
|
||||||
isComboOnly: boolean
|
isComboOnly: boolean
|
||||||
|
isDeleted: boolean
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
features: AdminSkuFeature[]
|
features: AdminSkuFeature[]
|
||||||
comboItems: AdminProductComboItem[]
|
comboItems: AdminProductComboItem[]
|
||||||
|
|
@ -429,6 +430,7 @@ export interface CreateSkuInput {
|
||||||
images?: string[] | null
|
images?: string[] | null
|
||||||
isOutOfStock?: boolean
|
isOutOfStock?: boolean
|
||||||
isSuspended?: boolean
|
isSuspended?: boolean
|
||||||
|
isDeleted?: boolean
|
||||||
isFlashAvailable?: boolean
|
isFlashAvailable?: boolean
|
||||||
flashPrice?: number | string | null
|
flashPrice?: number | string | null
|
||||||
isOffer?: boolean
|
isOffer?: boolean
|
||||||
|
|
|
||||||
|
|
@ -236,10 +236,10 @@ const BottomDropdown: React.FC<BottomDropdownProps> = ({
|
||||||
const labelStr = option.label as string;
|
const labelStr = option.label as string;
|
||||||
const [code, rest] = labelStr.split(' - ', 2);
|
const [code, rest] = labelStr.split(' - ', 2);
|
||||||
return (
|
return (
|
||||||
<>
|
<Text>
|
||||||
<Text style={tw`font-bold`}>{code}</Text>
|
<Text style={tw`font-bold`}>{code}</Text>
|
||||||
{rest ? ` - ${rest}` : ''}
|
{rest ? ` - ${rest}` : ''}
|
||||||
</>
|
</Text>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { ReactNode, useState } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
import { Modal, View, TouchableOpacity, StyleSheet, Animated, Easing, Dimensions, TextInput, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
import { Modal, View, TouchableOpacity, StyleSheet, Animated, Easing, Dimensions, TextInput, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
import MyText from './text';
|
import MyText from './text';
|
||||||
import { MyButton } from 'common-ui';
|
import MyButton from './button';
|
||||||
import tw from "../lib/tailwind";
|
import tw from "../lib/tailwind";
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
|
|
|
||||||
64
packages/ui/src/components/info-dialog.tsx
Normal file
64
packages/ui/src/components/info-dialog.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue