This commit is contained in:
shafi54 2026-08-02 10:03:21 +05:30
parent 6d67a1e759
commit b5cdc53e50
17 changed files with 400 additions and 123 deletions

View file

@ -14,6 +14,28 @@ export default function AddProduct() {
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => { const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
try { try {
for (const variant of values.variants) {
const price = parseFloat(variant.price)
if (isNaN(price) || price <= 0) {
Alert.alert('Error', 'Please enter a valid price for every variant')
return
}
}
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const attributes = variant.attributes || []
const signature = attributes
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
if (seenSignatures.has(signature)) {
Alert.alert('Error', 'Two variants have the same attributes')
return
}
seenSignatures.add(signature)
}
const allBlobs: { blob: Blob; mimeType: string }[] = [] const allBlobs: { blob: Blob; mimeType: string }[] = []
const imageCounts: number[] = variantImages.map((imgs) => imgs.length) const imageCounts: number[] = variantImages.map((imgs) => imgs.length)

View file

@ -78,6 +78,28 @@ export default function EditProduct() {
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => { const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
try { try {
for (const variant of values.variants) {
const price = parseFloat(variant.price)
if (isNaN(price) || price <= 0) {
Alert.alert('Error', 'Please enter a valid price for every variant')
return
}
}
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const attributes = variant.attributes || []
const signature = attributes
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
if (seenSignatures.has(signature)) {
Alert.alert('Error', 'Two variants have the same attributes')
return
}
seenSignatures.add(signature)
}
const allBlobs: { blob: Blob; mimeType: string }[] = [] const allBlobs: { blob: Blob; mimeType: string }[] = []
const imageCounts: number[] = variantImages.map((imgs) => const imageCounts: number[] = variantImages.map((imgs) =>
imgs.filter((img) => img.mimeType !== null).length imgs.filter((img) => img.mimeType !== null).length

View file

@ -1,6 +1,7 @@
import React, { useState, useImperativeHandle, forwardRef } from 'react' import React, { useState, useImperativeHandle, forwardRef } from 'react'
import { View, TouchableOpacity, ScrollView } 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 { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui' import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } 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'
@ -60,6 +61,60 @@ const defaultVariant = (): Variant => ({
comboItems: [], comboItems: [],
}) })
const variantSignature = (attributes: Attribute[]): string =>
attributes
.map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
const productValidationSchema = Yup.object().shape({
name: Yup.string().required('Product name is required'),
storeId: Yup.number().required('Store is required').min(1, 'Store is required'),
productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'),
variants: Yup.array()
.min(1, 'At least one variant is required')
.of(
Yup.object().shape({
price: Yup.number()
.typeError('Price must be a number')
.positive('Price must be a positive number')
.required('Price is required'),
marketPrice: Yup.number()
.typeError('Market price must be a number')
.min(0, 'Market price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
flashPrice: Yup.number()
.typeError('Flash price must be a number')
.min(0, 'Flash price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
attributes: Yup.array()
.min(1, 'At least one attribute is required')
.of(
Yup.object().shape({
featureValue: Yup.string().required('Value is required'),
})
),
})
)
.test('unique-variants', 'Two variants have the same attributes', function (variants) {
if (!Array.isArray(variants)) return true
const seen = new Set<string>()
for (const variant of variants) {
const attrs = variant?.attributes || []
const signature = variantSignature(attrs as Attribute[])
if (seen.has(signature)) {
return this.createError({ message: 'Two variants have the same attributes' })
}
seen.add(signature)
}
return true
}),
})
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode, mode,
initialValues, initialValues,
@ -93,6 +148,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
validationSchema={productValidationSchema}
onSubmit={(values) => { onSubmit={(values) => {
const images = variantImages.map((imgs) => const images = variantImages.map((imgs) =>
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType })) imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
@ -113,7 +169,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
}} }}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue }) => ( {({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
return (
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
<MyTextInput <MyTextInput
topLabel="Product Name" topLabel="Product Name"
@ -363,7 +420,25 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</FieldArray> </FieldArray>
<TouchableOpacity <TouchableOpacity
onPress={() => handleSubmit()} onPress={async () => {
const validationErrors = await validateForm()
if (Object.keys(validationErrors).length > 0) {
const variantsError = validationErrors.variants
const firstVariantErrors = Array.isArray(variantsError)
? (variantsError[0] as Record<string, unknown> | undefined) || {}
: {}
const message = (firstVariantErrors.price as string | undefined)
|| (firstVariantErrors.marketPrice as string | undefined)
|| (firstVariantErrors.flashPrice as string | undefined)
|| (firstVariantErrors.attributes as string | undefined)
|| validationErrors.name
|| validationErrors.storeId
|| variantsError
Alert.alert('Check your form', String(message || 'Please fix the highlighted fields'))
return
}
handleSubmit()
}}
disabled={isLoading} disabled={isLoading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-3 rounded-lg shadow-lg items-center mt-4 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
@ -372,7 +447,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
)} )
}}
</Formik> </Formik>
) )
}) })

View file

@ -1,6 +1,8 @@
import { import {
getOrdersByIdsWithFullData, getOrdersByIdsWithFullData,
getOrderByIdWithFullData, getOrderByIdWithFullData,
composeSkuName,
composeUnitNotation,
} from '@/src/dbService' } from '@/src/dbService'
import { sendTelegramMessage } from '@/src/lib/telegram-service' import { sendTelegramMessage } from '@/src/lib/telegram-service'
import { queueDataPusher } from '@/src/lib/queue-data-pusher' import { queueDataPusher } from '@/src/lib/queue-data-pusher'
@ -49,10 +51,11 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
message += '📦 <b>Items:</b>\n'; message += '📦 <b>Items:</b>\n';
order.orderItems?.forEach((item: any) => { order.orderItems?.forEach((item: any) => {
const sku = item.sku const sku = item.sku
const features = (sku?.features || []).map((f: any) => f.featureValue).join(' ') const features = sku?.features || []
message += `${sku?.product?.name || 'Unknown'} ${features} x${item.quantity}\n`; const name = composeSkuName(sku?.product?.name || 'Unknown', features)
const unit = composeUnitNotation(features)
message += `${name} ${unit} x${item.quantity}\n`;
}); });
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`; message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
message += `🚚 <b>Delivery:</b> ${ message += `🚚 <b>Delivery:</b> ${

View file

@ -16,6 +16,8 @@ import {
getVendorOrders as getVendorOrdersInDb, getVendorOrders as getVendorOrdersInDb,
updateVendorOrderItemPackaging as updateVendorOrderItemPackagingInDb, updateVendorOrderItemPackaging as updateVendorOrderItemPackagingInDb,
getSlotsAfterDate as getSlotsAfterDateInDb, getSlotsAfterDate as getSlotsAfterDateInDb,
composeSkuName,
composeUnitNotation,
} from '@/src/dbService' } from '@/src/dbService'
import type { import type {
AdminVendorSnippet, AdminVendorSnippet,
@ -417,11 +419,11 @@ export const vendorSnippetsRouter = router({
const products = attachedOrderItems.map(item => ({ const products = attachedOrderItems.map(item => ({
orderItemId: item.id, orderItemId: item.id,
productId: item.skuId, productId: item.skuId,
productName: item.sku?.product?.name || 'Unknown', productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []),
quantity: parseFloat(item.quantity), quantity: parseFloat(item.quantity),
productSize: 1, productSize: 1,
price: parseFloat((item.price ?? 0).toString()), price: parseFloat((item.price ?? 0).toString()),
unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', unit: composeUnitNotation(item.sku?.features || []) || 'unit',
subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity),
is_packaged: item.is_packaged, is_packaged: item.is_packaged,
is_package_verified: item.is_package_verified, is_package_verified: item.is_package_verified,
@ -488,9 +490,9 @@ export const vendorSnippetsRouter = router({
orderDate: order.createdAt ? order.createdAt.toISOString() : new Date(0).toISOString(), orderDate: order.createdAt ? order.createdAt.toISOString() : new Date(0).toISOString(),
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
products: order.orderItems.map(item => ({ products: order.orderItems.map(item => ({
name: item.sku?.product?.name || 'Unknown', name: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []),
quantity: parseFloat(item.quantity || '0'), quantity: parseFloat(item.quantity || '0'),
unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', unit: composeUnitNotation(item.sku?.features || []) || 'unit',
})), })),
})) }))
}), }),
@ -602,10 +604,10 @@ export const vendorSnippetsRouter = router({
const products = attachedOrderItems.map(item => ({ const products = attachedOrderItems.map(item => ({
orderItemId: item.id, orderItemId: item.id,
productId: item.skuId, productId: item.skuId,
productName: item.sku?.product?.name || 'Unknown', productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []),
quantity: parseFloat(item.quantity), quantity: parseFloat(item.quantity),
price: parseFloat((item.price ?? 0).toString()), price: parseFloat((item.price ?? 0).toString()),
unit: (item.sku?.features || []).map((f: any) => f.featureValue).join(' ') || 'unit', unit: composeUnitNotation(item.sku?.features || []) || 'unit',
subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity), subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity),
productSize: 1, productSize: 1,
is_packaged: item.is_packaged, is_packaged: item.is_packaged,

View file

@ -54,7 +54,8 @@ export async function scaffoldProducts() {
isFlashAvailable: product.isFlashAvailable, isFlashAvailable: product.isFlashAvailable,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: product.images, images: product.images,
flashPrice: product.flashPrice flashPrice: product.flashPrice,
productType: product.productType || 'item'
}; };
}) })
); );

View file

@ -9,6 +9,7 @@ import {
getUserProductReviews as getUserProductReviewsInDb, getUserProductReviews as getUserProductReviewsInDb,
getUserProductByIdBasic as getUserProductByIdBasicInDb, getUserProductByIdBasic as getUserProductByIdBasicInDb,
createUserProductReview as createUserProductReviewInDb, createUserProductReview as createUserProductReviewInDb,
getOffersAndCombos as getOffersAndCombosInDb,
} from '@/src/dbService' } from '@/src/dbService'
import type { import type {
UserProductDetail, UserProductDetail,
@ -181,4 +182,21 @@ export const productRouter = router({
return transformedProducts return transformedProducts
}), }),
getOffersPage: publicProcedure
.query(async () => {
const data = await getOffersAndCombosInDb();
const signImages = (products: typeof data.combos) =>
products.map((product) => ({
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
}));
return {
success: true,
combos: signImages(data.combos),
offers: signImages(data.offers),
};
}),
}); });

View file

@ -17,17 +17,81 @@ import FloatingCartBar from "@/components/floating-cart-bar";
import TabLayoutWrapper from "@/components/TabLayoutWrapper"; import TabLayoutWrapper from "@/components/TabLayoutWrapper";
const { width: screenWidth } = Dimensions.get("window"); const { width: screenWidth } = Dimensions.get("window");
const itemWidth = (screenWidth - 48) / 2; const itemWidth = screenWidth * 0.45;
export default function OrderAgain() { const rowListContent = { paddingBottom: 16 };
interface OffersRowProps {
title: string;
subtitle: string;
products: any[];
onProductPress: (id: number) => void;
}
const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps) => {
const renderItem = ({ item }: { item: any }) => (
<View style={tw`mr-4`}>
<ProductCard
item={item}
itemWidth={itemWidth}
onPress={() => onProductPress(item.id)}
showDeliveryInfo={false}
useAddToCartDialog={true}
miniView={true}
/>
</View>
);
return (
<View style={tw`bg-white`}>
<View style={tw`mb-2 pt-4 px-4`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>
{title}
</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium`}>
{subtitle}
</MyText>
</View>
{products.length === 0 ? (
<View style={tw`items-center justify-center py-10`}>
<MaterialIcons name="local-offer" size={28} color="#9CA3AF" />
<MyText style={tw`text-gray-500 mt-2`}>
No {title.toLowerCase()} available right now
</MyText>
</View>
) : (
<View style={tw`relative`}>
<MyFlatList
data={products}
keyExtractor={(item) => item.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={rowListContent}
renderItem={renderItem}
removeClippedSubviews={true}
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.08)"]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`}
pointerEvents="none"
/>
</View>
)}
</View>
);
};
export default function Offers() {
const router = useRouter(); const router = useRouter();
const { data: recentProductsData, isLoading, error, refetch } = const { data, isLoading, error, refetch } =
trpc.user.order.getRecentlyOrderedProducts.useQuery({ trpc.user.product.getOffersPage.useQuery();
limit: 20,
});
const recentProducts = recentProductsData?.products || []; const combos = data?.combos || [];
const offers = data?.offers || [];
useManualRefresh(() => { useManualRefresh(() => {
refetch(); refetch();
@ -41,9 +105,9 @@ export default function OrderAgain() {
return ( return (
<AppContainer> <AppContainer>
<View style={tw`flex-1 justify-center items-center bg-gray-50`}> <View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<MaterialIcons name="refresh" size={48} color="#3B82F6" /> <MaterialIcons name="local-offer" size={48} color="#3B82F6" />
<MyText style={tw`text-gray-500 font-medium mt-4`}> <MyText style={tw`text-gray-500 font-medium mt-4`}>
Loading your recent orders... Loading offers...
</MyText> </MyText>
</View> </View>
</AppContainer> </AppContainer>
@ -57,90 +121,33 @@ export default function OrderAgain() {
<MaterialIcons name="error-outline" size={48} color="#EF4444" /> <MaterialIcons name="error-outline" size={48} color="#EF4444" />
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Oops!</MyText> <MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Oops!</MyText>
<MyText style={tw`text-gray-500 mt-2`}> <MyText style={tw`text-gray-500 mt-2`}>
Failed to load recent orders Failed to load offers
</MyText> </MyText>
</View> </View>
</AppContainer> </AppContainer>
); );
} }
const handleProductPress = (id: number) => {
router.push(`/(drawer)/(tabs)/order-again/product-detail/${id}`);
};
return ( return (
<TabLayoutWrapper> <TabLayoutWrapper>
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}>
<LinearGradient <OffersRow
colors={["#194185", "#1570EF"]} title="Combos"
start={{ x: 0, y: 0 }} subtitle="Bundle your favorites and save"
end={{ x: 1, y: 1 }} products={combos}
style={tw`pb-8 pt-6 px-5 rounded-b-[32px] shadow-lg mb-4`} onProductPress={handleProductPress}
> />
<View style={tw`flex-row justify-between items-start mb-6`}>
<View style={tw`flex-1 mr-4`}> <OffersRow
<View style={tw`flex-row items-center mb-1`}> title="Offers"
<View style={tw`bg-white/20 p-1 rounded-full mr-2`}> subtitle="Great prices on select items"
<MaterialIcons name="refresh" size={14} color="#FFF" /> products={offers}
</View> onProductPress={handleProductPress}
<MyText
style={tw`text-brand100 text-xs font-bold uppercase tracking-widest`}
>
Order Again
</MyText>
</View>
<MyText
style={tw`text-white text-sm font-medium opacity-90 ml-1`}
numberOfLines={1}
>
Reorder your favorite items quickly
</MyText>
</View>
</View>
</LinearGradient>
<View style={tw`bg-white`}>
<View style={tw`flex-row items-center mb-2 px-4 pt-4`}>
<MyText style={tw`text-lg font-bold text-gray-900`}>
Recently Ordered
</MyText>
</View>
{recentProducts.length === 0 ? (
<View style={tw`items-center justify-center py-12`}>
<View
style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}
>
<MaterialIcons name="shopping-bag" size={32} color="#9CA3AF" />
</View>
<MyText style={tw`text-lg font-bold text-gray-900`}>
No recent orders
</MyText>
<MyText style={tw`text-gray-500 mt-2 text-center px-8`}>
Items you've ordered recently will appear here
</MyText>
</View>
) : (
<View style={tw`px-4 pb-4`}>
<View style={tw`flex-row flex-wrap justify-between`}>
{recentProducts.map((item, index) => (
<View
key={item.id}
style={tw`mb-4 ${index % 2 === 0 ? "mr-2" : ""}`}
>
<ProductCard
item={item}
itemWidth={itemWidth}
onPress={() =>
router.push(
`/(drawer)/(tabs)/order-again/product-detail/${item.id}`
)
}
showDeliveryInfo={false}
useAddToCartDialog={true}
/> />
</View>
))}
</View>
</View>
)}
</View>
</ScrollView> </ScrollView>
<View style={tw`absolute bottom-2 left-4 right-4`}> <View style={tw`absolute bottom-2 left-4 right-4`}>

View file

@ -222,7 +222,9 @@ const ProductCard: React.FC<ProductCardProps> = ({
)} )}
</View> </View>
<View style={tw`flex-row items-center mb-2`}> <View style={tw`flex-row items-center mb-2`}>
{item.productType !== 'combo' && (
<MyText style={tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unitNotation}</MyText></MyText> <MyText style={tw`text-gray-500 text-xs font-medium`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unitNotation}</MyText></MyText>
)}
</View> </View>
{showDeliveryInfo && displayDeliveryDate && ( {showDeliveryInfo && displayDeliveryDate && (

View file

@ -233,8 +233,11 @@ export {
createProductReview as createUserProductReview, createProductReview as createUserProductReview,
getAllProductsWithUnits, getAllProductsWithUnits,
getAllSkusSummary, getAllSkusSummary,
getOffersAndCombos,
type ProductSummaryData, type ProductSummaryData,
type SkuSummary, type SkuSummary,
type OffersPageData,
type OffersPageProductData,
} from './src/user-apis/product' } from './src/user-apis/product'
export { export {
@ -377,6 +380,15 @@ export {
deleteOrdersWithRelations, deleteOrdersWithRelations,
} from './src/lib/delete-orders' } from './src/lib/delete-orders'
// SKU Features Helper
export {
cleanFeatureValue,
splitQuantityFeature,
composeUnitNotation,
composeSkuName,
type SkuFeatureLike,
} from './src/lib/sku-features'
// Upload URL Helpers // Upload URL Helpers
export { export {
createUploadUrlStatus, createUploadUrlStatus,

View file

@ -28,6 +28,7 @@ import type {
} from '@packages/shared' } from '@packages/shared'
import type { InferSelectModel } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm'
import { coerceDate } from '../lib/date' import { coerceDate } from '../lib/date'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
const isPaymentStatus = (value: string): value is PaymentStatus => const isPaymentStatus = (value: string): value is PaymentStatus =>
value === 'pending' || value === 'success' || value === 'cod' || value === 'failed' value === 'pending' || value === 'success' || value === 'cod' || value === 'failed'
@ -236,12 +237,12 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
isDelivered: orderStatusRecord?.isDelivered || false, isDelivered: orderStatusRecord?.isDelivered || false,
items: orderData.orderItems.map((item: any) => ({ items: orderData.orderItems.map((item: any) => ({
id: item.id, id: item.id,
name: item.sku.product?.name ?? 'Unknown', name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
skuName: item.sku.name ?? null, skuName: item.sku.name ?? null,
quantity: item.quantity, quantity: item.quantity,
productSize: 1, productSize: 1,
price: item.price, price: item.price,
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), unit: composeUnitNotation(item.sku.features || []),
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'), amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
isPackaged: item.is_packaged, isPackaged: item.is_packaged,
isPackageVerified: item.is_package_verified, isPackageVerified: item.is_package_verified,
@ -375,12 +376,12 @@ export async function getSlotOrders(slotId: string): Promise<AdminGetSlotOrdersR
const items = order.orderItems.map((item: any) => ({ const items = order.orderItems.map((item: any) => ({
id: item.id, id: item.id,
name: item.sku.product?.name ?? 'Unknown', name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
skuName: item.sku.name ?? null, skuName: item.sku.name ?? null,
quantity: parseFloat(item.quantity), quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()), price: parseFloat(item.price.toString()),
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), unit: composeUnitNotation(item.sku.features || []),
isPackaged: item.is_packaged, isPackaged: item.is_packaged,
isPackageVerified: item.is_package_verified, isPackageVerified: item.is_package_verified,
features: (item.sku.features || []).map((f: any) => ({ features: (item.sku.features || []).map((f: any) => ({
@ -545,12 +546,12 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise<AdminGetAl
const items = order.orderItems const items = order.orderItems
.map((item: any) => ({ .map((item: any) => ({
id: item.id, id: item.id,
name: item.sku.product?.name ?? 'Unknown', name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
skuName: item.sku.name ?? null, skuName: item.sku.name ?? null,
quantity: parseFloat(item.quantity), quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()), price: parseFloat(item.price.toString()),
amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), amount: parseFloat(item.quantity) * parseFloat(item.price.toString()),
unit: (item.sku.features || []).map((f: any) => f.featureValue).join(' '), unit: composeUnitNotation(item.sku.features || []),
productSize: 1, productSize: 1,
isPackaged: item.is_packaged, isPackaged: item.is_packaged,
isPackageVerified: item.is_package_verified, isPackageVerified: item.is_package_verified,

View file

@ -0,0 +1,44 @@
export interface SkuFeatureLike {
featureName?: string | null
featureValue: string
}
export const cleanFeatureValue = (value: string): string =>
value.replace(/\.0(?=\D|$)/g, '')
export function splitQuantityFeature(features: SkuFeatureLike[]): {
quantity: string
others: string[]
} {
if (!features || features.length === 0) {
return { quantity: '', others: [] }
}
const quantityFeature = features.find((f) => f.featureName === 'quantity')
const otherFeatures = features.filter((f) => f.featureName !== 'quantity')
return {
quantity: quantityFeature ? cleanFeatureValue(quantityFeature.featureValue) : '',
others: otherFeatures.map((f) => cleanFeatureValue(f.featureValue)),
}
}
/**
* Builds the unit notation for a SKU: the quantity feature value only.
* Falls back to joining all feature values when no quantity feature exists.
*/
export function composeUnitNotation(features: SkuFeatureLike[]): string {
const { quantity, others } = splitQuantityFeature(features)
if (quantity) return quantity
if (others.length > 0) return others.join(' ')
return ''
}
/**
* Builds the display name for a SKU: the product name followed by the
* values of all non-quantity features (values only).
*/
export function composeSkuName(baseName: string, features: SkuFeatureLike[]): string {
const { others } = splitQuantityFeature(features)
return [baseName, ...others].filter(Boolean).join(' ')
}

View file

@ -15,6 +15,7 @@ import {
userIncidents, userIncidents,
} from '../db/schema' } from '../db/schema'
import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm' import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
// ============================================================================ // ============================================================================
// BANNER STORE HELPERS // BANNER STORE HELPERS
@ -117,7 +118,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
name: sku.product?.name ?? 'Unknown', name: composeSkuName(sku.product?.name ?? 'Unknown', features),
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
longDescription: sku.product?.longDescription ?? null, longDescription: sku.product?.longDescription ?? null,
@ -126,7 +127,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.isOutOfStock,
storeId: sku.product?.storeId ?? null, storeId: sku.product?.storeId ?? null,
unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitNotation: composeUnitNotation(features),
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
productQuantity: 1, productQuantity: 1,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
@ -204,10 +205,10 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
return { return {
comboSkuId: ci.comboSkuId, comboSkuId: ci.comboSkuId,
skuId: ci.skuId, skuId: ci.skuId,
productName: ci.sku?.product?.name ?? 'Unknown', productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', features),
skuName: ci.sku?.name ?? null, skuName: ci.sku?.name ?? null,
images: ci.sku?.images, images: ci.sku?.images,
unitNotation: features.map((f) => f.featureValue).join(' '), unitNotation: composeUnitNotation(features),
price: String(ci.sku?.price ?? '0'), price: String(ci.sku?.price ?? '0'),
} }
}) })
@ -332,13 +333,13 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
name: sku.product?.name ?? 'Unknown', name: composeSkuName(sku.product?.name ?? 'Unknown', features),
skuName: sku.name ?? null, skuName: sku.name ?? null,
productQuantity: 1, productQuantity: 1,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: String(sku.price ?? '0'),
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: features.map((f: any) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitNotation: composeUnitNotation(features),
store: sku.product?.store ? { store: sku.product?.store ? {
id: sku.product.store.id, id: sku.product.store.id,
name: sku.product.store.name, name: sku.product.store.name,

View file

@ -2,6 +2,7 @@ import { db } from '../db/db_index'
import { cartItems, productSkus } from '../db/schema' import { cartItems, productSkus } from '../db/schema'
import { and, eq, sql } from 'drizzle-orm' import { and, eq, sql } from 'drizzle-orm'
import type { UserCartItem } from '@packages/shared' import type { UserCartItem } from '@packages/shared'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
const getStringArray = (value: unknown): string[] => { const getStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
@ -33,10 +34,10 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
addedAt: item.addedAt, addedAt: item.addedAt,
product: { product: {
id: sku?.id ?? 0, id: sku?.id ?? 0,
name: sku?.product?.name ?? 'Unknown', name: composeSkuName(sku?.product?.name ?? 'Unknown', features),
price: String(priceValue), price: String(priceValue),
productQuantity: 1, productQuantity: 1,
unit: features.map((f) => f.featureValue).join(' '), unit: composeUnitNotation(features),
isOutOfStock: sku?.isOutOfStock ?? false, isOutOfStock: sku?.isOutOfStock ?? false,
images: getStringArray(sku?.images), images: getStringArray(sku?.images),
}, },

View file

@ -23,6 +23,7 @@ import type {
} from '@packages/shared' } from '@packages/shared'
import { coerceDate } from '../lib/date' import { coerceDate } from '../lib/date'
import { runBatched } from '../lib/run-batched' import { runBatched } from '../lib/run-batched'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
export interface OrderItemInput { export interface OrderItemInput {
productId: number productId: number
@ -686,12 +687,12 @@ export async function getProductsForRecentOrders(
const features = sku.features || [] const features = sku.features || []
return { return {
id: sku.id, id: sku.id,
name: sku.product?.name ?? 'Unknown', name: composeSkuName(sku.product?.name ?? 'Unknown', features),
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: String(sku.price ?? '0'),
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.isOutOfStock,
unitShortNotation: features.map((f) => f.featureValue).join(' '), unitShortNotation: composeUnitNotation(features),
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
} }
}) })

View file

@ -2,6 +2,7 @@ import { db } from '../db/db_index'
import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema'
import { and, desc, eq, gt, sql } from 'drizzle-orm' import { and, desc, eq, gt, sql } from 'drizzle-orm'
import type { UserProductDetailData, UserProductReview } from '@packages/shared' import type { UserProductDetailData, UserProductReview } from '@packages/shared'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
const getStringArray = (value: unknown): string[] | null => { const getStringArray = (value: unknown): string[] | null => {
if (!Array.isArray(value)) return null if (!Array.isArray(value)) return null
@ -56,8 +57,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
return { return {
skuId: ci.skuId, skuId: ci.skuId,
skuName: ci.sku?.name ?? null, skuName: ci.sku?.name ?? null,
unitNotation: ciFeatures.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitNotation: composeUnitNotation(ciFeatures),
productName: ci.sku?.product?.name ?? 'Unknown', productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures),
images: getStringArray(ci.sku?.images), images: getStringArray(ci.sku?.images),
price: String(ci.sku?.price ?? '0'), price: String(ci.sku?.price ?? '0'),
} }
@ -65,12 +66,12 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
return { return {
id: sku.id, id: sku.id,
name: product?.name ?? 'Unknown', name: composeSkuName(product?.name ?? 'Unknown', features),
shortDescription: product?.shortDescription ?? null, shortDescription: product?.shortDescription ?? null,
longDescription: product?.longDescription ?? null, longDescription: product?.longDescription ?? null,
price: String(sku.price ?? '0'), price: String(sku.price ?? '0'),
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.isOutOfStock,
store: storeData ? { store: storeData ? {
@ -215,7 +216,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
const features = sku.features || [] const features = sku.features || []
return { return {
id: sku.product?.id ?? 0, id: sku.product?.id ?? 0,
name: sku.product?.name ?? 'Unknown', name: composeSkuName(sku.product?.name ?? 'Unknown', features),
skuId: sku.id, skuId: sku.id,
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
@ -223,7 +224,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.isOutOfStock,
unitShortNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitShortNotation: composeUnitNotation(features),
productQuantity: 1, productQuantity: 1,
features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
} }
@ -296,3 +297,65 @@ export async function getAllSkusSummary(): Promise<SkuSummary[]> {
} }
}) })
} }
export interface OffersPageProductData {
id: number
name: string
price: string
marketPrice: string | null
unitNotation: string
images: unknown
isOutOfStock: boolean
incrementStep: number
}
export interface OffersPageData {
combos: OffersPageProductData[]
offers: OffersPageProductData[]
}
const mapOffersPageProduct = (sku: {
id: number
price: string | null
marketPrice: string | null
images: unknown
isOutOfStock: boolean
product: { name: string; incrementStep: number | null } | null
features: Array<{ featureValue: string }>
}): OffersPageProductData => {
const features = sku.features || []
return {
id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
price: String(sku.price ?? '0'),
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
unitNotation: composeUnitNotation(features),
images: sku.images,
isOutOfStock: sku.isOutOfStock,
incrementStep: sku.product?.incrementStep ?? 1,
}
}
export async function getOffersAndCombos(): Promise<OffersPageData> {
const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: {
product: true,
features: true,
},
})
const combos: OffersPageProductData[] = []
const offers: OffersPageProductData[] = []
for (const sku of skus) {
if (sku.product?.productType === 'combo') {
combos.push(mapOffersPageProduct(sku))
}
if (sku.isOffer) {
offers.push(mapOffersPageProduct(sku))
}
}
return { combos, offers }
}

View file

@ -3,6 +3,7 @@ import { productInfo, productSkus, storeInfo } from '../db/schema'
import { and, asc, eq, inArray } from 'drizzle-orm' import { and, asc, eq, inArray } from 'drizzle-orm'
import type { InferSelectModel } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm'
import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
type StoreRow = InferSelectModel<typeof storeInfo> type StoreRow = InferSelectModel<typeof storeInfo>
@ -91,13 +92,13 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
const features = sku.features || [] const features = sku.features || []
return { return {
id: sku.id, id: sku.id,
name: sku.product?.name ?? 'Unknown', name: composeSkuName(sku.product?.name ?? 'Unknown', features),
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: String(sku.price ?? '0'),
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
unit: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unit: composeUnitNotation(features),
unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.isOutOfStock,
productQuantity: 1, productQuantity: 1,