enh
This commit is contained in:
parent
8fae91b582
commit
d7a588a529
30 changed files with 1313 additions and 30 deletions
|
|
@ -135,6 +135,13 @@ function CustomDrawerContent() {
|
|||
<MaterialIcons name="campaign" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Offers"
|
||||
onPress={() => router.push("/(drawer)/offers" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="sell" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Logout"
|
||||
onPress={() => logout()}
|
||||
|
|
@ -225,6 +232,7 @@ export default function Layout() {
|
|||
<Drawer.Screen name="complaints" options={{ title: "Complaints" }} />
|
||||
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="offers" options={{ title: "Offers" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
|
|
|
|||
|
|
@ -175,6 +175,16 @@ export default function Dashboard() {
|
|||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Offers',
|
||||
icon: 'sell',
|
||||
description: 'Create and manage product offers',
|
||||
route: '/(drawer)/offers' as any,
|
||||
category: 'marketing',
|
||||
iconColor: '#DC2626',
|
||||
iconBg: '#FEE2E2',
|
||||
testID: 'offers-menu-item',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
|
|
|
|||
11
apps/admin-ui/app/(drawer)/offers/_layout.tsx
Normal file
11
apps/admin-ui/app/(drawer)/offers/_layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ title: 'Offers' }} />
|
||||
<Stack.Screen name="add" options={{ title: 'Add Offer' }} />
|
||||
<Stack.Screen name="edit/[id]" options={{ title: 'Edit Offer' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
21
apps/admin-ui/app/(drawer)/offers/add.tsx
Normal file
21
apps/admin-ui/app/(drawer)/offers/add.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import { AppContainer } from 'common-ui'
|
||||
import OfferForm from '@/components/OfferForm'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
export default function AddOffer() {
|
||||
const router = useRouter()
|
||||
const { refetch } = trpc.admin.offers.getAll.useQuery()
|
||||
|
||||
const handleOfferAdded = () => {
|
||||
refetch()
|
||||
router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<OfferForm onOfferAdded={handleOfferAdded} />
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
67
apps/admin-ui/app/(drawer)/offers/edit/[id].tsx
Normal file
67
apps/admin-ui/app/(drawer)/offers/edit/[id].tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react'
|
||||
import { View, Text, ActivityIndicator } from 'react-native'
|
||||
import { AppContainer, tw } from 'common-ui'
|
||||
import OfferForm from '@/components/OfferForm'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
export default function EditOffer() {
|
||||
const router = useRouter()
|
||||
const { id } = useLocalSearchParams()
|
||||
const offerId = parseInt(id as string)
|
||||
|
||||
const { data: offerData, isLoading } = trpc.admin.offers.getById.useQuery(
|
||||
{ id: offerId },
|
||||
{ enabled: !!offerId }
|
||||
)
|
||||
|
||||
const handleOfferUpdated = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
<Text style={tw`mt-4 text-gray-500`}>Loading offer...</Text>
|
||||
</View>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!offerData?.offer) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={tw`text-gray-500`}>Offer not found</Text>
|
||||
</View>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const offer = offerData.offer
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<OfferForm
|
||||
offerId={offer.id}
|
||||
initialName={offer.name}
|
||||
initialShortDescription={offer.shortDescription}
|
||||
initialLongDescription={offer.longDescription}
|
||||
initialPrice={offer.price}
|
||||
initialMarketPrice={offer.marketPrice}
|
||||
initialImages={offer.images || []}
|
||||
initialImageKeys={offer.imageKeys || []}
|
||||
initialIsSuspended={offer.isSuspended}
|
||||
initialIsFlashEnabled={offer.isFlashEnabled}
|
||||
initialItems={offer.items.map((item: any) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product,
|
||||
}))}
|
||||
onOfferAdded={handleOfferUpdated}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
146
apps/admin-ui/app/(drawer)/offers/index.tsx
Normal file
146
apps/admin-ui/app/(drawer)/offers/index.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React from 'react'
|
||||
import { View, TouchableOpacity, Alert, Image, FlatList } from 'react-native'
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons'
|
||||
import { MyText, tw, MyTouchableOpacity, useManualRefresh, useMarkDataFetchers } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import { useRouter } from 'expo-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
|
||||
export default function OffersList() {
|
||||
const router = useRouter()
|
||||
const { data, isLoading, refetch } = trpc.admin.offers.getAll.useQuery()
|
||||
const toggleSuspend = trpc.admin.offers.toggleSuspend.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
const deleteOffer = trpc.admin.offers.delete.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const offers = data?.offers || []
|
||||
|
||||
useManualRefresh(() => refetch())
|
||||
useMarkDataFetchers(() => refetch())
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||
<MyText>Loading offers...</MyText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleSuspend = (id: number) => {
|
||||
toggleSuspend.mutate({ id })
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
Alert.alert('Delete Offer', 'Are you sure you want to delete this offer?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => deleteOffer.mutate({ id }) },
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white relative`}>
|
||||
<FlatList
|
||||
data={offers}
|
||||
keyExtractor={(item: any) => item.id.toString()}
|
||||
contentContainerStyle={tw`p-4`}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<MaterialCommunityIcons name="sale" size={64} color="#E5E7EB" />
|
||||
<MyText style={tw`text-gray-500 mt-4 text-lg`}>No offers yet</MyText>
|
||||
<MyText style={tw`text-gray-400 text-sm mt-1`}>Create your first offer</MyText>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: offer }: { item: any }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/offers/edit/${offer.id}` as any)}
|
||||
style={tw`bg-white border border-gray-200 rounded-2xl mb-4 overflow-hidden shadow-sm`}
|
||||
>
|
||||
<View style={tw`h-40 bg-gray-100`}>
|
||||
{offer.images?.[0] ? (
|
||||
<Image source={{ uri: offer.images[0] }} style={tw`w-full h-full`} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MaterialCommunityIcons name="image-off" size={48} color="#D1D5DB" />
|
||||
</View>
|
||||
)}
|
||||
{offer.isSuspended && (
|
||||
<View style={tw`absolute top-3 left-3 bg-red-500 px-3 py-1 rounded-full`}>
|
||||
<MyText style={tw`text-white text-xs font-bold`}>Suspended</MyText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-900`} numberOfLines={1}>
|
||||
{offer.name}
|
||||
</MyText>
|
||||
|
||||
{offer.shortDescription && (
|
||||
<MyText style={tw`text-gray-500 text-sm mt-1`} numberOfLines={2}>
|
||||
{offer.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row items-center mt-3`}>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>₹{offer.price}</MyText>
|
||||
{offer.marketPrice && Number(offer.marketPrice) > Number(offer.price) && (
|
||||
<MyText style={tw`text-sm text-gray-400 line-through ml-2`}>
|
||||
₹{offer.marketPrice}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center mt-2`}>
|
||||
<MaterialCommunityIcons name="package-variant" size={16} color="#9CA3AF" />
|
||||
<MyText style={tw`text-gray-500 text-sm ml-1`}>
|
||||
{offer.items?.length || 0} product{(offer.items?.length || 0) !== 1 ? 's' : ''}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<MyText style={tw`text-gray-400 text-xs mt-2`}>
|
||||
Added {dayjs(offer.addedOn).format('DD MMM, YYYY')}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row border-t border-gray-100`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleSuspend(offer.id)}
|
||||
style={tw`flex-1 py-3 items-center ${offer.isSuspended ? 'bg-green-50' : 'bg-red-50'}`}
|
||||
>
|
||||
<MyText style={tw`text-sm font-bold ${offer.isSuspended ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{offer.isSuspended ? 'Activate' : 'Suspend'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(offer.id)}
|
||||
style={tw`flex-1 py-3 items-center bg-red-50 border-l border-gray-100`}
|
||||
>
|
||||
<MyText style={tw`text-sm font-bold text-red-600`}>Delete</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MyTouchableOpacity
|
||||
testID="add-offer-fab"
|
||||
accessibilityLabel="add-offer-fab"
|
||||
onPress={() => router.push('/offers/add' as any)}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#F83758', '#E91E63']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg`}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
312
apps/admin-ui/components/OfferForm.tsx
Normal file
312
apps/admin-ui/components/OfferForm.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
||||
import { Formik, FieldArray } from 'formik'
|
||||
import { MyTextInput, tw, ImageUploaderNeo, Checkbox, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'
|
||||
import { trpc } from '../src/trpc-client'
|
||||
import ProductsSelector from '../components/ProductsSelector'
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
|
||||
interface OfferItem {
|
||||
productId: number
|
||||
quantity: string
|
||||
product?: { id: number; name: string; images: string[]; unitShortNotation: string }
|
||||
}
|
||||
|
||||
interface OfferFormProps {
|
||||
onOfferAdded?: () => void
|
||||
offerId?: number
|
||||
initialName?: string
|
||||
initialShortDescription?: string
|
||||
initialLongDescription?: string
|
||||
initialPrice?: string
|
||||
initialMarketPrice?: string
|
||||
initialImages?: string[]
|
||||
initialImageKeys?: string[]
|
||||
initialIsSuspended?: boolean
|
||||
initialIsFlashEnabled?: boolean
|
||||
initialItems?: OfferItem[]
|
||||
}
|
||||
|
||||
export default function OfferForm({
|
||||
onOfferAdded,
|
||||
offerId,
|
||||
initialName = '',
|
||||
initialShortDescription = '',
|
||||
initialLongDescription = '',
|
||||
initialPrice = '',
|
||||
initialMarketPrice = '',
|
||||
initialImages = [],
|
||||
initialImageKeys = [],
|
||||
initialIsSuspended = false,
|
||||
initialIsFlashEnabled = false,
|
||||
initialItems = [],
|
||||
}: OfferFormProps) {
|
||||
const isEditMode = !!offerId
|
||||
|
||||
const { mutate: createOffer, isPending: isCreating } = trpc.admin.offers.create.useMutation()
|
||||
const { mutate: updateOffer, isPending: isUpdating } = trpc.admin.offers.update.useMutation()
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
const isPending = isCreating || isUpdating || isUploading
|
||||
|
||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>(
|
||||
initialImages.map((url) => ({ imgUrl: url, mimeType: 'image/jpeg' }))
|
||||
)
|
||||
|
||||
const initialValues = {
|
||||
name: initialName,
|
||||
shortDescription: initialShortDescription,
|
||||
longDescription: initialLongDescription,
|
||||
price: initialPrice,
|
||||
marketPrice: initialMarketPrice,
|
||||
isFlashEnabled: initialIsFlashEnabled,
|
||||
items: initialItems.length > 0
|
||||
? initialItems.map(item => ({ productId: item.productId, quantity: item.quantity }))
|
||||
: [{ productId: 0, quantity: '1' }],
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: typeof initialValues) => {
|
||||
if (!values.name.trim()) {
|
||||
Alert.alert('Error', 'Offer name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.price || Number(values.price) <= 0) {
|
||||
Alert.alert('Error', 'Valid price is required')
|
||||
return
|
||||
}
|
||||
|
||||
const validItems = values.items.filter(item => item.productId > 0)
|
||||
if (validItems.length === 0) {
|
||||
Alert.alert('Error', 'At least one product is required')
|
||||
return
|
||||
}
|
||||
|
||||
let imageUrls: string[] = []
|
||||
|
||||
// Build map of signed URL → raw S3 key for existing images
|
||||
const signedUrlToKey: Record<string, string> = {}
|
||||
initialImages.forEach((url, i) => {
|
||||
if (initialImageKeys[i]) {
|
||||
signedUrlToKey[url] = initialImageKeys[i]
|
||||
}
|
||||
})
|
||||
|
||||
// Existing images that are still present → use their raw S3 keys
|
||||
const keptImageKeys = images
|
||||
.filter(img => img.imgUrl.startsWith('http'))
|
||||
.map(img => signedUrlToKey[img.imgUrl])
|
||||
.filter(Boolean)
|
||||
|
||||
// New images (local file:// URIs) → upload to S3
|
||||
const newImages = images.filter(img => !img.imgUrl.startsWith('http'))
|
||||
|
||||
if (newImages.length > 0) {
|
||||
try {
|
||||
const blobs = await Promise.all(
|
||||
newImages.map(async (img) => {
|
||||
const response = await fetch(img.imgUrl)
|
||||
const blob = await response.blob()
|
||||
return { blob, mimeType: img.mimeType || 'image/jpeg' }
|
||||
})
|
||||
)
|
||||
const result = await upload({ images: blobs, contextString: 'product_info' as any })
|
||||
imageUrls = [...keptImageKeys, ...result.keys]
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to upload images')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
imageUrls = keptImageKeys
|
||||
}
|
||||
|
||||
const offerData = {
|
||||
name: values.name.trim(),
|
||||
shortDescription: values.shortDescription.trim() || undefined,
|
||||
longDescription: values.longDescription.trim() || undefined,
|
||||
price: values.price,
|
||||
marketPrice: values.marketPrice || undefined,
|
||||
images: imageUrls,
|
||||
isFlashEnabled: values.isFlashEnabled,
|
||||
items: validItems.map(item => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
}
|
||||
|
||||
if (isEditMode && offerId) {
|
||||
updateOffer(
|
||||
{ id: offerId, ...offerData } as any,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Offer updated successfully!')
|
||||
onOfferAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update offer')
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
createOffer(
|
||||
offerData as any,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Offer created successfully!')
|
||||
onOfferAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to create offer')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={tw`flex-1 bg-white`} contentContainerStyle={tw`p-4 pb-12`}>
|
||||
<Text style={tw`text-xl font-bold mb-6 text-center`}>
|
||||
{isEditMode ? 'Edit Offer' : 'Create New Offer'}
|
||||
</Text>
|
||||
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
|
||||
{({ handleSubmit, values, setFieldValue }) => (
|
||||
<View>
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-name-input"
|
||||
topLabel="Offer Name *"
|
||||
placeholder="e.g. Weekend Combo Pack"
|
||||
value={values.name}
|
||||
onChangeText={(text) => setFieldValue('name', text)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-short-desc-input"
|
||||
topLabel="Short Description"
|
||||
placeholder="Brief description of the offer"
|
||||
value={values.shortDescription}
|
||||
onChangeText={(text) => setFieldValue('shortDescription', text)}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-long-desc-input"
|
||||
topLabel="Long Description"
|
||||
placeholder="Detailed description"
|
||||
value={values.longDescription}
|
||||
onChangeText={(text) => setFieldValue('longDescription', text)}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<Text style={tw`text-sm font-medium text-gray-700 mb-2`}>Offer Images</Text>
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(newImages) => setImages(prev => [...prev, ...newImages.map((img: ImageUploaderNeoPayload) => ({
|
||||
imgUrl: img.url,
|
||||
mimeType: img.mimeType,
|
||||
}))])}
|
||||
onImageRemove={(img) => setImages(prev => prev.filter(i => i.imgUrl !== img.url))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4 flex-row gap-2`}>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyTextInput
|
||||
testID="offer-price-input"
|
||||
topLabel="Price *"
|
||||
placeholder="₹"
|
||||
value={values.price}
|
||||
onChangeText={(text) => setFieldValue('price', text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyTextInput
|
||||
testID="offer-market-price-input"
|
||||
topLabel="Market Price"
|
||||
placeholder="₹ (optional)"
|
||||
value={values.marketPrice}
|
||||
onChangeText={(text) => setFieldValue('marketPrice', text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center mb-4`}>
|
||||
<Checkbox
|
||||
checked={values.isFlashEnabled}
|
||||
onPress={() => setFieldValue('isFlashEnabled', !values.isFlashEnabled)}
|
||||
/>
|
||||
<Text style={tw`text-sm font-medium text-gray-700 ml-2`}>Flash Enabled</Text>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-3`}>Products in Offer</Text>
|
||||
<FieldArray name="items">
|
||||
{({ push, remove }) => (
|
||||
<View>
|
||||
{values.items.map((item, index) => (
|
||||
<View key={index} style={tw`bg-gray-50 p-4 rounded-xl mb-3 border border-gray-100`}>
|
||||
<View style={tw`flex-row items-center justify-between mb-3`}>
|
||||
<Text style={tw`text-sm font-bold text-gray-700`}>Product {index + 1}</Text>
|
||||
{values.items.length > 1 && (
|
||||
<TouchableOpacity onPress={() => remove(index)}>
|
||||
<MaterialIcons name="close" size={20} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-3`}>
|
||||
<ProductsSelector
|
||||
value={item.productId || []}
|
||||
onChange={(value) => setFieldValue(`items.${index}.productId`, Array.isArray(value) ? value[0] : value)}
|
||||
multiple={false}
|
||||
placeholder="Select a product"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Quantity"
|
||||
placeholder="e.g. 1"
|
||||
value={item.quantity}
|
||||
onChangeText={(text) => setFieldValue(`items.${index}.quantity`, text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => push({ productId: 0, quantity: '1' })}
|
||||
style={tw`bg-blue-500 px-4 py-3 rounded-xl items-center mb-4`}
|
||||
>
|
||||
<Text style={tw`text-white font-medium`}>Add Product</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</FieldArray>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
testID="offer-create-button"
|
||||
accessibilityLabel="offer-create-button"
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isPending}
|
||||
style={tw`${isPending ? 'bg-pink-300' : 'bg-pink-500'} p-4 rounded-xl items-center mt-4`}
|
||||
>
|
||||
<Text style={tw`text-white text-base font-bold`}>
|
||||
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Offer' : 'Create Offer')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { View, Text, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
import DateTimePickerMod from 'common-ui/src/components/date-time-picker';
|
||||
import { tw, MyTextInput } from 'common-ui';
|
||||
import { tw, MyTextInput, BottomDropdown } from 'common-ui';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import ProductsSelector from '../components/ProductsSelector';
|
||||
|
||||
|
|
@ -49,6 +49,7 @@ export default function SlotForm({
|
|||
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||
selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||
selectedOfferIds: slotData?.slot?.offerIds || [],
|
||||
vendorSnippetList: vendorSnippetsFromSlot,
|
||||
};
|
||||
|
||||
|
|
@ -61,6 +62,9 @@ export default function SlotForm({
|
|||
// Fetch groups
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
// Fetch offers
|
||||
const { data: offersData } = trpc.admin.offers.getAll.useQuery();
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -84,6 +88,7 @@ export default function SlotForm({
|
|||
isActive: initialIsActive,
|
||||
groupIds: values.selectedGroupIds,
|
||||
productIds: values.selectedProductIds,
|
||||
offerIds: values.selectedOfferIds,
|
||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||
name: snippet.name,
|
||||
productIds: snippet.productIds,
|
||||
|
|
@ -178,6 +183,24 @@ export default function SlotForm({
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<BottomDropdown
|
||||
testID="slot-offers-dropdown"
|
||||
label="Select Offers"
|
||||
options={(offersData?.offers || []).map((offer: any) => ({
|
||||
label: `${offer.name} (₹${offer.price})`,
|
||||
value: String(offer.id),
|
||||
}))}
|
||||
value={values.selectedOfferIds.map((id: number) => String(id))}
|
||||
onValueChange={(value) => {
|
||||
const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
|
||||
setFieldValue('selectedOfferIds', selectedValues.map((v: string) => Number(v)))
|
||||
}}
|
||||
placeholder="Select offers for this slot"
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Vendor Snippets */}
|
||||
<FieldArray name="vendorSnippetList">
|
||||
{({ push, remove }) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Buffer } from 'buffer'
|
||||
import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common'
|
||||
import { scaffoldOffers } from '@/src/trpc/apis/common-apis/common'
|
||||
import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index'
|
||||
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
|
||||
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
|
||||
|
|
@ -24,6 +25,7 @@ export interface CreateAllCacheFilesResult {
|
|||
stores: string
|
||||
slots: string
|
||||
banners: string
|
||||
offers: string
|
||||
individualStores: string[]
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
|||
storesKey,
|
||||
slotsKey,
|
||||
bannersKey,
|
||||
offersKey,
|
||||
individualStoreKeys,
|
||||
] = await Promise.all([
|
||||
createProductsFileInternal(cacheVersion),
|
||||
|
|
@ -46,6 +49,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
|||
createStoresFileInternal(cacheVersion),
|
||||
createSlotsFileInternal(cacheVersion),
|
||||
createBannersFileInternal(cacheVersion),
|
||||
createOffersFileInternal(cacheVersion),
|
||||
createAllStoresFilesInternal(cacheVersion),
|
||||
])
|
||||
|
||||
|
|
@ -58,6 +62,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
|||
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
|
||||
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
|
||||
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
|
||||
constructCacheUrl(CACHE_FILENAMES.offers, cacheVersion),
|
||||
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
|
||||
]
|
||||
|
||||
|
|
@ -78,6 +83,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
|||
stores: storesKey,
|
||||
slots: slotsKey,
|
||||
banners: bannersKey,
|
||||
offers: offersKey,
|
||||
individualStores: individualStoreKeys,
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +104,17 @@ async function createProductsFileInternal(version: number): Promise<string> {
|
|||
|
||||
}
|
||||
|
||||
async function createOffersFileInternal(version: number): Promise<string> {
|
||||
const offersData = await scaffoldOffers()
|
||||
const jsonContent = JSON.stringify(offersData, null, 2)
|
||||
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||
return await imageUploadS3(
|
||||
buffer,
|
||||
'application/json',
|
||||
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.offers, version)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function createEssentialConstsFileInternal(version: number): Promise<string> {
|
||||
const essentialConstsData = await scaffoldEssentialConsts()
|
||||
const jsonContent = JSON.stringify(essentialConstsData, null, 2)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface SlotWithProducts {
|
|||
freezeTime: Date
|
||||
isActive: boolean
|
||||
isCapacityFull: boolean
|
||||
offerIds?: number[]
|
||||
products: Array<{
|
||||
id: number
|
||||
name: string
|
||||
|
|
@ -42,6 +43,7 @@ async function transformSlotToStoreSlot(slot: SlotWithProductsData): Promise<Slo
|
|||
freezeTime: slot.freezeTime,
|
||||
isActive: slot.isActive,
|
||||
isCapacityFull: slot.isCapacityFull,
|
||||
offerIds: slot.offerIds || [],
|
||||
products: slot.products.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
|
|||
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
||||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||
import { offersRouter } from '@/src/trpc/apis/admin-apis/apis/offers'
|
||||
|
||||
export const adminRouter = router({
|
||||
complaint: complaintRouter,
|
||||
|
|
@ -26,6 +27,7 @@ export const adminRouter = router({
|
|||
banner: bannerRouter,
|
||||
user: userRouter,
|
||||
const: constRouter,
|
||||
offers: offersRouter,
|
||||
});
|
||||
|
||||
export type AdminRouter = typeof adminRouter;
|
||||
|
|
|
|||
138
apps/backend/src/trpc/apis/admin-apis/apis/offers.ts
Normal file
138
apps/backend/src/trpc/apis/admin-apis/apis/offers.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { scaffoldAssetUrl, deleteImageUtil } from '@/src/lib/s3-client'
|
||||
import {
|
||||
getAllOffers as getAllOffersFromDb,
|
||||
getOfferById as getOfferByIdFromDb,
|
||||
createOffer as createOfferInDb,
|
||||
updateOffer as updateOfferInDb,
|
||||
deleteOffer as deleteOfferFromDb,
|
||||
toggleSuspendOffer as toggleSuspendOfferInDb,
|
||||
} from '@/src/dbService'
|
||||
|
||||
const offerItemInput = z.object({
|
||||
productId: z.number(),
|
||||
quantity: z.string(),
|
||||
})
|
||||
|
||||
export const offersRouter = router({
|
||||
getAll: protectedProcedure
|
||||
.query(async (): Promise<{ offers: any[]; count: number }> => {
|
||||
const offers = await getAllOffersFromDb()
|
||||
|
||||
const offersWithSignedImages = offers.map(offer => ({
|
||||
...offer,
|
||||
imageKeys: offer.images || [],
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
items: offer.items.map((item: any) => ({
|
||||
...item,
|
||||
product: item.product ? {
|
||||
...item.product,
|
||||
images: (item.product.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
} : null,
|
||||
})),
|
||||
}))
|
||||
|
||||
return { offers: offersWithSignedImages, count: offers.length }
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }): Promise<{ offer: any }> => {
|
||||
const offer = await getOfferByIdFromDb(input.id)
|
||||
if (!offer) throw new ApiError('Offer not found', 404)
|
||||
|
||||
const signedOffer = {
|
||||
...offer,
|
||||
imageKeys: offer.images || [],
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
items: offer.items.map((item: any) => ({
|
||||
...item,
|
||||
product: item.product ? {
|
||||
...item.product,
|
||||
images: (item.product.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
} : null,
|
||||
})),
|
||||
}
|
||||
|
||||
return { offer: signedOffer }
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
shortDescription: z.string().optional(),
|
||||
longDescription: z.string().optional(),
|
||||
price: z.string(),
|
||||
marketPrice: z.string().optional(),
|
||||
images: z.array(z.string()).optional().default([]),
|
||||
isSuspended: z.boolean().optional().default(false),
|
||||
isFlashEnabled: z.boolean().optional().default(false),
|
||||
items: z.array(offerItemInput).min(1, 'At least one product is required'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const offer = await createOfferInDb(input)
|
||||
return {
|
||||
offer: {
|
||||
...offer,
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
},
|
||||
message: 'Offer created successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(1).optional(),
|
||||
shortDescription: z.string().optional(),
|
||||
longDescription: z.string().optional(),
|
||||
price: z.string().optional(),
|
||||
marketPrice: z.string().optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
isSuspended: z.boolean().optional(),
|
||||
isFlashEnabled: z.boolean().optional(),
|
||||
items: z.array(offerItemInput).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, ...updateData } = input
|
||||
const offer = await updateOfferInDb(id, updateData as any)
|
||||
return {
|
||||
offer: {
|
||||
...offer,
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
},
|
||||
message: 'Offer updated successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const offer = await getOfferByIdFromDb(input.id)
|
||||
if (!offer) throw new ApiError('Offer not found', 404)
|
||||
|
||||
if (offer.images?.length) {
|
||||
await deleteImageUtil({ keys: offer.images })
|
||||
}
|
||||
|
||||
await deleteOfferFromDb(input.id)
|
||||
return { message: 'Offer deleted successfully' }
|
||||
}),
|
||||
|
||||
toggleSuspend: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const offer = await toggleSuspendOfferInDb(input.id)
|
||||
return {
|
||||
offer: {
|
||||
...offer,
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
},
|
||||
message: `Offer ${offer.isSuspended ? 'suspended' : 'activated'} successfully`,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type OffersRouter = typeof offersRouter
|
||||
|
|
@ -53,6 +53,7 @@ const createSlotSchema = z.object({
|
|||
validTill: z.string().optional(),
|
||||
})).optional(),
|
||||
groupIds: z.array(z.number()).optional(),
|
||||
offerIds: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
const getSlotByIdSchema = z.object({
|
||||
|
|
@ -71,6 +72,7 @@ const updateSlotSchema = z.object({
|
|||
validTill: z.string().optional(),
|
||||
})).optional(),
|
||||
groupIds: z.array(z.number()).optional(),
|
||||
offerIds: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
const deleteSlotSchema = z.object({
|
||||
|
|
@ -282,7 +284,7 @@ export const slotsRouter = router({
|
|||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
}
|
||||
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input;
|
||||
|
||||
// Validate required fields
|
||||
if (!deliveryTime || !freezeTime) {
|
||||
|
|
@ -296,6 +298,7 @@ export const slotsRouter = router({
|
|||
productIds,
|
||||
vendorSnippets: snippets,
|
||||
groupIds,
|
||||
offerIds,
|
||||
})
|
||||
|
||||
/*
|
||||
|
|
@ -445,7 +448,7 @@ export const slotsRouter = router({
|
|||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
}
|
||||
try{
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input;
|
||||
|
||||
if (!deliveryTime || !freezeTime) {
|
||||
throw new ApiError("Delivery time and orders close time are required", 400);
|
||||
|
|
@ -459,6 +462,7 @@ export const slotsRouter = router({
|
|||
productIds,
|
||||
vendorSnippets: snippets,
|
||||
groupIds,
|
||||
offerIds,
|
||||
})
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
getSuspendedProductIds,
|
||||
getNextDeliveryDateWithCapacity,
|
||||
getStoresSummary,
|
||||
getAllOffers as getAllOffersFromDb,
|
||||
} from '@/src/dbService'
|
||||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
||||
|
|
@ -64,6 +65,37 @@ export async function scaffoldProducts() {
|
|||
};
|
||||
}
|
||||
|
||||
export async function scaffoldOffers() {
|
||||
const allOffers = await getAllOffersFromDb()
|
||||
|
||||
const activeOffers = allOffers.filter((offer: any) => !offer.isSuspended)
|
||||
|
||||
const formattedOffers = activeOffers.map((offer: any) => ({
|
||||
id: offer.id,
|
||||
name: offer.name,
|
||||
shortDescription: offer.shortDescription,
|
||||
longDescription: offer.longDescription,
|
||||
price: offer.price,
|
||||
marketPrice: offer.marketPrice || null,
|
||||
images: offer.images || [],
|
||||
isFlashEnabled: offer.isFlashEnabled,
|
||||
items: (offer.items || []).map((item: any) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images || [],
|
||||
} : null,
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
offers: formattedOffers,
|
||||
count: formattedOffers.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const commonRouter = router({
|
||||
getDashboardTags: publicProcedure
|
||||
.query(async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { router, publicProcedure } from "@/src/trpc/trpc-index"
|
|||
import { z } from "zod"
|
||||
import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"
|
||||
import dayjs from 'dayjs'
|
||||
import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb } from '@/src/dbService'
|
||||
import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb, getAllOffers as getAllOffersFromDb } from '@/src/dbService'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import type { UserSlotData, UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared'
|
||||
|
||||
// Helper method to get formatted slot data by ID
|
||||
|
|
@ -28,6 +29,7 @@ async function getSlotData(slotId: number) {
|
|||
|
||||
export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProductsResponse> {
|
||||
const allSlots = await getAllSlotsFromCache();
|
||||
const allOffers = await getAllOffersFromDb()
|
||||
const currentTime = new Date();
|
||||
const validSlots = allSlots
|
||||
.filter((slot) => {
|
||||
|
|
@ -39,28 +41,38 @@ export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProducts
|
|||
|
||||
const productAvailability = await getUserProductAvailabilityInDb()
|
||||
|
||||
/*
|
||||
// Old implementation - direct DB query:
|
||||
const allProducts = await db
|
||||
.select({
|
||||
id: productInfo.id,
|
||||
name: productInfo.name,
|
||||
isOutOfStock: productInfo.isOutOfStock,
|
||||
isFlashAvailable: productInfo.isFlashAvailable,
|
||||
})
|
||||
.from(productInfo)
|
||||
.where(eq(productInfo.isSuspended, false));
|
||||
const offersMap: Record<number, any> = {}
|
||||
allOffers.forEach((offer: any) => {
|
||||
if (!offer.isSuspended) {
|
||||
offersMap[offer.id] = {
|
||||
id: offer.id,
|
||||
name: offer.name,
|
||||
shortDescription: offer.shortDescription,
|
||||
longDescription: offer.longDescription,
|
||||
price: offer.price,
|
||||
marketPrice: offer.marketPrice || null,
|
||||
images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)),
|
||||
isFlashEnabled: offer.isFlashEnabled,
|
||||
items: (offer.items || []).map((item: any) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images,
|
||||
} : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const productAvailability = allProducts.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
isOutOfStock: product.isOutOfStock,
|
||||
isFlashAvailable: product.isFlashAvailable,
|
||||
}));
|
||||
*/
|
||||
const slotsWithOffers = validSlots.map(slot => ({
|
||||
...slot,
|
||||
offers: (slot.offerIds || []).map((id: number) => offersMap[id]).filter(Boolean),
|
||||
}))
|
||||
|
||||
return {
|
||||
slots: validSlots,
|
||||
slots: slotsWithOffers,
|
||||
productAvailability,
|
||||
count: validSlots.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import {
|
|||
handleOrderPlacedQueue,
|
||||
handleOrderCancelledQueue,
|
||||
} from './src/lib/queue-consumer'
|
||||
import { scaffoldSlotsWithProducts } from './src/trpc/apis/user-apis/apis/slots'
|
||||
|
||||
export { CacheCreator }
|
||||
|
||||
let app: ReturnType<typeof createApp> | null = null
|
||||
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@
|
|||
--file dumps/latest.sql --remote
|
||||
# run a single file
|
||||
|
||||
|
||||
|
||||
|
||||
wrangler d1 execute freshyo-backend-dev \
|
||||
--config wrangler.dev.toml \
|
||||
--file ../../packages/db_helper_sqlite/drizzle/0002_offers.sql
|
||||
|
|
@ -8,8 +8,10 @@ routes = [
|
|||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "freshyo-backend-dev"
|
||||
database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a"
|
||||
#database_name = "freshyo-backend-dev"
|
||||
#database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a"
|
||||
database_name = "freshyo-dev"
|
||||
database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de"
|
||||
migrations_dir="../../packages/db_helper_sqlite/drizzle"
|
||||
migrations_pattern="migration.sql"
|
||||
[durable_objects]
|
||||
|
|
|
|||
|
|
@ -186,6 +186,16 @@ export {
|
|||
getVendorOrders,
|
||||
} from './src/admin-apis/vendor-snippets';
|
||||
|
||||
export {
|
||||
// Offers
|
||||
getAllOffers,
|
||||
getOfferById,
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
toggleSuspendOffer,
|
||||
} from './src/admin-apis/offers'
|
||||
|
||||
export {
|
||||
// User Address
|
||||
getDefaultAddress as getUserDefaultAddress,
|
||||
|
|
|
|||
162
packages/db_helper_postgres/src/admin-apis/offers.ts
Normal file
162
packages/db_helper_postgres/src/admin-apis/offers.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { db } from '../db/db_index'
|
||||
import { offers, offerItems, productInfo, units } from '../db/schema'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
|
||||
export interface OfferItemInput {
|
||||
productId: number
|
||||
quantity: string
|
||||
}
|
||||
|
||||
export interface CreateOfferInput {
|
||||
name: string
|
||||
shortDescription?: string | null
|
||||
longDescription?: string | null
|
||||
price: string
|
||||
marketPrice?: string | null
|
||||
images?: string[] | null
|
||||
isSuspended?: boolean
|
||||
isFlashEnabled?: boolean
|
||||
items: OfferItemInput[]
|
||||
}
|
||||
|
||||
export interface UpdateOfferInput extends Partial<CreateOfferInput> {}
|
||||
|
||||
export async function getAllOffers(): Promise<any[]> {
|
||||
const results = await db.query.offers.findMany({
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: {
|
||||
columns: { id: true, name: true, images: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: desc(offers.addedOn),
|
||||
})
|
||||
|
||||
return results.map((offer: any) => ({
|
||||
...offer,
|
||||
images: offer.images || [],
|
||||
items: (offer.items || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
offerId: item.offerId,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images || [],
|
||||
} : null,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getOfferById(id: number): Promise<any | null> {
|
||||
const result = await db.query.offers.findFirst({
|
||||
where: eq(offers.id, id),
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: {
|
||||
columns: { id: true, name: true, images: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) return null
|
||||
|
||||
return {
|
||||
...result,
|
||||
images: result.images || [],
|
||||
items: (result.items || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
offerId: item.offerId,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images || [],
|
||||
} : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOffer(input: CreateOfferInput): Promise<any> {
|
||||
const [offer] = await db.insert(offers).values({
|
||||
name: input.name,
|
||||
shortDescription: input.shortDescription || null,
|
||||
longDescription: input.longDescription || null,
|
||||
price: numericStr(input.price),
|
||||
marketPrice: input.marketPrice ? numericStr(input.marketPrice) : null,
|
||||
images: input.images || [],
|
||||
isSuspended: input.isSuspended || false,
|
||||
isFlashEnabled: input.isFlashEnabled || false,
|
||||
}).returning()
|
||||
|
||||
if (input.items.length > 0) {
|
||||
await db.insert(offerItems).values(
|
||||
input.items.map(item => ({
|
||||
offerId: offer.id,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return getOfferById(offer.id)
|
||||
}
|
||||
|
||||
export async function updateOffer(id: number, input: UpdateOfferInput): Promise<any> {
|
||||
const updateData: Record<string, any> = {}
|
||||
if (input.name !== undefined) updateData.name = input.name
|
||||
if (input.shortDescription !== undefined) updateData.shortDescription = input.shortDescription
|
||||
if (input.longDescription !== undefined) updateData.longDescription = input.longDescription
|
||||
if (input.price !== undefined) updateData.price = numericStr(input.price)
|
||||
if (input.marketPrice !== undefined) updateData.marketPrice = input.marketPrice ? numericStr(input.marketPrice) : null
|
||||
if (input.images !== undefined) updateData.images = input.images
|
||||
if (input.isSuspended !== undefined) updateData.isSuspended = input.isSuspended
|
||||
if (input.isFlashEnabled !== undefined) updateData.isFlashEnabled = input.isFlashEnabled
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(offers).set(updateData).where(eq(offers.id, id))
|
||||
}
|
||||
|
||||
if (input.items !== undefined) {
|
||||
await db.delete(offerItems).where(eq(offerItems.offerId, id))
|
||||
if (input.items.length > 0) {
|
||||
await db.insert(offerItems).values(
|
||||
input.items.map(item => ({
|
||||
offerId: id,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return getOfferById(id)
|
||||
}
|
||||
|
||||
export async function deleteOffer(id: number): Promise<void> {
|
||||
await db.delete(offerItems).where(eq(offerItems.offerId, id))
|
||||
await db.delete(offers).where(eq(offers.id, id))
|
||||
}
|
||||
|
||||
export async function toggleSuspendOffer(id: number): Promise<any> {
|
||||
const existing = await getOfferById(id)
|
||||
if (!existing) throw new Error('Offer not found')
|
||||
|
||||
await db.update(offers)
|
||||
.set({ isSuspended: !existing.isSuspended })
|
||||
.where(eq(offers.id, id))
|
||||
|
||||
return getOfferById(id)
|
||||
}
|
||||
|
||||
function numericStr(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
|
@ -179,8 +179,9 @@ export async function createSlotWithRelations(input: {
|
|||
productIds?: number[]
|
||||
vendorSnippets?: SlotSnippetInput[]
|
||||
groupIds?: number[]
|
||||
offerIds?: number[]
|
||||
}): Promise<AdminSlotCreateResult> {
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [newSlot] = await tx
|
||||
|
|
@ -191,6 +192,7 @@ export async function createSlotWithRelations(input: {
|
|||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: groupIds !== undefined ? groupIds : [],
|
||||
productIds: productIds !== undefined ? productIds : [],
|
||||
offerIds: offerIds || [],
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
|
@ -240,8 +242,9 @@ export async function updateSlotWithRelations(input: {
|
|||
productIds?: number[]
|
||||
vendorSnippets?: SlotSnippetInput[]
|
||||
groupIds?: number[]
|
||||
offerIds?: number[]
|
||||
}): Promise<AdminSlotUpdateResult | null> {
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input
|
||||
|
||||
let validGroupIds = groupIds
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
|
|
@ -261,6 +264,7 @@ export async function updateSlotWithRelations(input: {
|
|||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||
...(productIds !== undefined && { productIds }),
|
||||
...(offerIds !== undefined && { offerIds }),
|
||||
})
|
||||
.where(eq(deliverySlotInfo.id, id))
|
||||
.returning()
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
|||
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||
productIds: jsonb('product_ids').$defaultFn(() => []),
|
||||
offerIds: jsonb('offer_ids').$defaultFn(() => []),
|
||||
});
|
||||
|
||||
export const vendorSnippets = mf.table('vendor_snippets', {
|
||||
|
|
@ -212,6 +213,28 @@ export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
|||
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
||||
}));
|
||||
|
||||
export const offers = mf.table('offers', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
shortDescription: varchar('short_description', { length: 500 }),
|
||||
longDescription: varchar('long_description', { length: 1000 }),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
marketPrice: numeric('market_price', { precision: 10, scale: 2 }),
|
||||
images: jsonb('images').$defaultFn(() => []),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
isFlashEnabled: boolean('is_flash_enabled').notNull().default(false),
|
||||
addedOn: timestamp('added_on').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const offerItems = mf.table('offer_items', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
offerId: integer('offer_id').notNull().references(() => offers.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: varchar('quantity', { length: 20 }).notNull(),
|
||||
}, (t) => ({
|
||||
unq_offer_product: unique('unique_offer_product').on(t.offerId, t.productId),
|
||||
}))
|
||||
|
||||
export const specialDeals = mf.table('special_deals', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
|
|
@ -673,4 +696,13 @@ export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
|||
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
|
||||
}))
|
||||
|
||||
export const offersRelations = relations(offers, ({ many }) => ({
|
||||
items: many(offerItems),
|
||||
}))
|
||||
|
||||
export const offerItemsRelations = relations(offerItems, ({ one }) => ({
|
||||
offer: one(offers, { fields: [offerItems.offerId], references: [offers.id] }),
|
||||
product: one(productInfo, { fields: [offerItems.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
|
|
|||
30
packages/db_helper_sqlite/drizzle/0002_offers.sql
Normal file
30
packages/db_helper_sqlite/drizzle/0002_offers.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- Migration: Add offers tables and delivery slot offer_ids
|
||||
-- Creates the offers feature: bundles of products at a fixed price
|
||||
|
||||
-- Step 1: Create offers table
|
||||
CREATE TABLE `offers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`short_description` text,
|
||||
`long_description` text,
|
||||
`price` text NOT NULL,
|
||||
`market_price` text,
|
||||
`images` text DEFAULT '[]',
|
||||
`is_suspended` integer DEFAULT 0 NOT NULL,
|
||||
`is_flash_enabled` integer DEFAULT 0 NOT NULL,
|
||||
`added_on` text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Step 2: Create offer_items junction table
|
||||
CREATE TABLE `offer_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`offer_id` integer NOT NULL REFERENCES `offers`(`id`),
|
||||
`product_id` integer NOT NULL REFERENCES `product_info`(`id`),
|
||||
`quantity` text NOT NULL
|
||||
);
|
||||
|
||||
-- Step 3: Unique index on offer + product
|
||||
CREATE UNIQUE INDEX `unique_offer_product` ON `offer_items` (`offer_id`, `product_id`);
|
||||
|
||||
-- Step 4: Add offer_ids array to delivery slots
|
||||
ALTER TABLE `delivery_slot_info` ADD COLUMN `offer_ids` text DEFAULT '[]';
|
||||
|
|
@ -8,6 +8,20 @@
|
|||
"when": 1774588140474,
|
||||
"tag": "0000_nifty_sauron",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1747862400000,
|
||||
"tag": "0001_migrate_product_slots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756233600000,
|
||||
"tag": "0002_offers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,16 @@ export {
|
|||
getVendorOrders,
|
||||
} from './src/admin-apis/vendor-snippets'
|
||||
|
||||
export {
|
||||
// Offers
|
||||
getAllOffers,
|
||||
getOfferById,
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
toggleSuspendOffer,
|
||||
} from './src/admin-apis/offers'
|
||||
|
||||
export {
|
||||
// User Address
|
||||
getDefaultAddress as getUserDefaultAddress,
|
||||
|
|
|
|||
167
packages/db_helper_sqlite/src/admin-apis/offers.ts
Normal file
167
packages/db_helper_sqlite/src/admin-apis/offers.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { db } from '../db/db_index'
|
||||
import { offers, offerItems } from '../db/schema'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
|
||||
export type OfferRow = typeof offers.$inferSelect
|
||||
export type OfferItemRow = typeof offerItems.$inferSelect
|
||||
|
||||
export interface OfferItemInput {
|
||||
productId: number
|
||||
quantity: string
|
||||
}
|
||||
|
||||
export interface CreateOfferInput {
|
||||
name: string
|
||||
shortDescription?: string | null
|
||||
longDescription?: string | null
|
||||
price: string
|
||||
marketPrice?: string | null
|
||||
images?: string[] | null
|
||||
isSuspended?: boolean
|
||||
isFlashEnabled?: boolean
|
||||
items: OfferItemInput[]
|
||||
}
|
||||
|
||||
export interface UpdateOfferInput extends Partial<CreateOfferInput> {}
|
||||
|
||||
export async function getAllOffers(): Promise<any[]> {
|
||||
const results = await db.query.offers.findMany({
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: {
|
||||
columns: { id: true, name: true, images: true },
|
||||
with: {
|
||||
unit: { columns: { shortNotation: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: desc(offers.addedOn),
|
||||
})
|
||||
|
||||
return results.map((offer: any) => ({
|
||||
...offer,
|
||||
items: offer.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
offerId: item.offerId,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images,
|
||||
unitShortNotation: item.product.unit?.shortNotation || '',
|
||||
} : null,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getOfferById(id: number): Promise<any | null> {
|
||||
const result = await db.query.offers.findFirst({
|
||||
where: eq(offers.id, id),
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: {
|
||||
columns: { id: true, name: true, images: true },
|
||||
with: {
|
||||
unit: { columns: { shortNotation: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) return null
|
||||
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
offerId: item.offerId,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product ? {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
images: item.product.images,
|
||||
unitShortNotation: item.product.unit?.shortNotation || '',
|
||||
} : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOffer(input: CreateOfferInput): Promise<any> {
|
||||
const [offer] = await db.insert(offers).values({
|
||||
name: input.name,
|
||||
shortDescription: input.shortDescription || null,
|
||||
longDescription: input.longDescription || null,
|
||||
price: input.price,
|
||||
marketPrice: input.marketPrice || null,
|
||||
images: input.images || [],
|
||||
isSuspended: input.isSuspended || false,
|
||||
isFlashEnabled: input.isFlashEnabled || false,
|
||||
}).returning()
|
||||
|
||||
if (input.items.length > 0) {
|
||||
await db.insert(offerItems).values(
|
||||
input.items.map(item => ({
|
||||
offerId: offer.id,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return getOfferById(offer.id)
|
||||
}
|
||||
|
||||
export async function updateOffer(id: number, input: UpdateOfferInput): Promise<any> {
|
||||
const updateData: Record<string, any> = {}
|
||||
if (input.name !== undefined) updateData.name = input.name
|
||||
if (input.shortDescription !== undefined) updateData.shortDescription = input.shortDescription
|
||||
if (input.longDescription !== undefined) updateData.longDescription = input.longDescription
|
||||
if (input.price !== undefined) updateData.price = input.price
|
||||
if (input.marketPrice !== undefined) updateData.marketPrice = input.marketPrice
|
||||
if (input.images !== undefined) updateData.images = input.images
|
||||
if (input.isSuspended !== undefined) updateData.isSuspended = input.isSuspended
|
||||
if (input.isFlashEnabled !== undefined) updateData.isFlashEnabled = input.isFlashEnabled
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(offers).set(updateData).where(eq(offers.id, id))
|
||||
}
|
||||
|
||||
if (input.items !== undefined) {
|
||||
await db.delete(offerItems).where(eq(offerItems.offerId, id))
|
||||
if (input.items.length > 0) {
|
||||
await db.insert(offerItems).values(
|
||||
input.items.map(item => ({
|
||||
offerId: id,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return getOfferById(id)
|
||||
}
|
||||
|
||||
export async function deleteOffer(id: number): Promise<void> {
|
||||
await db.delete(offerItems).where(eq(offerItems.offerId, id))
|
||||
await db.delete(offers).where(eq(offers.id, id))
|
||||
}
|
||||
|
||||
export async function toggleSuspendOffer(id: number): Promise<any> {
|
||||
const existing = await getOfferById(id)
|
||||
if (!existing) throw new Error('Offer not found')
|
||||
|
||||
await db.update(offers)
|
||||
.set({ isSuspended: !existing.isSuspended })
|
||||
.where(eq(offers.id, id))
|
||||
|
||||
return getOfferById(id)
|
||||
}
|
||||
|
|
@ -224,8 +224,9 @@ export async function createSlotWithRelations(input: {
|
|||
productIds?: number[]
|
||||
vendorSnippets?: SlotSnippetInput[]
|
||||
groupIds?: number[]
|
||||
offerIds?: number[]
|
||||
}): Promise<AdminSlotCreateResult> {
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input
|
||||
|
||||
const normalizedProductIds = normalizeProductIds(productIds)
|
||||
|
||||
|
|
@ -247,6 +248,7 @@ export async function createSlotWithRelations(input: {
|
|||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: groupIds !== undefined ? groupIds : [],
|
||||
productIds: normalizedProductIds,
|
||||
offerIds: offerIds || [],
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
|
@ -296,8 +298,9 @@ export async function updateSlotWithRelations(input: {
|
|||
productIds?: number[]
|
||||
vendorSnippets?: SlotSnippetInput[]
|
||||
groupIds?: number[]
|
||||
offerIds?: number[]
|
||||
}): Promise<AdminSlotUpdateResult | null> {
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input
|
||||
|
||||
let validGroupIds = groupIds
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
|
|
@ -328,6 +331,7 @@ export async function updateSlotWithRelations(input: {
|
|||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||
...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }),
|
||||
...(offerIds !== undefined && { offerIds }),
|
||||
})
|
||||
.where(eq(deliverySlotInfo.id, id))
|
||||
.returning()
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ export const deliverySlotInfo = sqliteTable('delivery_slot_info', {
|
|||
deliverySequence: jsonText<Record<string, number>>('delivery_sequence').$defaultFn(() => ({})),
|
||||
groupIds: jsonText<number[]>('group_ids').$defaultFn(() => []),
|
||||
productIds: jsonText<number[]>('product_ids').$defaultFn(() => []),
|
||||
offerIds: jsonText<number[]>('offer_ids').$defaultFn(() => []),
|
||||
})
|
||||
|
||||
export const vendorSnippets = sqliteTable('vendor_snippets', {
|
||||
|
|
@ -293,6 +294,28 @@ export const vendorSnippets = sqliteTable('vendor_snippets', {
|
|||
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
})
|
||||
|
||||
export const offers = sqliteTable('offers', {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
shortDescription: text('short_description'),
|
||||
longDescription: text('long_description'),
|
||||
price: numericText('price').notNull(),
|
||||
marketPrice: numericText('market_price'),
|
||||
images: jsonText<string[]>('images').$defaultFn(() => []),
|
||||
isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false),
|
||||
isFlashEnabled: integer('is_flash_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
addedOn: timestampText('added_on').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
})
|
||||
|
||||
export const offerItems = sqliteTable('offer_items', {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
offerId: integer('offer_id').notNull().references(() => offers.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: numericText('quantity').notNull(),
|
||||
}, (t) => ({
|
||||
unq_offer_product: uniqueIndex('unique_offer_product').on(t.offerId, t.productId),
|
||||
}))
|
||||
|
||||
export const specialDeals = sqliteTable('special_deals', {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
|
|
@ -730,3 +753,12 @@ export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
|||
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
||||
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
||||
}))
|
||||
|
||||
export const offersRelations = relations(offers, ({ many }) => ({
|
||||
items: many(offerItems),
|
||||
}))
|
||||
|
||||
export const offerItemsRelations = relations(offerItems, ({ one }) => ({
|
||||
offer: one(offers, { fields: [offerItems.offerId], references: [offers.id] }),
|
||||
product: one(productInfo, { fields: [offerItems.productId], references: [productInfo.id] }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ export interface SlotWithProductsData {
|
|||
freezeTime: Date
|
||||
isActive: boolean
|
||||
isCapacityFull: boolean
|
||||
offerIds?: number[]
|
||||
products: Array<{
|
||||
id: number
|
||||
name: string
|
||||
|
|
@ -291,6 +292,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
|||
freezeTime: slot.freezeTime,
|
||||
isActive: slot.isActive,
|
||||
isCapacityFull: slot.isCapacityFull,
|
||||
offerIds: slot.offerIds,
|
||||
products: (slot.productIds || [])
|
||||
.map(productId => productMap.get(productId))
|
||||
.filter((p): p is NonNullable<typeof p> => p != null)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const CACHE_FILENAMES = {
|
|||
slots: 'slots.json',
|
||||
essentialConsts: 'essential-consts.json',
|
||||
banners: 'banners.json',
|
||||
offers: 'offers.json',
|
||||
} as const
|
||||
|
||||
export type CacheFilename = typeof CACHE_FILENAMES[keyof typeof CACHE_FILENAMES]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue