enh
This commit is contained in:
parent
27c1515c86
commit
9bfe057c49
22 changed files with 427 additions and 57 deletions
9
.commandcode/settings.json
Normal file
9
.commandcode/settings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"defaultMode": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
# Taste
|
# Taste
|
||||||
|
See [taste/taste.md](taste/taste.md)
|
||||||
- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9
|
|
||||||
|
|
||||||
- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6
|
|
||||||
|
|
||||||
- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9
|
|
||||||
|
|
||||||
- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4
|
|
||||||
|
|
|
||||||
11
.commandcode/taste/taste/taste.md
Normal file
11
.commandcode/taste/taste/taste.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Taste
|
||||||
|
- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9
|
||||||
|
- Prefers detailed analysis and explicit error/mistake checking before executing git operations (e.g., merges), rather than just performing the action. Confidence: 0.6
|
||||||
|
- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9
|
||||||
|
- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4
|
||||||
|
- Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7
|
||||||
|
- Wants the agent to come up with a plan first before implementing code changes. Confidence: 0.9
|
||||||
|
- Appreciates being asked clarifying design questions with concrete options during planning. Confidence: 0.7
|
||||||
|
- Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9
|
||||||
|
- Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9
|
||||||
|
- When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8
|
||||||
|
|
@ -25,6 +25,13 @@ export default function AddProduct() {
|
||||||
const seenSignatures = new Set<string>()
|
const seenSignatures = new Set<string>()
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const attributes = variant.attributes || []
|
const attributes = variant.attributes || []
|
||||||
|
const hasQuantity = attributes.some(
|
||||||
|
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
|
)
|
||||||
|
if (!hasQuantity) {
|
||||||
|
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||||
|
return
|
||||||
|
}
|
||||||
const signature = attributes
|
const signature = attributes
|
||||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||||
.sort()
|
.sort()
|
||||||
|
|
@ -68,6 +75,7 @@ export default function AddProduct() {
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.attributes.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
featureValue: attr.featureValue,
|
featureValue: attr.featureValue,
|
||||||
|
|
@ -111,7 +119,8 @@ export default function AddProduct() {
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
attributes: [{ featureName: 'quantity', featureValue: '' }],
|
isSuspended: false,
|
||||||
|
attributes: [{ featureName: '', featureValue: '' }],
|
||||||
comboItems: [] as { skuId: number | string }[],
|
comboItems: [] as { skuId: number | string }[],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export default function EditProduct() {
|
||||||
flashPrice: sku.flashPrice || '',
|
flashPrice: sku.flashPrice || '',
|
||||||
isOffer: sku.isOffer || false,
|
isOffer: sku.isOffer || false,
|
||||||
isComboOnly: sku.isComboOnly || false,
|
isComboOnly: sku.isComboOnly || false,
|
||||||
|
isSuspended: sku.isSuspended || false,
|
||||||
attributes: (sku.features || []).map((f) => ({
|
attributes: (sku.features || []).map((f) => ({
|
||||||
featureName: f.featureName,
|
featureName: f.featureName,
|
||||||
featureValue: f.featureValue,
|
featureValue: f.featureValue,
|
||||||
|
|
@ -89,6 +90,13 @@ export default function EditProduct() {
|
||||||
const seenSignatures = new Set<string>()
|
const seenSignatures = new Set<string>()
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const attributes = variant.attributes || []
|
const attributes = variant.attributes || []
|
||||||
|
const hasQuantity = attributes.some(
|
||||||
|
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
|
)
|
||||||
|
if (!hasQuantity) {
|
||||||
|
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||||
|
return
|
||||||
|
}
|
||||||
const signature = attributes
|
const signature = attributes
|
||||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||||
.sort()
|
.sort()
|
||||||
|
|
@ -144,6 +152,7 @@ export default function EditProduct() {
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.attributes.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
featureValue: attr.featureValue,
|
featureValue: attr.featureValue,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface Variant {
|
||||||
flashPrice: string
|
flashPrice: string
|
||||||
isOffer: boolean
|
isOffer: boolean
|
||||||
isComboOnly: boolean
|
isComboOnly: boolean
|
||||||
|
isSuspended: boolean
|
||||||
attributes: Attribute[]
|
attributes: Attribute[]
|
||||||
comboItems: { skuId: number | string }[]
|
comboItems: { skuId: number | string }[]
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +47,7 @@ interface ProductFormProps {
|
||||||
existingVariantImageKeys?: string[][]
|
existingVariantImageKeys?: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
|
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
|
||||||
|
|
||||||
const defaultVariant = (): Variant => ({
|
const defaultVariant = (): Variant => ({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
|
|
@ -57,6 +58,7 @@ const defaultVariant = (): Variant => ({
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
|
isSuspended: false,
|
||||||
attributes: [defaultAttribute()],
|
attributes: [defaultAttribute()],
|
||||||
comboItems: [],
|
comboItems: [],
|
||||||
})
|
})
|
||||||
|
|
@ -112,6 +114,19 @@ const productValidationSchema = Yup.object().shape({
|
||||||
seen.add(signature)
|
seen.add(signature)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
})
|
||||||
|
.test('quantity-feature', 'Every SKU must have a quantity feature', function (variants) {
|
||||||
|
if (!Array.isArray(variants)) return true
|
||||||
|
for (const variant of variants) {
|
||||||
|
const attrs = variant?.attributes || []
|
||||||
|
const hasQuantity = attrs.some(
|
||||||
|
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
|
)
|
||||||
|
if (!hasQuantity) {
|
||||||
|
return this.createError({ message: 'Every SKU must have a quantity feature' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -289,6 +304,13 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => pushAttr(defaultAttribute())}
|
||||||
|
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center self-start`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={14} color="#4B5563" />
|
||||||
|
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add Feature</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
|
@ -344,6 +366,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText>
|
<MyText style={tw`text-gray-700 font-medium`}>Combo Only SKU</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={variant.isSuspended}
|
||||||
|
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
|
||||||
|
style={tw`mr-3`}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-gray-700 font-medium`}>Suspend SKU</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{variant.isFlashAvailable && (
|
{variant.isFlashAvailable && (
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel="Flash Price"
|
topLabel="Flash Price"
|
||||||
|
|
@ -415,6 +448,17 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
push(defaultVariant())
|
||||||
|
setVariantImages((prev) => [...prev, []])
|
||||||
|
}}
|
||||||
|
style={tw`bg-blue-500 px-3 py-2 rounded-lg flex-row items-center justify-center mb-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={18} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>Add Variant</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@ export const productRouter = router({
|
||||||
flashPrice: z.number().optional().nullable(),
|
flashPrice: z.number().optional().nullable(),
|
||||||
isOffer: z.boolean().optional().default(false),
|
isOffer: z.boolean().optional().default(false),
|
||||||
isComboOnly: z.boolean().optional().default(false),
|
isComboOnly: z.boolean().optional().default(false),
|
||||||
|
isSuspended: z.boolean().optional().default(false),
|
||||||
features: z.array(z.object({
|
features: z.array(z.object({
|
||||||
featureName: z.string().nullable().optional(),
|
featureName: z.string().nullable().optional(),
|
||||||
featureValue: z.string().min(1, 'Value is required'),
|
featureValue: z.string().min(1, 'Value is required'),
|
||||||
|
|
@ -226,6 +227,7 @@ export const productRouter = router({
|
||||||
flashPrice: sku.flashPrice ?? null,
|
flashPrice: sku.flashPrice ?? null,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
|
isSuspended: sku.isSuspended,
|
||||||
features: sku.features.map((f) => ({
|
features: sku.features.map((f) => ({
|
||||||
featureName: f.featureName?.trim() || null,
|
featureName: f.featureName?.trim() || null,
|
||||||
featureValue: f.featureValue,
|
featureValue: f.featureValue,
|
||||||
|
|
@ -284,6 +286,7 @@ export const productRouter = router({
|
||||||
flashPrice: z.number().optional().nullable(),
|
flashPrice: z.number().optional().nullable(),
|
||||||
isOffer: z.boolean().optional().default(false),
|
isOffer: z.boolean().optional().default(false),
|
||||||
isComboOnly: z.boolean().optional().default(false),
|
isComboOnly: z.boolean().optional().default(false),
|
||||||
|
isSuspended: z.boolean().optional().default(false),
|
||||||
features: z.array(z.object({
|
features: z.array(z.object({
|
||||||
featureName: z.string().nullable().optional(),
|
featureName: z.string().nullable().optional(),
|
||||||
featureValue: z.string().min(1, 'Value is required'),
|
featureValue: z.string().min(1, 'Value is required'),
|
||||||
|
|
@ -314,6 +317,7 @@ export const productRouter = router({
|
||||||
flashPrice: sku.flashPrice ?? null,
|
flashPrice: sku.flashPrice ?? null,
|
||||||
isOffer: sku.isOffer,
|
isOffer: sku.isOffer,
|
||||||
isComboOnly: sku.isComboOnly,
|
isComboOnly: sku.isComboOnly,
|
||||||
|
isSuspended: sku.isSuspended,
|
||||||
features: sku.features.map((f) => ({
|
features: sku.features.map((f) => ({
|
||||||
featureName: f.featureName?.trim() || null,
|
featureName: f.featureName?.trim() || null,
|
||||||
featureValue: f.featureValue,
|
featureValue: f.featureValue,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import {
|
||||||
getNextDeliveryDateWithCapacity,
|
getNextDeliveryDateWithCapacity,
|
||||||
getStoresSummary,
|
getStoresSummary,
|
||||||
getAllSkusSummary as getAllSkusSummaryInDb,
|
getAllSkusSummary as getAllSkusSummaryInDb,
|
||||||
|
getAllTagsForCache,
|
||||||
|
getAllTagProductMappings,
|
||||||
} from '@/src/dbService'
|
} from '@/src/dbService'
|
||||||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||||
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
|
||||||
import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store'
|
import { getDashboardTags as getDashboardTagsFromCache } from '@/src/stores/product-tag-store'
|
||||||
|
|
||||||
|
|
@ -60,9 +62,34 @@ export async function scaffoldProducts() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch all product tags with their mapped product ids
|
||||||
|
const [allTags, tagMappings] = await Promise.all([
|
||||||
|
getAllTagsForCache(),
|
||||||
|
getAllTagProductMappings(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const productIdsByTag = new Map<number, number[]>()
|
||||||
|
for (const mapping of tagMappings) {
|
||||||
|
if (!productIdsByTag.has(mapping.tagId)) {
|
||||||
|
productIdsByTag.set(mapping.tagId, [])
|
||||||
|
}
|
||||||
|
productIdsByTag.get(mapping.tagId)!.push(mapping.productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = allTags.map((tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown }) => ({
|
||||||
|
id: tag.id,
|
||||||
|
tagName: tag.tagName,
|
||||||
|
tagDescription: tag.tagDescription,
|
||||||
|
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||||
|
isDashboardTag: tag.isDashboardTag,
|
||||||
|
relatedStores: (tag.relatedStores as number[]) || [],
|
||||||
|
productIds: productIdsByTag.get(tag.id) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: formattedProducts,
|
products: formattedProducts,
|
||||||
count: formattedProducts.length,
|
count: formattedProducts.length,
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from "react";
|
import React, { useState, useCallback, useMemo, memo } from "react";
|
||||||
import { View, Dimensions, Image, RefreshControl } from "react-native";
|
import { View, Dimensions, Image, RefreshControl, ScrollView } from "react-native";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,6 +58,20 @@ const staticStyles = {
|
||||||
slotsListContent: { paddingBottom: 24 },
|
slotsListContent: { paddingBottom: 24 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Light/pastel color pairs for the Explore Products tabs.
|
||||||
|
const TAG_COLORS = [
|
||||||
|
{ bg: '#FFE4E6', border: '#FECDD3', text: '#BE123C' }, // rose
|
||||||
|
{ bg: '#FEF3C7', border: '#FDE68A', text: '#B45309' }, // amber
|
||||||
|
{ bg: '#DCFCE7', border: '#BBF7D0', text: '#15803D' }, // green
|
||||||
|
{ bg: '#DBEAFE', border: '#BFDBFE', text: '#1D4ED8' }, // blue
|
||||||
|
{ bg: '#EDE9FE', border: '#DDD6FE', text: '#6D28D9' }, // violet
|
||||||
|
{ bg: '#FFE4CC', border: '#FFD6B0', text: '#C2410C' }, // orange
|
||||||
|
{ bg: '#CFFAFE', border: '#A5F3FC', text: '#0E7490' }, // cyan
|
||||||
|
{ bg: '#FCE7F3', border: '#FBCFE8', text: '#BE185D' }, // pink
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length];
|
||||||
|
|
||||||
interface RenderStoreProps {
|
interface RenderStoreProps {
|
||||||
item: any;
|
item: any;
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +210,61 @@ const PopularProductItem = memo(({ item, onPress }: PopularProductItemProps) =>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface ExploreTabProps {
|
||||||
|
tag: any;
|
||||||
|
isSelected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreTab = memo(({ tag, isSelected, onPress }: ExploreTabProps) => {
|
||||||
|
const color = getTagColor(tag.id);
|
||||||
|
const productCount = tag.productIds?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={[
|
||||||
|
tw`px-4 py-2.5 rounded-full border`,
|
||||||
|
isSelected
|
||||||
|
? { backgroundColor: color.bg, borderColor: color.text }
|
||||||
|
: { backgroundColor: '#FFFFFF', borderColor: color.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MyText
|
||||||
|
style={[
|
||||||
|
tw`text-sm font-semibold`,
|
||||||
|
{ color: color.text },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tag.tagName} ({productCount})
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ExploreProductItemProps {
|
||||||
|
item: any;
|
||||||
|
onPress: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => {
|
||||||
|
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`mr-4`}>
|
||||||
|
<ProductCard
|
||||||
|
item={item}
|
||||||
|
itemWidth={itemWidth}
|
||||||
|
onPress={handlePress}
|
||||||
|
showDeliveryInfo={false}
|
||||||
|
useAddToCartDialog={true}
|
||||||
|
miniView={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface SlotItemProps {
|
interface SlotItemProps {
|
||||||
item: any;
|
item: any;
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +298,10 @@ interface ListHeaderProps {
|
||||||
popularProducts: any[];
|
popularProducts: any[];
|
||||||
sortedSlots: any[];
|
sortedSlots: any[];
|
||||||
onProductPress: (id: number) => void;
|
onProductPress: (id: number) => void;
|
||||||
|
dashboardTags: any[];
|
||||||
|
activeTagId: number | null;
|
||||||
|
activeTagProducts: any[];
|
||||||
|
onSelectTag: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListHeader = memo(({
|
const ListHeader = memo(({
|
||||||
|
|
@ -238,6 +311,10 @@ const ListHeader = memo(({
|
||||||
popularProducts,
|
popularProducts,
|
||||||
sortedSlots,
|
sortedSlots,
|
||||||
onProductPress,
|
onProductPress,
|
||||||
|
dashboardTags,
|
||||||
|
activeTagId,
|
||||||
|
activeTagProducts,
|
||||||
|
onSelectTag,
|
||||||
}: ListHeaderProps) => {
|
}: ListHeaderProps) => {
|
||||||
const handleLayout = useCallback((event: any) => {
|
const handleLayout = useCallback((event: any) => {
|
||||||
const { y, height } = event.nativeEvent.layout;
|
const { y, height } = event.nativeEvent.layout;
|
||||||
|
|
@ -248,6 +325,10 @@ const ListHeader = memo(({
|
||||||
<PopularProductItem item={item} onPress={onProductPress} />
|
<PopularProductItem item={item} onPress={onProductPress} />
|
||||||
), [onProductPress]);
|
), [onProductPress]);
|
||||||
|
|
||||||
|
const renderExploreItem = useCallback(({ item }: { item: any }) => (
|
||||||
|
<ExploreProductItem item={item} onPress={onProductPress} />
|
||||||
|
), [onProductPress]);
|
||||||
|
|
||||||
const renderSlotItem = useCallback(({ item }: { item: any }) => (
|
const renderSlotItem = useCallback(({ item }: { item: any }) => (
|
||||||
<SlotItem item={item} />
|
<SlotItem item={item} />
|
||||||
), []);
|
), []);
|
||||||
|
|
@ -323,6 +404,55 @@ const ListHeader = memo(({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{dashboardTags.length > 0 && (
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<View style={tw`px-1 mb-2`}>
|
||||||
|
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Explore Products</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>Browse by category</MyText>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={tw`gap-2 py-2`}
|
||||||
|
>
|
||||||
|
{dashboardTags.map((tag) => (
|
||||||
|
<ExploreTab
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
isSelected={activeTagId === tag.id}
|
||||||
|
onPress={() => onSelectTag(tag.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
{activeTagProducts.length > 0 ? (
|
||||||
|
<View style={tw`mt-3 relative`}>
|
||||||
|
<MyFlatList
|
||||||
|
data={activeTagProducts}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={staticStyles.popularListContent}
|
||||||
|
renderItem={renderExploreItem}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.08)"]}
|
||||||
|
start={{ x: 0, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 0.5 }}
|
||||||
|
style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`py-6 items-center`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
||||||
|
No products in this category yet
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{sortedSlots.length > 0 && (
|
{sortedSlots.length > 0 && (
|
||||||
<View style={tw`mt-2 mb-4`}>
|
<View style={tw`mt-2 mb-4`}>
|
||||||
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
|
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
|
||||||
|
|
@ -386,6 +516,10 @@ export default function Dashboard() {
|
||||||
const { data: slotsData } = useSlots();
|
const { data: slotsData } = useSlots();
|
||||||
|
|
||||||
const products = productsData?.products || [];
|
const products = productsData?.products || [];
|
||||||
|
const dashboardTags = productsData?.tags || [];
|
||||||
|
|
||||||
|
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
|
||||||
|
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null;
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -446,6 +580,26 @@ export default function Dashboard() {
|
||||||
.filter((product): product is NonNullable<typeof product> => product != null);
|
.filter((product): product is NonNullable<typeof product> => product != null);
|
||||||
}, [popularItemIds, products]);
|
}, [popularItemIds, products]);
|
||||||
|
|
||||||
|
const activeTagProducts = useMemo(() => {
|
||||||
|
if (activeTagId == null) return [];
|
||||||
|
const activeTag = dashboardTags.find((tag: any) => tag.id === activeTagId);
|
||||||
|
if (!activeTag) return [];
|
||||||
|
|
||||||
|
return products
|
||||||
|
.filter((product: any) => activeTag.productIds?.includes(product.id) ?? false)
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
const slotA = getQuickestSlot(a.id)
|
||||||
|
const slotB = getQuickestSlot(b.id)
|
||||||
|
|
||||||
|
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
|
||||||
|
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
|
||||||
|
|
||||||
|
if (aOutOfStock && !bOutOfStock) return 1
|
||||||
|
if (!aOutOfStock && bOutOfStock) return -1
|
||||||
|
return 0
|
||||||
|
});
|
||||||
|
}, [activeTagId, dashboardTags, products, getQuickestSlot, productSlotsMap]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -499,8 +653,12 @@ export default function Dashboard() {
|
||||||
popularProducts={popularProducts}
|
popularProducts={popularProducts}
|
||||||
sortedSlots={sortedSlots}
|
sortedSlots={sortedSlots}
|
||||||
onProductPress={handleProductPress}
|
onProductPress={handleProductPress}
|
||||||
|
dashboardTags={dashboardTags}
|
||||||
|
activeTagId={activeTagId}
|
||||||
|
activeTagProducts={activeTagProducts}
|
||||||
|
onSelectTag={setSelectedTagId}
|
||||||
/>
|
/>
|
||||||
), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress]);
|
), [gradientHeight, handleGradientLayout, storesData, popularProducts, sortedSlots, handleProductPress, dashboardTags, activeTagId, activeTagProducts]);
|
||||||
|
|
||||||
const searchBarContainerStyle = useMemo(() => [
|
const searchBarContainerStyle = useMemo(() => [
|
||||||
tw`w-full px-4 pt-4 pb-2`,
|
tw`w-full px-4 pt-4 pb-2`,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import TabLayoutWrapper from "@/components/TabLayoutWrapper";
|
||||||
const { width: screenWidth } = Dimensions.get("window");
|
const { width: screenWidth } = Dimensions.get("window");
|
||||||
const itemWidth = screenWidth * 0.45;
|
const itemWidth = screenWidth * 0.45;
|
||||||
|
|
||||||
const rowListContent = { paddingBottom: 16 };
|
const rowListContent = { paddingBottom: 16, paddingHorizontal: 16 };
|
||||||
|
|
||||||
interface OffersRowProps {
|
interface OffersRowProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -75,7 +75,7 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
|
||||||
colors={["transparent", "rgba(0,0,0,0.08)"]}
|
colors={["transparent", "rgba(0,0,0,0.08)"]}
|
||||||
start={{ x: 0, y: 0.5 }}
|
start={{ x: 0, y: 0.5 }}
|
||||||
end={{ x: 1, y: 0.5 }}
|
end={{ x: 1, y: 0.5 }}
|
||||||
style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`}
|
style={tw`absolute right-4 top-0 bottom-4 w-12 rounded-l-xl`}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useAuth } from "@/src/contexts/AuthContext";
|
||||||
import { tw, theme, MyTouchableOpacity, MyText } from "common-ui";
|
import { tw, theme, MyTouchableOpacity, MyText } from "common-ui";
|
||||||
import HomeIcon from "@/components/icons/HomeIcon";
|
import HomeIcon from "@/components/icons/HomeIcon";
|
||||||
import StoresIcon from "@/components/icons/StoresIcon";
|
import StoresIcon from "@/components/icons/StoresIcon";
|
||||||
import OrderAgainIcon from "@/components/icons/OrderAgainIcon";
|
import OffersIcon from "@/components/icons/OffersIcon";
|
||||||
import MeIcon from "@/components/icons/MeIcon";
|
import MeIcon from "@/components/icons/MeIcon";
|
||||||
import { useAppStore } from "@/src/store/appStore";
|
import { useAppStore } from "@/src/store/appStore";
|
||||||
|
|
||||||
|
|
@ -97,9 +97,9 @@ export default function Layout() {
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="(tabs)/order-again"
|
name="(tabs)/order-again"
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: 'Order Again',
|
tabBarLabel: 'Offers',
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
tabBarIcon: ({ color, size, focused }) => (
|
||||||
<OrderAgainIcon focused={focused} size={size} color={color} />
|
<OffersIcon focused={focused} size={size} color={color} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, TouchableOpacity, ScrollView, Alert } from 'react-native';
|
import { View, Text, TouchableOpacity, ScrollView, Alert, NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
|
||||||
import { tw, BottomDialog, RawBottomDialog } from 'common-ui';
|
import { tw, BottomDialog, RawBottomDialog } from 'common-ui';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import AddressForm from '@/src/components/AddressForm';
|
import AddressForm from '@/src/components/AddressForm';
|
||||||
|
|
@ -14,6 +14,8 @@ interface AddressSelectorProps {
|
||||||
onAddressSelect: (addressId: number) => void;
|
onAddressSelect: (addressId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CARD_WIDTH = 300; // 288 (w-72) + 12 (mr-3)
|
||||||
|
|
||||||
const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
||||||
selectedAddress,
|
selectedAddress,
|
||||||
onAddressSelect,
|
onAddressSelect,
|
||||||
|
|
@ -21,6 +23,7 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
||||||
const [showAddAddress, setShowAddAddress] = useState(false);
|
const [showAddAddress, setShowAddAddress] = useState(false);
|
||||||
const [editingLocationAddressId, setEditingLocationAddressId] = useState<number | null>(null);
|
const [editingLocationAddressId, setEditingLocationAddressId] = useState<number | null>(null);
|
||||||
const [locationLoading, setLocationLoading] = useState(false);
|
const [locationLoading, setLocationLoading] = useState(false);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
@ -70,9 +73,15 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
||||||
|
|
||||||
// Reset scroll to left when address is selected
|
// Reset scroll to left when address is selected
|
||||||
const resetScrollToLeft = () => {
|
const resetScrollToLeft = () => {
|
||||||
|
setCurrentIndex(0);
|
||||||
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true });
|
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const index = Math.round(event.nativeEvent.contentOffset.x / CARD_WIDTH);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAttachLocation = async (address: any) => {
|
const handleAttachLocation = async (address: any) => {
|
||||||
setEditingLocationAddressId(address.id);
|
setEditingLocationAddressId(address.id);
|
||||||
setLocationLoading(true);
|
setLocationLoading(true);
|
||||||
|
|
@ -145,6 +154,11 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={tw`pb-2`}
|
style={tw`pb-2`}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
onMomentumScrollEnd={handleScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToInterval={CARD_WIDTH}
|
||||||
>
|
>
|
||||||
{sortedAddresses.map((address) => (
|
{sortedAddresses.map((address) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -187,6 +201,20 @@ const CheckoutAddressSelector: React.FC<AddressSelectorProps> = ({
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination Dots */}
|
||||||
|
{sortedAddresses.length > 1 && (
|
||||||
|
<View style={tw`flex-row justify-center mt-3`}>
|
||||||
|
{sortedAddresses.map((_, index: number) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={tw`w-2 h-2 rounded-full mx-1 ${
|
||||||
|
index === currentIndex ? 'bg-brand500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Attach Location for selected address - outside the white box */}
|
{/* Attach Location for selected address - outside the white box */}
|
||||||
{selectedAddress && (() => {
|
{selectedAddress && (() => {
|
||||||
const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress);
|
const selectedAddr = sortedAddresses.find(a => a.id === selectedAddress);
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,9 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
<MyText style={tw`text-3xl font-bold text-gray-900`}>
|
<MyText style={tw`text-3xl font-bold text-gray-900`}>
|
||||||
₹{productDetail.price}
|
₹{productDetail.price}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
{productDetail.productType !== 'combo' && (
|
||||||
<MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {productDetail.unitNotation}</MyText>
|
<MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {productDetail.unitNotation}</MyText>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show market price discount if available */}
|
{/* Show market price discount if available */}
|
||||||
{productDetail.marketPrice && (
|
{productDetail.marketPrice && (
|
||||||
|
|
@ -296,7 +298,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
|
{productAvailability?.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
|
||||||
<View style={tw`mt-1`}>
|
<View style={tw`mt-1`}>
|
||||||
<MyText style={tw`text-pink-600 text-lg font-bold`}>
|
<MyText style={tw`text-pink-600 text-lg font-bold`}>
|
||||||
1 Hr Delivery: ₹{productDetail.flashPrice} / {productDetail.unitNotation}
|
1 Hr Delivery: ₹{productDetail.flashPrice}{productDetail.productType !== 'combo' ? ` / ${productDetail.unitNotation}` : ''}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -463,7 +465,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
|
||||||
|
|
||||||
{productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => (
|
{productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => (
|
||||||
<View key={index} style={tw`flex-row justify-between items-center p-3 bg-amber-50 rounded-xl border border-amber-100 mb-2`}>
|
<View key={index} style={tw`flex-row justify-between items-center p-3 bg-amber-50 rounded-xl border border-amber-100 mb-2`}>
|
||||||
<MyText style={tw`text-amber-900 font-medium`}>Buy {deal.quantity} • {productDetail.unitNotation}</MyText>
|
<MyText style={tw`text-amber-900 font-medium`}>Buy {deal.quantity}{productDetail.productType !== 'combo' ? ` • ${productDetail.unitNotation}` : ''}</MyText>
|
||||||
<MyText style={tw`text-amber-900 font-bold text-lg`}>₹{deal.price}</MyText>
|
<MyText style={tw`text-amber-900 font-bold text-lg`}>₹{deal.price}</MyText>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,9 @@ const CompactProductCard = ({
|
||||||
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
|
{item.marketPrice && Number(item.marketPrice) > Number(item.price) && (
|
||||||
<MyText style={tw`text-gray-400 text-xs ml-1 line-through`}>₹{item.marketPrice}</MyText>
|
<MyText style={tw`text-gray-400 text-xs ml-1 line-through`}>₹{item.marketPrice}</MyText>
|
||||||
)}
|
)}
|
||||||
|
{item.productType !== 'combo' && (
|
||||||
<MyText style={tw`text-gray-600 text-xs ml-1`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unit || item.unitNotation}</MyText></MyText>
|
<MyText style={tw`text-gray-600 text-xs ml-1`}>Quantity: <MyText style={tw`text-[#f81260] font-semibold`}>{item.unit || item.unitNotation}</MyText></MyText>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -460,10 +460,7 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
|
||||||
{product?.name}
|
{product?.name}
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyText style={tw`text-xs text-gray-500 mr-2`}>
|
<MyText style={tw`text-xs text-gray-500 mr-2`}>
|
||||||
{(() => {
|
{product?.productType !== 'combo' ? (product?.unitNotation || '') : ''}
|
||||||
const unit = product?.unitNotation || '';
|
|
||||||
return unit;
|
|
||||||
})()}
|
|
||||||
</MyText>
|
</MyText>
|
||||||
|
|
||||||
<View style={tw`flex-row items-center w-32 justify-end`}>
|
<View style={tw`flex-row items-center w-32 justify-end`}>
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,14 @@ const formatTimeRange = (deliveryTime: string | Date) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Product name component with quantity
|
// Product name component with quantity
|
||||||
const ProductNameWithQuantity = ({ name, unitNotation }: { name: string; unitNotation: string }) => {
|
const ProductNameWithQuantity = ({ name, unitNotation, productType }: { name: string; unitNotation: string; productType?: string }) => {
|
||||||
const truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name;
|
const truncatedName = name.length > 25 ? name.substring(0, 25) + '...' : name;
|
||||||
const unit = unitNotation ? ` ${unitNotation}` : '';
|
const unit = unitNotation ? ` ${unitNotation}` : '';
|
||||||
return (
|
return (
|
||||||
<MyText style={tw`text-slate-900 font-extrabold text-sm flex-1`} numberOfLines={1}>
|
<MyText style={tw`text-slate-900 font-extrabold text-sm flex-1`} numberOfLines={1}>
|
||||||
{truncatedName} <MyText style={tw`text-slate-500 font-medium text-xs`}>({unit})</MyText>
|
{truncatedName} {productType !== 'combo' && (
|
||||||
|
<MyText style={tw`text-slate-500 font-medium text-xs`}>({unit})</MyText>
|
||||||
|
)}
|
||||||
</MyText>
|
</MyText>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -272,7 +274,9 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
|
||||||
style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`}
|
style={tw`w-8 h-8 rounded-lg bg-slate-50 border border-slate-100`}
|
||||||
/>
|
/>
|
||||||
<MyText style={tw`text-gray-500 text-[9px] font-medium mt-1`}>
|
<MyText style={tw`text-gray-500 text-[9px] font-medium mt-1`}>
|
||||||
|
{productsById[item.skuId]?.productType !== 'combo' && (
|
||||||
<MyText style={tw`text-[#f81260] font-semibold`}>{productsById[item.skuId]?.unitNotation || ''}</MyText>
|
<MyText style={tw`text-[#f81260] font-semibold`}>{productsById[item.skuId]?.unitNotation || ''}</MyText>
|
||||||
|
)}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -281,6 +285,7 @@ const FloatingCartBar: React.FC<FloatingCartBarProps> = ({
|
||||||
<ProductNameWithQuantity
|
<ProductNameWithQuantity
|
||||||
name={productsById[item.skuId]?.name || ''}
|
name={productsById[item.skuId]?.name || ''}
|
||||||
unitNotation={productsById[item.skuId]?.unitNotation || ''}
|
unitNotation={productsById[item.skuId]?.unitNotation || ''}
|
||||||
|
productType={productsById[item.skuId]?.productType}
|
||||||
/>
|
/>
|
||||||
<MiniQuantifier
|
<MiniQuantifier
|
||||||
value={quantities[item.id] || item.quantity}
|
value={quantities[item.id] || item.quantity}
|
||||||
|
|
|
||||||
29
apps/user-ui/components/icons/OffersIcon.tsx
Normal file
29
apps/user-ui/components/icons/OffersIcon.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
|
interface OffersIconProps {
|
||||||
|
focused: boolean;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OffersIcon: React.FC<OffersIconProps> = ({ focused, size, color }) => {
|
||||||
|
if (focused) {
|
||||||
|
// Selected state SVG (filled offer tag)
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path fillRule="evenodd" clipRule="evenodd" d="M13.41 2.59C13.05 2.22 12.55 2 12 2H4C2.9 2 2 2.9 2 4v8c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42l-9-9zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z" fill={color} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Unselected state SVG (outlined offer tag)
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42z" fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M5.5 5.5m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0-3.6 0" fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OffersIcon;
|
||||||
|
|
@ -152,7 +152,7 @@ export default function AddToCartDialog() {
|
||||||
<MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText>
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Select Delivery Slot</MyText>
|
||||||
{product?.name && (
|
{product?.name && (
|
||||||
<MyText style={tw`text-sm text-gray-500`}>
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
{product.name} ({product.unitNotation ? ` ${product.unitNotation}` : ''})
|
{product.name}{product.productType !== 'combo' && product.unitNotation ? ` (${product.unitNotation})` : ''}
|
||||||
</MyText>
|
</MyText>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
|
||||||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
isOffer: sku.isOffer ?? false,
|
isOffer: sku.isOffer ?? false,
|
||||||
isComboOnly: sku.isComboOnly ?? false,
|
isComboOnly: sku.isComboOnly ?? false,
|
||||||
|
isSuspended: sku.isSuspended ?? false,
|
||||||
}))
|
}))
|
||||||
).returning()
|
).returning()
|
||||||
|
|
||||||
|
|
@ -355,6 +356,31 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
})
|
})
|
||||||
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id))
|
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id))
|
||||||
|
|
||||||
|
const skusBeingSuspended = skus.filter(
|
||||||
|
(sku: any) => sku.isSuspended && sku.id != null && existingSkuIdSet.has(sku.id)
|
||||||
|
)
|
||||||
|
if (skusBeingSuspended.length > 0) {
|
||||||
|
const suspendingIds = skusBeingSuspended.map((sku: any) => sku.id)
|
||||||
|
const comboMemberships = await db.query.productCombos.findMany({
|
||||||
|
where: inArray(productCombos.skuId, suspendingIds),
|
||||||
|
columns: { comboSkuId: true },
|
||||||
|
})
|
||||||
|
const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId)))
|
||||||
|
|
||||||
|
if (comboIds.length > 0) {
|
||||||
|
const combos = await db.query.productSkus.findMany({
|
||||||
|
where: inArray(productSkus.id, comboIds),
|
||||||
|
columns: { id: true, isSuspended: true },
|
||||||
|
})
|
||||||
|
const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id)
|
||||||
|
if (activeComboIds.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const sku of skus) {
|
for (const sku of skus) {
|
||||||
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
|
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
|
||||||
// Update existing SKU
|
// Update existing SKU
|
||||||
|
|
@ -368,6 +394,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
isOffer: sku.isOffer ?? false,
|
isOffer: sku.isOffer ?? false,
|
||||||
isComboOnly: sku.isComboOnly ?? false,
|
isComboOnly: sku.isComboOnly ?? false,
|
||||||
|
isSuspended: sku.isSuspended ?? false,
|
||||||
})
|
})
|
||||||
.where(eq(productSkus.id, sku.id))
|
.where(eq(productSkus.id, sku.id))
|
||||||
|
|
||||||
|
|
@ -401,6 +428,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
|
||||||
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
||||||
isOffer: sku.isOffer ?? false,
|
isOffer: sku.isOffer ?? false,
|
||||||
isComboOnly: sku.isComboOnly ?? false,
|
isComboOnly: sku.isComboOnly ?? false,
|
||||||
|
isSuspended: sku.isSuspended ?? false,
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
await db.insert(skuFeatures).values(
|
await db.insert(skuFeatures).values(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
productTagInfo,
|
productTagInfo,
|
||||||
userIncidents,
|
userIncidents,
|
||||||
} from '../db/schema'
|
} from '../db/schema'
|
||||||
import { eq, and, gt, sql, isNotNull, asc, inArray } from 'drizzle-orm'
|
import { eq, and, gt, sql, isNotNull, asc } from 'drizzle-orm'
|
||||||
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -107,6 +107,7 @@ export interface ProductTagData {
|
||||||
|
|
||||||
export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
|
||||||
const skus = await db.query.productSkus.findMany({
|
const skus = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.isSuspended, false),
|
||||||
with: {
|
with: {
|
||||||
product: true,
|
product: true,
|
||||||
features: true,
|
features: true,
|
||||||
|
|
@ -200,7 +201,16 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return results.map((ci) => {
|
const suspendedSkuIds = new Set(
|
||||||
|
(await db
|
||||||
|
.select({ id: productSkus.id })
|
||||||
|
.from(productSkus)
|
||||||
|
.where(eq(productSkus.isSuspended, true))).map((r) => r.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter((ci) => !suspendedSkuIds.has(ci.comboSkuId))
|
||||||
|
.map((ci) => {
|
||||||
const features = ci.sku?.features || []
|
const features = ci.sku?.features || []
|
||||||
return {
|
return {
|
||||||
comboSkuId: ci.comboSkuId,
|
comboSkuId: ci.comboSkuId,
|
||||||
|
|
@ -307,6 +317,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
|
||||||
let skusData: any[] = []
|
let skusData: any[] = []
|
||||||
if (skuIdsArray.length > 0) {
|
if (skuIdsArray.length > 0) {
|
||||||
skusData = await db.query.productSkus.findMany({
|
skusData = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.isSuspended, false),
|
||||||
with: {
|
with: {
|
||||||
product: {
|
product: {
|
||||||
with: { store: true },
|
with: { store: true },
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return cartItemsWithProducts.map((item) => {
|
return cartItemsWithProducts
|
||||||
|
.filter((item) => item.sku && !item.sku.isSuspended)
|
||||||
|
.map((item) => {
|
||||||
const sku = item.sku
|
const sku = item.sku
|
||||||
const features = sku?.features || []
|
const features = sku?.features || []
|
||||||
const priceValue = sku?.price ?? '0'
|
const priceValue = sku?.price ?? '0'
|
||||||
|
|
@ -48,7 +50,7 @@ export async function getCartItemsWithProducts(userId: number): Promise<UserCart
|
||||||
|
|
||||||
export async function getProductById(skuId: number) {
|
export async function getProductById(skuId: number) {
|
||||||
return db.query.productSkus.findFirst({
|
return db.query.productSkus.findFirst({
|
||||||
where: eq(productSkus.id, skuId),
|
where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const getStringArray = (value: unknown): string[] | null => {
|
||||||
|
|
||||||
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
|
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
|
||||||
const sku = await db.query.productSkus.findFirst({
|
const sku = await db.query.productSkus.findFirst({
|
||||||
where: eq(productSkus.id, skuId),
|
where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)),
|
||||||
with: {
|
with: {
|
||||||
product: true,
|
product: true,
|
||||||
features: true,
|
features: true,
|
||||||
|
|
@ -202,6 +202,7 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
|
||||||
}
|
}
|
||||||
|
|
||||||
const skus = await db.query.productSkus.findMany({
|
const skus = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.isSuspended, false),
|
||||||
with: {
|
with: {
|
||||||
product: true,
|
product: true,
|
||||||
features: true,
|
features: true,
|
||||||
|
|
@ -278,6 +279,7 @@ export interface SkuSummary {
|
||||||
|
|
||||||
export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
||||||
const skus = await db.query.productSkus.findMany({
|
const skus = await db.query.productSkus.findMany({
|
||||||
|
where: eq(productSkus.isSuspended, false),
|
||||||
with: {
|
with: {
|
||||||
features: true,
|
features: true,
|
||||||
product: {
|
product: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue