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} />
|
<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
|
<DrawerItem
|
||||||
label="Logout"
|
label="Logout"
|
||||||
onPress={() => logout()}
|
onPress={() => logout()}
|
||||||
|
|
@ -225,6 +232,7 @@ export default function Layout() {
|
||||||
<Drawer.Screen name="complaints" options={{ title: "Complaints" }} />
|
<Drawer.Screen name="complaints" options={{ title: "Complaints" }} />
|
||||||
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
<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="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,16 @@ export default function Dashboard() {
|
||||||
category: 'marketing',
|
category: 'marketing',
|
||||||
iconColor: '#F97316',
|
iconColor: '#F97316',
|
||||||
iconBg: '#FFEDD5',
|
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',
|
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 { View, Text, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik';
|
||||||
import DateTimePickerMod from 'common-ui/src/components/date-time-picker';
|
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 { trpc } from '../src/trpc-client';
|
||||||
import ProductsSelector from '../components/ProductsSelector';
|
import ProductsSelector from '../components/ProductsSelector';
|
||||||
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default function SlotForm({
|
||||||
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||||
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||||
selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||||
|
selectedOfferIds: slotData?.slot?.offerIds || [],
|
||||||
vendorSnippetList: vendorSnippetsFromSlot,
|
vendorSnippetList: vendorSnippetsFromSlot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,6 +62,9 @@ export default function SlotForm({
|
||||||
// Fetch groups
|
// Fetch groups
|
||||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
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,
|
isActive: initialIsActive,
|
||||||
groupIds: values.selectedGroupIds,
|
groupIds: values.selectedGroupIds,
|
||||||
productIds: values.selectedProductIds,
|
productIds: values.selectedProductIds,
|
||||||
|
offerIds: values.selectedOfferIds,
|
||||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||||
name: snippet.name,
|
name: snippet.name,
|
||||||
productIds: snippet.productIds,
|
productIds: snippet.productIds,
|
||||||
|
|
@ -178,6 +183,24 @@ export default function SlotForm({
|
||||||
/>
|
/>
|
||||||
</View>
|
</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 */}
|
{/* Vendor Snippets */}
|
||||||
<FieldArray name="vendorSnippetList">
|
<FieldArray name="vendorSnippetList">
|
||||||
{({ push, remove }) => (
|
{({ push, remove }) => (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Buffer } from 'buffer'
|
import { Buffer } from 'buffer'
|
||||||
import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common'
|
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 { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index'
|
||||||
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
|
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
|
||||||
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
|
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
|
||||||
|
|
@ -24,6 +25,7 @@ export interface CreateAllCacheFilesResult {
|
||||||
stores: string
|
stores: string
|
||||||
slots: string
|
slots: string
|
||||||
banners: string
|
banners: string
|
||||||
|
offers: string
|
||||||
individualStores: string[]
|
individualStores: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
storesKey,
|
storesKey,
|
||||||
slotsKey,
|
slotsKey,
|
||||||
bannersKey,
|
bannersKey,
|
||||||
|
offersKey,
|
||||||
individualStoreKeys,
|
individualStoreKeys,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
createProductsFileInternal(cacheVersion),
|
createProductsFileInternal(cacheVersion),
|
||||||
|
|
@ -46,6 +49,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
createStoresFileInternal(cacheVersion),
|
createStoresFileInternal(cacheVersion),
|
||||||
createSlotsFileInternal(cacheVersion),
|
createSlotsFileInternal(cacheVersion),
|
||||||
createBannersFileInternal(cacheVersion),
|
createBannersFileInternal(cacheVersion),
|
||||||
|
createOffersFileInternal(cacheVersion),
|
||||||
createAllStoresFilesInternal(cacheVersion),
|
createAllStoresFilesInternal(cacheVersion),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -58,6 +62,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
|
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
|
||||||
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
|
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
|
||||||
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
|
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.offers, cacheVersion),
|
||||||
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
|
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -78,6 +83,7 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
|
||||||
stores: storesKey,
|
stores: storesKey,
|
||||||
slots: slotsKey,
|
slots: slotsKey,
|
||||||
banners: bannersKey,
|
banners: bannersKey,
|
||||||
|
offers: offersKey,
|
||||||
individualStores: individualStoreKeys,
|
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> {
|
async function createEssentialConstsFileInternal(version: number): Promise<string> {
|
||||||
const essentialConstsData = await scaffoldEssentialConsts()
|
const essentialConstsData = await scaffoldEssentialConsts()
|
||||||
const jsonContent = JSON.stringify(essentialConstsData, null, 2)
|
const jsonContent = JSON.stringify(essentialConstsData, null, 2)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface SlotWithProducts {
|
||||||
freezeTime: Date
|
freezeTime: Date
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isCapacityFull: boolean
|
isCapacityFull: boolean
|
||||||
|
offerIds?: number[]
|
||||||
products: Array<{
|
products: Array<{
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -42,6 +43,7 @@ async function transformSlotToStoreSlot(slot: SlotWithProductsData): Promise<Slo
|
||||||
freezeTime: slot.freezeTime,
|
freezeTime: slot.freezeTime,
|
||||||
isActive: slot.isActive,
|
isActive: slot.isActive,
|
||||||
isCapacityFull: slot.isCapacityFull,
|
isCapacityFull: slot.isCapacityFull,
|
||||||
|
offerIds: slot.offerIds || [],
|
||||||
products: slot.products.map((product) => ({
|
products: slot.products.map((product) => ({
|
||||||
id: product.id,
|
id: product.id,
|
||||||
name: product.name,
|
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 { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
||||||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||||
|
import { offersRouter } from '@/src/trpc/apis/admin-apis/apis/offers'
|
||||||
|
|
||||||
export const adminRouter = router({
|
export const adminRouter = router({
|
||||||
complaint: complaintRouter,
|
complaint: complaintRouter,
|
||||||
|
|
@ -26,6 +27,7 @@ export const adminRouter = router({
|
||||||
banner: bannerRouter,
|
banner: bannerRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
const: constRouter,
|
const: constRouter,
|
||||||
|
offers: offersRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdminRouter = typeof adminRouter;
|
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(),
|
validTill: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
groupIds: z.array(z.number()).optional(),
|
groupIds: z.array(z.number()).optional(),
|
||||||
|
offerIds: z.array(z.number()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSlotByIdSchema = z.object({
|
const getSlotByIdSchema = z.object({
|
||||||
|
|
@ -71,6 +72,7 @@ const updateSlotSchema = z.object({
|
||||||
validTill: z.string().optional(),
|
validTill: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
groupIds: z.array(z.number()).optional(),
|
groupIds: z.array(z.number()).optional(),
|
||||||
|
offerIds: z.array(z.number()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteSlotSchema = z.object({
|
const deleteSlotSchema = z.object({
|
||||||
|
|
@ -282,7 +284,7 @@ export const slotsRouter = router({
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
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
|
// Validate required fields
|
||||||
if (!deliveryTime || !freezeTime) {
|
if (!deliveryTime || !freezeTime) {
|
||||||
|
|
@ -296,6 +298,7 @@ export const slotsRouter = router({
|
||||||
productIds,
|
productIds,
|
||||||
vendorSnippets: snippets,
|
vendorSnippets: snippets,
|
||||||
groupIds,
|
groupIds,
|
||||||
|
offerIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -445,7 +448,7 @@ export const slotsRouter = router({
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||||
}
|
}
|
||||||
try{
|
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) {
|
if (!deliveryTime || !freezeTime) {
|
||||||
throw new ApiError("Delivery time and orders close time are required", 400);
|
throw new ApiError("Delivery time and orders close time are required", 400);
|
||||||
|
|
@ -459,6 +462,7 @@ export const slotsRouter = router({
|
||||||
productIds,
|
productIds,
|
||||||
vendorSnippets: snippets,
|
vendorSnippets: snippets,
|
||||||
groupIds,
|
groupIds,
|
||||||
|
offerIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
getSuspendedProductIds,
|
getSuspendedProductIds,
|
||||||
getNextDeliveryDateWithCapacity,
|
getNextDeliveryDateWithCapacity,
|
||||||
getStoresSummary,
|
getStoresSummary,
|
||||||
|
getAllOffers as getAllOffersFromDb,
|
||||||
} from '@/src/dbService'
|
} from '@/src/dbService'
|
||||||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
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({
|
export const commonRouter = router({
|
||||||
getDashboardTags: publicProcedure
|
getDashboardTags: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { router, publicProcedure } from "@/src/trpc/trpc-index"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"
|
import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"
|
||||||
import dayjs from 'dayjs'
|
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'
|
import type { UserSlotData, UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared'
|
||||||
|
|
||||||
// Helper method to get formatted slot data by ID
|
// Helper method to get formatted slot data by ID
|
||||||
|
|
@ -28,6 +29,7 @@ async function getSlotData(slotId: number) {
|
||||||
|
|
||||||
export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProductsResponse> {
|
export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProductsResponse> {
|
||||||
const allSlots = await getAllSlotsFromCache();
|
const allSlots = await getAllSlotsFromCache();
|
||||||
|
const allOffers = await getAllOffersFromDb()
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
const validSlots = allSlots
|
const validSlots = allSlots
|
||||||
.filter((slot) => {
|
.filter((slot) => {
|
||||||
|
|
@ -39,28 +41,38 @@ export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProducts
|
||||||
|
|
||||||
const productAvailability = await getUserProductAvailabilityInDb()
|
const productAvailability = await getUserProductAvailabilityInDb()
|
||||||
|
|
||||||
/*
|
const offersMap: Record<number, any> = {}
|
||||||
// Old implementation - direct DB query:
|
allOffers.forEach((offer: any) => {
|
||||||
const allProducts = await db
|
if (!offer.isSuspended) {
|
||||||
.select({
|
offersMap[offer.id] = {
|
||||||
id: productInfo.id,
|
id: offer.id,
|
||||||
name: productInfo.name,
|
name: offer.name,
|
||||||
isOutOfStock: productInfo.isOutOfStock,
|
shortDescription: offer.shortDescription,
|
||||||
isFlashAvailable: productInfo.isFlashAvailable,
|
longDescription: offer.longDescription,
|
||||||
})
|
price: offer.price,
|
||||||
.from(productInfo)
|
marketPrice: offer.marketPrice || null,
|
||||||
.where(eq(productInfo.isSuspended, false));
|
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 => ({
|
const slotsWithOffers = validSlots.map(slot => ({
|
||||||
id: product.id,
|
...slot,
|
||||||
name: product.name,
|
offers: (slot.offerIds || []).map((id: number) => offersMap[id]).filter(Boolean),
|
||||||
isOutOfStock: product.isOutOfStock,
|
}))
|
||||||
isFlashAvailable: product.isFlashAvailable,
|
|
||||||
}));
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots: validSlots,
|
slots: slotsWithOffers,
|
||||||
productAvailability,
|
productAvailability,
|
||||||
count: validSlots.length,
|
count: validSlots.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ import {
|
||||||
handleOrderPlacedQueue,
|
handleOrderPlacedQueue,
|
||||||
handleOrderCancelledQueue,
|
handleOrderCancelledQueue,
|
||||||
} from './src/lib/queue-consumer'
|
} from './src/lib/queue-consumer'
|
||||||
|
import { scaffoldSlotsWithProducts } from './src/trpc/apis/user-apis/apis/slots'
|
||||||
|
|
||||||
export { CacheCreator }
|
export { CacheCreator }
|
||||||
|
|
||||||
let app: ReturnType<typeof createApp> | null = null
|
let app: ReturnType<typeof createApp> | null = null
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(
|
async fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,9 @@
|
||||||
--file dumps/latest.sql --remote
|
--file dumps/latest.sql --remote
|
||||||
# run a single file
|
# 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]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "freshyo-backend-dev"
|
#database_name = "freshyo-backend-dev"
|
||||||
database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a"
|
#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_dir="../../packages/db_helper_sqlite/drizzle"
|
||||||
migrations_pattern="migration.sql"
|
migrations_pattern="migration.sql"
|
||||||
[durable_objects]
|
[durable_objects]
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,16 @@ export {
|
||||||
getVendorOrders,
|
getVendorOrders,
|
||||||
} from './src/admin-apis/vendor-snippets';
|
} from './src/admin-apis/vendor-snippets';
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Offers
|
||||||
|
getAllOffers,
|
||||||
|
getOfferById,
|
||||||
|
createOffer,
|
||||||
|
updateOffer,
|
||||||
|
deleteOffer,
|
||||||
|
toggleSuspendOffer,
|
||||||
|
} from './src/admin-apis/offers'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// User Address
|
// User Address
|
||||||
getDefaultAddress as getUserDefaultAddress,
|
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[]
|
productIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
|
offerIds?: number[]
|
||||||
}): Promise<AdminSlotCreateResult> {
|
}): 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 result = await db.transaction(async (tx) => {
|
||||||
const [newSlot] = await tx
|
const [newSlot] = await tx
|
||||||
|
|
@ -191,6 +192,7 @@ export async function createSlotWithRelations(input: {
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: groupIds !== undefined ? groupIds : [],
|
groupIds: groupIds !== undefined ? groupIds : [],
|
||||||
productIds: productIds !== undefined ? productIds : [],
|
productIds: productIds !== undefined ? productIds : [],
|
||||||
|
offerIds: offerIds || [],
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
|
@ -240,8 +242,9 @@ export async function updateSlotWithRelations(input: {
|
||||||
productIds?: number[]
|
productIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
|
offerIds?: number[]
|
||||||
}): Promise<AdminSlotUpdateResult | null> {
|
}): 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
|
let validGroupIds = groupIds
|
||||||
if (groupIds && groupIds.length > 0) {
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
|
@ -261,6 +264,7 @@ export async function updateSlotWithRelations(input: {
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||||
...(productIds !== undefined && { productIds }),
|
...(productIds !== undefined && { productIds }),
|
||||||
|
...(offerIds !== undefined && { offerIds }),
|
||||||
})
|
})
|
||||||
.where(eq(deliverySlotInfo.id, id))
|
.where(eq(deliverySlotInfo.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||||
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||||
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||||
productIds: jsonb('product_ids').$defaultFn(() => []),
|
productIds: jsonb('product_ids').$defaultFn(() => []),
|
||||||
|
offerIds: jsonb('offer_ids').$defaultFn(() => []),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const vendorSnippets = mf.table('vendor_snippets', {
|
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] }),
|
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', {
|
export const specialDeals = mf.table('special_deals', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
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] }),
|
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||||
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||||
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.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,
|
"when": 1774588140474,
|
||||||
"tag": "0000_nifty_sauron",
|
"tag": "0000_nifty_sauron",
|
||||||
"breakpoints": true
|
"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,
|
getVendorOrders,
|
||||||
} from './src/admin-apis/vendor-snippets'
|
} from './src/admin-apis/vendor-snippets'
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Offers
|
||||||
|
getAllOffers,
|
||||||
|
getOfferById,
|
||||||
|
createOffer,
|
||||||
|
updateOffer,
|
||||||
|
deleteOffer,
|
||||||
|
toggleSuspendOffer,
|
||||||
|
} from './src/admin-apis/offers'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// User Address
|
// User Address
|
||||||
getDefaultAddress as getUserDefaultAddress,
|
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[]
|
productIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
|
offerIds?: number[]
|
||||||
}): Promise<AdminSlotCreateResult> {
|
}): 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)
|
const normalizedProductIds = normalizeProductIds(productIds)
|
||||||
|
|
||||||
|
|
@ -247,6 +248,7 @@ export async function createSlotWithRelations(input: {
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: groupIds !== undefined ? groupIds : [],
|
groupIds: groupIds !== undefined ? groupIds : [],
|
||||||
productIds: normalizedProductIds,
|
productIds: normalizedProductIds,
|
||||||
|
offerIds: offerIds || [],
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
|
@ -296,8 +298,9 @@ export async function updateSlotWithRelations(input: {
|
||||||
productIds?: number[]
|
productIds?: number[]
|
||||||
vendorSnippets?: SlotSnippetInput[]
|
vendorSnippets?: SlotSnippetInput[]
|
||||||
groupIds?: number[]
|
groupIds?: number[]
|
||||||
|
offerIds?: number[]
|
||||||
}): Promise<AdminSlotUpdateResult | null> {
|
}): 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
|
let validGroupIds = groupIds
|
||||||
if (groupIds && groupIds.length > 0) {
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
|
@ -328,6 +331,7 @@ export async function updateSlotWithRelations(input: {
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||||
...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }),
|
...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }),
|
||||||
|
...(offerIds !== undefined && { offerIds }),
|
||||||
})
|
})
|
||||||
.where(eq(deliverySlotInfo.id, id))
|
.where(eq(deliverySlotInfo.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ export const deliverySlotInfo = sqliteTable('delivery_slot_info', {
|
||||||
deliverySequence: jsonText<Record<string, number>>('delivery_sequence').$defaultFn(() => ({})),
|
deliverySequence: jsonText<Record<string, number>>('delivery_sequence').$defaultFn(() => ({})),
|
||||||
groupIds: jsonText<number[]>('group_ids').$defaultFn(() => []),
|
groupIds: jsonText<number[]>('group_ids').$defaultFn(() => []),
|
||||||
productIds: jsonText<number[]>('product_ids').$defaultFn(() => []),
|
productIds: jsonText<number[]>('product_ids').$defaultFn(() => []),
|
||||||
|
offerIds: jsonText<number[]>('offer_ids').$defaultFn(() => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const vendorSnippets = sqliteTable('vendor_snippets', {
|
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`),
|
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', {
|
export const specialDeals = sqliteTable('special_deals', {
|
||||||
id: integer().primaryKey({ autoIncrement: true }),
|
id: integer().primaryKey({ autoIncrement: true }),
|
||||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
|
@ -730,3 +753,12 @@ export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
||||||
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
||||||
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
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
|
freezeTime: Date
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isCapacityFull: boolean
|
isCapacityFull: boolean
|
||||||
|
offerIds?: number[]
|
||||||
products: Array<{
|
products: Array<{
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -291,6 +292,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
||||||
freezeTime: slot.freezeTime,
|
freezeTime: slot.freezeTime,
|
||||||
isActive: slot.isActive,
|
isActive: slot.isActive,
|
||||||
isCapacityFull: slot.isCapacityFull,
|
isCapacityFull: slot.isCapacityFull,
|
||||||
|
offerIds: slot.offerIds,
|
||||||
products: (slot.productIds || [])
|
products: (slot.productIds || [])
|
||||||
.map(productId => productMap.get(productId))
|
.map(productId => productMap.get(productId))
|
||||||
.filter((p): p is NonNullable<typeof p> => p != null)
|
.filter((p): p is NonNullable<typeof p> => p != null)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const CACHE_FILENAMES = {
|
||||||
slots: 'slots.json',
|
slots: 'slots.json',
|
||||||
essentialConsts: 'essential-consts.json',
|
essentialConsts: 'essential-consts.json',
|
||||||
banners: 'banners.json',
|
banners: 'banners.json',
|
||||||
|
offers: 'offers.json',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CacheFilename = typeof CACHE_FILENAMES[keyof typeof CACHE_FILENAMES]
|
export type CacheFilename = typeof CACHE_FILENAMES[keyof typeof CACHE_FILENAMES]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue