enh
This commit is contained in:
parent
6d67a1e759
commit
b5cdc53e50
17 changed files with 400 additions and 123 deletions
|
|
@ -14,6 +14,28 @@ export default function AddProduct() {
|
|||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
||||
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 imageCounts: number[] = variantImages.map((imgs) => imgs.length)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,28 @@ export default function EditProduct() {
|
|||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
|
||||
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 imageCounts: number[] = variantImages.map((imgs) =>
|
||||
imgs.filter((img) => img.mimeType !== null).length
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 * as Yup from 'yup'
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox } from 'common-ui'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
import { trpc } from '../trpc-client'
|
||||
|
|
@ -60,6 +61,60 @@ const defaultVariant = (): Variant => ({
|
|||
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>(({
|
||||
mode,
|
||||
initialValues,
|
||||
|
|
@ -93,6 +148,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={productValidationSchema}
|
||||
onSubmit={(values) => {
|
||||
const images = variantImages.map((imgs) =>
|
||||
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
|
||||
|
|
@ -113,7 +169,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
}}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue }) => (
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
|
||||
return (
|
||||
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
|
||||
<MyTextInput
|
||||
topLabel="Product Name"
|
||||
|
|
@ -363,7 +420,25 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
</FieldArray>
|
||||
|
||||
<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}
|
||||
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>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
getOrdersByIdsWithFullData,
|
||||
getOrderByIdWithFullData,
|
||||
composeSkuName,
|
||||
composeUnitNotation,
|
||||
} from '@/src/dbService'
|
||||
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||
|
|
@ -49,10 +51,11 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
|||
message += '📦 <b>Items:</b>\n';
|
||||
order.orderItems?.forEach((item: any) => {
|
||||
const sku = item.sku
|
||||
const features = (sku?.features || []).map((f: any) => f.featureValue).join(' ')
|
||||
message += ` • ${sku?.product?.name || 'Unknown'} ${features} x${item.quantity}\n`;
|
||||
const features = sku?.features || []
|
||||
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 += `🚚 <b>Delivery:</b> ${
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
getVendorOrders as getVendorOrdersInDb,
|
||||
updateVendorOrderItemPackaging as updateVendorOrderItemPackagingInDb,
|
||||
getSlotsAfterDate as getSlotsAfterDateInDb,
|
||||
composeSkuName,
|
||||
composeUnitNotation,
|
||||
} from '@/src/dbService'
|
||||
import type {
|
||||
AdminVendorSnippet,
|
||||
|
|
@ -417,11 +419,11 @@ export const vendorSnippetsRouter = router({
|
|||
const products = attachedOrderItems.map(item => ({
|
||||
orderItemId: item.id,
|
||||
productId: item.skuId,
|
||||
productName: item.sku?.product?.name || 'Unknown',
|
||||
productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []),
|
||||
quantity: parseFloat(item.quantity),
|
||||
productSize: 1,
|
||||
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),
|
||||
is_packaged: item.is_packaged,
|
||||
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(),
|
||||
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
|
||||
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'),
|
||||
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 => ({
|
||||
orderItemId: item.id,
|
||||
productId: item.skuId,
|
||||
productName: item.sku?.product?.name || 'Unknown',
|
||||
productName: composeSkuName(item.sku?.product?.name || 'Unknown', item.sku?.features || []),
|
||||
quantity: parseFloat(item.quantity),
|
||||
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),
|
||||
productSize: 1,
|
||||
is_packaged: item.is_packaged,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ export async function scaffoldProducts() {
|
|||
isFlashAvailable: product.isFlashAvailable,
|
||||
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||
images: product.images,
|
||||
flashPrice: product.flashPrice
|
||||
flashPrice: product.flashPrice,
|
||||
productType: product.productType || 'item'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getUserProductReviews as getUserProductReviewsInDb,
|
||||
getUserProductByIdBasic as getUserProductByIdBasicInDb,
|
||||
createUserProductReview as createUserProductReviewInDb,
|
||||
getOffersAndCombos as getOffersAndCombosInDb,
|
||||
} from '@/src/dbService'
|
||||
import type {
|
||||
UserProductDetail,
|
||||
|
|
@ -181,4 +182,21 @@ export const productRouter = router({
|
|||
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),
|
||||
};
|
||||
}),
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,17 +17,81 @@ import FloatingCartBar from "@/components/floating-cart-bar";
|
|||
import TabLayoutWrapper from "@/components/TabLayoutWrapper";
|
||||
|
||||
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 { data: recentProductsData, isLoading, error, refetch } =
|
||||
trpc.user.order.getRecentlyOrderedProducts.useQuery({
|
||||
limit: 20,
|
||||
});
|
||||
const { data, isLoading, error, refetch } =
|
||||
trpc.user.product.getOffersPage.useQuery();
|
||||
|
||||
const recentProducts = recentProductsData?.products || [];
|
||||
const combos = data?.combos || [];
|
||||
const offers = data?.offers || [];
|
||||
|
||||
useManualRefresh(() => {
|
||||
refetch();
|
||||
|
|
@ -41,9 +105,9 @@ export default function OrderAgain() {
|
|||
return (
|
||||
<AppContainer>
|
||||
<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`}>
|
||||
Loading your recent orders...
|
||||
Loading offers...
|
||||
</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
|
|
@ -57,90 +121,33 @@ export default function OrderAgain() {
|
|||
<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-500 mt-2`}>
|
||||
Failed to load recent orders
|
||||
Failed to load offers
|
||||
</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const handleProductPress = (id: number) => {
|
||||
router.push(`/(drawer)/(tabs)/order-again/product-detail/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabLayoutWrapper>
|
||||
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}>
|
||||
<LinearGradient
|
||||
colors={["#194185", "#1570EF"]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`pb-8 pt-6 px-5 rounded-b-[32px] shadow-lg mb-4`}
|
||||
>
|
||||
<View style={tw`flex-row justify-between items-start mb-6`}>
|
||||
<View style={tw`flex-1 mr-4`}>
|
||||
<View style={tw`flex-row items-center mb-1`}>
|
||||
<View style={tw`bg-white/20 p-1 rounded-full mr-2`}>
|
||||
<MaterialIcons name="refresh" size={14} color="#FFF" />
|
||||
</View>
|
||||
<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}
|
||||
<OffersRow
|
||||
title="Combos"
|
||||
subtitle="Bundle your favorites and save"
|
||||
products={combos}
|
||||
onProductPress={handleProductPress}
|
||||
/>
|
||||
|
||||
<OffersRow
|
||||
title="Offers"
|
||||
subtitle="Great prices on select items"
|
||||
products={offers}
|
||||
onProductPress={handleProductPress}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
||||
|
|
|
|||
|
|
@ -222,7 +222,9 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
|||
)}
|
||||
</View>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showDeliveryInfo && displayDeliveryDate && (
|
||||
|
|
|
|||
|
|
@ -233,8 +233,11 @@ export {
|
|||
createProductReview as createUserProductReview,
|
||||
getAllProductsWithUnits,
|
||||
getAllSkusSummary,
|
||||
getOffersAndCombos,
|
||||
type ProductSummaryData,
|
||||
type SkuSummary,
|
||||
type OffersPageData,
|
||||
type OffersPageProductData,
|
||||
} from './src/user-apis/product'
|
||||
|
||||
export {
|
||||
|
|
@ -377,6 +380,15 @@ export {
|
|||
deleteOrdersWithRelations,
|
||||
} from './src/lib/delete-orders'
|
||||
|
||||
// SKU Features Helper
|
||||
export {
|
||||
cleanFeatureValue,
|
||||
splitQuantityFeature,
|
||||
composeUnitNotation,
|
||||
composeSkuName,
|
||||
type SkuFeatureLike,
|
||||
} from './src/lib/sku-features'
|
||||
|
||||
// Upload URL Helpers
|
||||
export {
|
||||
createUploadUrlStatus,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from '@packages/shared'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { coerceDate } from '../lib/date'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
const isPaymentStatus = (value: string): value is PaymentStatus =>
|
||||
value === 'pending' || value === 'success' || value === 'cod' || value === 'failed'
|
||||
|
|
@ -236,12 +237,12 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
|
|||
isDelivered: orderStatusRecord?.isDelivered || false,
|
||||
items: orderData.orderItems.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
|
||||
skuName: item.sku.name ?? null,
|
||||
quantity: item.quantity,
|
||||
productSize: 1,
|
||||
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'),
|
||||
isPackaged: item.is_packaged,
|
||||
isPackageVerified: item.is_package_verified,
|
||||
|
|
@ -375,12 +376,12 @@ export async function getSlotOrders(slotId: string): Promise<AdminGetSlotOrdersR
|
|||
|
||||
const items = order.orderItems.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
|
||||
skuName: item.sku.name ?? null,
|
||||
quantity: parseFloat(item.quantity),
|
||||
price: 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,
|
||||
isPackageVerified: item.is_package_verified,
|
||||
features: (item.sku.features || []).map((f: any) => ({
|
||||
|
|
@ -545,12 +546,12 @@ export async function getAllOrders(input: GetAllOrdersInput): Promise<AdminGetAl
|
|||
const items = order.orderItems
|
||||
.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(item.sku.product?.name ?? 'Unknown', item.sku.features || []),
|
||||
skuName: item.sku.name ?? null,
|
||||
quantity: parseFloat(item.quantity),
|
||||
price: 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,
|
||||
isPackaged: item.is_packaged,
|
||||
isPackageVerified: item.is_package_verified,
|
||||
|
|
|
|||
44
packages/db_helper_sqlite/src/lib/sku-features.ts
Normal file
44
packages/db_helper_sqlite/src/lib/sku-features.ts
Normal 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(' ')
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
userIncidents,
|
||||
} from '../db/schema'
|
||||
import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
// ============================================================================
|
||||
// BANNER STORE HELPERS
|
||||
|
|
@ -117,7 +118,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
|||
return {
|
||||
id: sku.id,
|
||||
productId: sku.productId,
|
||||
name: sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
||||
skuName: sku.name ?? null,
|
||||
shortDescription: sku.product?.shortDescription ?? null,
|
||||
longDescription: sku.product?.longDescription ?? null,
|
||||
|
|
@ -126,7 +127,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
|||
images: sku.images,
|
||||
isOutOfStock: sku.isOutOfStock,
|
||||
storeId: sku.product?.storeId ?? null,
|
||||
unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '),
|
||||
unitNotation: composeUnitNotation(features),
|
||||
incrementStep: sku.product?.incrementStep ?? 1,
|
||||
productQuantity: 1,
|
||||
isFlashAvailable: sku.isFlashAvailable,
|
||||
|
|
@ -204,10 +205,10 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
|
|||
return {
|
||||
comboSkuId: ci.comboSkuId,
|
||||
skuId: ci.skuId,
|
||||
productName: ci.sku?.product?.name ?? 'Unknown',
|
||||
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', features),
|
||||
skuName: ci.sku?.name ?? null,
|
||||
images: ci.sku?.images,
|
||||
unitNotation: features.map((f) => f.featureValue).join(' '),
|
||||
unitNotation: composeUnitNotation(features),
|
||||
price: String(ci.sku?.price ?? '0'),
|
||||
}
|
||||
})
|
||||
|
|
@ -332,13 +333,13 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
|||
return {
|
||||
id: sku.id,
|
||||
productId: sku.productId,
|
||||
name: sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
||||
skuName: sku.name ?? null,
|
||||
productQuantity: 1,
|
||||
shortDescription: sku.product?.shortDescription ?? null,
|
||||
price: String(sku.price ?? '0'),
|
||||
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 ? {
|
||||
id: sku.product.store.id,
|
||||
name: sku.product.store.name,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { db } from '../db/db_index'
|
|||
import { cartItems, productSkus } from '../db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import type { UserCartItem } from '@packages/shared'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
const getStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
|
@ -33,10 +34,10 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
|
|||
addedAt: item.addedAt,
|
||||
product: {
|
||||
id: sku?.id ?? 0,
|
||||
name: sku?.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku?.product?.name ?? 'Unknown', features),
|
||||
price: String(priceValue),
|
||||
productQuantity: 1,
|
||||
unit: features.map((f) => f.featureValue).join(' '),
|
||||
unit: composeUnitNotation(features),
|
||||
isOutOfStock: sku?.isOutOfStock ?? false,
|
||||
images: getStringArray(sku?.images),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
} from '@packages/shared'
|
||||
import { coerceDate } from '../lib/date'
|
||||
import { runBatched } from '../lib/run-batched'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
export interface OrderItemInput {
|
||||
productId: number
|
||||
|
|
@ -686,12 +687,12 @@ export async function getProductsForRecentOrders(
|
|||
const features = sku.features || []
|
||||
return {
|
||||
id: sku.id,
|
||||
name: sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
||||
shortDescription: sku.product?.shortDescription ?? null,
|
||||
price: String(sku.price ?? '0'),
|
||||
images: sku.images,
|
||||
isOutOfStock: sku.isOutOfStock,
|
||||
unitShortNotation: features.map((f) => f.featureValue).join(' '),
|
||||
unitShortNotation: composeUnitNotation(features),
|
||||
incrementStep: sku.product?.incrementStep ?? 1,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { db } from '../db/db_index'
|
|||
import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema'
|
||||
import { and, desc, eq, gt, sql } from 'drizzle-orm'
|
||||
import type { UserProductDetailData, UserProductReview } from '@packages/shared'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
const getStringArray = (value: unknown): string[] | null => {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
|
@ -56,8 +57,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
|||
return {
|
||||
skuId: ci.skuId,
|
||||
skuName: ci.sku?.name ?? null,
|
||||
unitNotation: ciFeatures.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '),
|
||||
productName: ci.sku?.product?.name ?? 'Unknown',
|
||||
unitNotation: composeUnitNotation(ciFeatures),
|
||||
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures),
|
||||
images: getStringArray(ci.sku?.images),
|
||||
price: String(ci.sku?.price ?? '0'),
|
||||
}
|
||||
|
|
@ -65,12 +66,12 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
|
|||
|
||||
return {
|
||||
id: sku.id,
|
||||
name: product?.name ?? 'Unknown',
|
||||
name: composeSkuName(product?.name ?? 'Unknown', features),
|
||||
shortDescription: product?.shortDescription ?? null,
|
||||
longDescription: product?.longDescription ?? null,
|
||||
price: String(sku.price ?? '0'),
|
||||
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),
|
||||
isOutOfStock: sku.isOutOfStock,
|
||||
store: storeData ? {
|
||||
|
|
@ -215,7 +216,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
|
|||
const features = sku.features || []
|
||||
return {
|
||||
id: sku.product?.id ?? 0,
|
||||
name: sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
||||
skuId: sku.id,
|
||||
skuName: sku.name ?? 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,
|
||||
images: sku.images,
|
||||
isOutOfStock: sku.isOutOfStock,
|
||||
unitShortNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '),
|
||||
unitShortNotation: composeUnitNotation(features),
|
||||
productQuantity: 1,
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { productInfo, productSkus, storeInfo } from '../db/schema'
|
|||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared'
|
||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||
|
||||
type StoreRow = InferSelectModel<typeof storeInfo>
|
||||
|
||||
|
|
@ -91,13 +92,13 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
|
|||
const features = sku.features || []
|
||||
return {
|
||||
id: sku.id,
|
||||
name: sku.product?.name ?? 'Unknown',
|
||||
name: composeSkuName(sku.product?.name ?? 'Unknown', features),
|
||||
shortDescription: sku.product?.shortDescription ?? null,
|
||||
price: String(sku.price ?? '0'),
|
||||
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
||||
incrementStep: sku.product?.incrementStep ?? 1,
|
||||
unit: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '),
|
||||
unitNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '),
|
||||
unit: composeUnitNotation(features),
|
||||
unitNotation: composeUnitNotation(features),
|
||||
images: getStringArray(sku.images),
|
||||
isOutOfStock: sku.isOutOfStock,
|
||||
productQuantity: 1,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue