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

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

View file

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

View file

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

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 { 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`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText> <MyText style={tw`font-bold text-gray-700`}>Variant {vIndex + 1}</MyText>
{values.variants.length > 1 && ( {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={() => {
if (isExistingSku) {
// Mark existing SKU as deleted (sent to backend on save).
setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted)
} else {
remove(vIndex) remove(vIndex)
setVariantImages((prev) => prev.filter((_, i) => i !== 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>
))} ))}
<View style={tw`flex-row items-center self-start`}>
<TouchableOpacity <TouchableOpacity
onPress={() => pushAttr(defaultAttribute())} onPress={() => pushAttr(defaultAttribute())}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`} style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
> >
<MaterialIcons name="add" size={14} color="#4B5563" /> <MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText> <MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
</TouchableOpacity> </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={() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,9 +320,11 @@ 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')
} }
try {
return await db.transaction(async (tx) => {
const { skus, ...productData } = input 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,
@ -311,21 +333,25 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
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(
chunk.map((sku) => ({
productId: product.id, productId: product.id,
name: sku.name ?? null, name: sku.name ?? null,
images: sku.images ?? null, images: sku.images ?? null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isDeleted: sku.isDeleted ?? false,
})) }))
).returning() ).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',
@ -335,25 +361,34 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
isSuspended: sku.isSuspended ?? false, isSuspended: sku.isSuspended ?? false,
}) })
await db.insert(skuFeatures).values( if (sku.features && sku.features.length > 0) {
sku.features.map((f) => ({ await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f) => ({
skuId: skuRow.id, skuId: skuRow.id,
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
})) }))
) )
})
}
if (sku.comboItems && sku.comboItems.length > 0) { if (sku.comboItems && sku.comboItems.length > 0) {
await db.insert(productCombos).values( const comboItems = dedupeComboItems(sku.comboItems)
sku.comboItems.map((ci: any) => ({ 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, comboSkuId: skuRow.id,
skuId: ci.skuId, skuId: ci.skuId,
})) }))
) )
})
}
} }
} }
const createdSkus = await db.query.productSkus.findMany({ const createdSkus = await tx.query.productSkus.findMany({
where: eq(productSkus.productId, product.id), where: eq(productSkus.productId, product.id),
with: { features: true, marketStats: true }, with: { features: true, marketStats: true },
}) })
@ -363,6 +398,11 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
store: null, store: null,
skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), 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.')
}
} }
export async function updateProduct(id: number, input: any): Promise<AdminProductWithRelations | null> { export async function updateProduct(id: number, input: any): Promise<AdminProductWithRelations | null> {
@ -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,20 +459,42 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
} }
} }
} }
}
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))
if (skus !== undefined) {
const existingSkus = await tx.query.productSkus.findMany({
where: eq(productSkus.productId, id),
columns: { id: true },
})
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id))
for (const sku of skus) { for (const sku of skus) {
if (sku.id != null && existingSkuIdSet.has(sku.id)) { if (sku.id != null && existingSkuIdSet.has(sku.id)) {
// Update existing SKU // Update existing SKU
await db.update(productSkus) await tx.update(productSkus)
.set({ .set({
name: sku.name ?? null, name: sku.name ?? null,
images: sku.images ?? null, images: sku.images ?? null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
...(sku.isDeleted !== undefined && { isDeleted: sku.isDeleted }),
}) })
.where(eq(productSkus.id, sku.id)) .where(eq(productSkus.id, sku.id))
const existingMarketStats = await db.query.productMarketStats.findFirst({ const existingMarketStats = await tx.query.productMarketStats.findFirst({
where: eq(productMarketStats.skuId, sku.id), where: eq(productMarketStats.skuId, sku.id),
columns: { id: true }, columns: { id: true },
}) })
@ -455,45 +507,55 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
isSuspended: sku.isSuspended ?? false, isSuspended: sku.isSuspended ?? false,
} }
if (existingMarketStats) { if (existingMarketStats) {
await db.update(productMarketStats) await tx.update(productMarketStats)
.set(marketStatsValues) .set(marketStatsValues)
.where(eq(productMarketStats.skuId, sku.id)) .where(eq(productMarketStats.skuId, sku.id))
} else { } else {
await db.insert(productMarketStats).values({ await tx.insert(productMarketStats).values({
skuId: sku.id, skuId: sku.id,
...marketStatsValues, ...marketStatsValues,
}) })
} }
await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) await tx.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id))
await db.insert(skuFeatures).values( if (sku.features && sku.features.length > 0) {
sku.features.map((f: any) => ({ await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f: any) => ({
skuId: sku.id, skuId: sku.id,
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
})) }))
) )
})
}
await db.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id)) await tx.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id))
if (sku.comboItems && sku.comboItems.length > 0) { if (sku.comboItems && sku.comboItems.length > 0) {
await db.insert(productCombos).values( const comboItems = dedupeComboItems(sku.comboItems)
sku.comboItems.map((ci: any) => ({ 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, comboSkuId: sku.id,
skuId: ci.skuId, skuId: ci.skuId,
})) }))
) )
})
}
} }
} else { } else {
// Insert new SKU // Insert new SKU
const [newSku] = await db.insert(productSkus).values({ const [newSku] = await tx.insert(productSkus).values({
productId: id, productId: id,
name: sku.name ?? null, name: sku.name ?? null,
images: sku.images ?? null, images: sku.images ?? null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isDeleted: sku.isDeleted ?? false,
}).returning() }).returning()
await db.insert(productMarketStats).values({ await tx.insert(productMarketStats).values({
skuId: newSku.id, skuId: newSku.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',
@ -503,18 +565,37 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
isSuspended: sku.isSuspended ?? false, isSuspended: sku.isSuspended ?? false,
}) })
await db.insert(skuFeatures).values( if (sku.features && sku.features.length > 0) {
sku.features.map((f: any) => ({ await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f: any) => ({
skuId: newSku.id, skuId: newSku.id,
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, 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,
}))
)
})
}
}
} }
} }
} }
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,
@ -533,6 +614,11 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
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.')
}
} }
export async function updateSlotProducts(slotId: string, productIds: string[]): Promise<AdminUpdateSlotProductsResult> { export async function updateSlotProducts(slotId: string, productIds: string[]): Promise<AdminUpdateSlotProductsResult> {

View file

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

View file

@ -133,10 +133,12 @@ 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
.filter((sku) => !sku.isDeleted)
.map((sku) => ({
id: sku.id, id: sku.id,
name: sku.product?.name ?? 'Unknown', name: sku.product?.name ?? 'Unknown',
})) as AdminVendorSnippetProduct[] })) as AdminVendorSnippetProduct[]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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