This commit is contained in:
shafi54 2026-07-28 01:47:38 +05:30
parent 8fae91b582
commit d7a588a529
30 changed files with 1313 additions and 30 deletions

View file

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

View file

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -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 () => {

View file

@ -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,
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,
})),
}
}
}) })
.from(productInfo)
.where(eq(productInfo.isSuspended, false));
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,
}; };

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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 '[]';

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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