Compare commits

..

2 commits

Author SHA1 Message Date
shafi54
71a8dece86 enh 2026-02-06 01:57:16 +05:30
shafi54
9b38a3678b enh 2026-02-06 01:32:39 +05:30
13 changed files with 3906 additions and 13 deletions

View file

@ -4,16 +4,19 @@ import { useRouter } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;
relatedStores: number[];
}
export default function AddTag() {
const router = useRouter();
const { mutate: createTag, isPending: isCreating } = useCreateTag();
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
const formData = new FormData();
@ -25,6 +28,9 @@ export default function AddTag() {
}
formData.append('isDashboardTag', values.isDashboardTag.toString());
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
@ -58,6 +64,7 @@ export default function AddTag() {
tagName: '',
tagDescription: '',
isDashboardTag: false,
relatedStores: [],
};
return (
@ -70,6 +77,7 @@ export default function AddTag() {
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isCreating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>

View file

@ -4,11 +4,13 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;
relatedStores: number[];
existingImageUrl?: string;
}
@ -19,6 +21,7 @@ export default function EditTag() {
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
if (!tagIdNum) return;
@ -32,6 +35,9 @@ export default function EditTag() {
}
formData.append('isDashboardTag', values.isDashboardTag.toString());
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
@ -86,6 +92,7 @@ export default function EditTag() {
tagName: tag.tagName,
tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag,
relatedStores: tag.relatedStores || [],
existingImageUrl: tag.imageUrl || undefined,
};
@ -100,6 +107,7 @@ export default function EditTag() {
existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit}
isLoading={isUpdating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>

View file

@ -7,6 +7,7 @@ export interface CreateTagPayload {
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface UpdateTagPayload {
@ -14,6 +15,7 @@ export interface UpdateTagPayload {
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface Tag {
@ -22,6 +24,7 @@ export interface Tag {
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores: number[];
createdAt?: string;
}

View file

@ -3,14 +3,20 @@ import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback } from 'common-ui';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface StoreOption {
id: number;
name: string;
}
interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;
relatedStores: number[];
}
interface TagFormProps {
@ -19,6 +25,7 @@ interface TagFormProps {
existingImageUrl?: string;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
isLoading: boolean;
stores?: StoreOption[];
}
const TagForm = forwardRef<any, TagFormProps>(({
@ -27,6 +34,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
existingImageUrl = '',
onSubmit,
isLoading,
stores = [],
}, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null);
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
@ -120,6 +128,27 @@ const TagForm = forwardRef<any, TagFormProps>(({
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
</View>
{/* Related Stores Dropdown */}
<View style={tw`mb-6`}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>
Related Stores
</MyText>
<BottomDropdown
label="Select Related Stores"
placeholder="Select stores..."
value={values.relatedStores.map(id => id.toString())}
options={stores.map(store => ({
label: store.name,
value: store.id.toString(),
}))}
onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
formikSetFieldValue('relatedStores', numericValues);
}}
multiple={true}
/>
</View>
<TouchableOpacity
onPress={() => handleSubmit()}
disabled={isLoading}

View file

@ -0,0 +1 @@
ALTER TABLE "mf"."product_tag_info" ADD COLUMN "related_stores" jsonb;

File diff suppressed because it is too large Load diff

View file

@ -498,6 +498,13 @@
"when": 1769958949864,
"tag": "0070_known_ares",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1770321591876,
"tag": "0071_moaning_shadow_king",
"breakpoints": true
}
]
}

View file

@ -5,12 +5,13 @@ import { eq } from "drizzle-orm";
import { ApiError } from "../lib/api-error";
import { imageUploadS3, generateSignedUrlFromS3Url } from "../lib/s3-client";
import { deleteS3Image } from "../lib/delete-image";
import { initializeAllStores } from '../stores/store-initializer';
/**
* Create a new product tag
*/
export const createTag = async (req: Request, res: Response) => {
const { tagName, tagDescription, isDashboardTag } = req.body;
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
if (!tagName) {
throw new ApiError("Tag name is required", 400);
@ -33,6 +34,18 @@ export const createTag = async (req: Request, res: Response) => {
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
// Parse relatedStores if it's a string (from FormData)
let parsedRelatedStores: number[] = [];
if (relatedStores) {
try {
parsedRelatedStores = typeof relatedStores === 'string'
? JSON.parse(relatedStores)
: relatedStores;
} catch (e) {
parsedRelatedStores = [];
}
}
const [newTag] = await db
.insert(productTagInfo)
.values({
@ -40,9 +53,13 @@ export const createTag = async (req: Request, res: Response) => {
tagDescription,
imageUrl,
isDashboardTag: isDashboardTag || false,
relatedStores: parsedRelatedStores,
})
.returning();
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(201).json({
tag: newTag,
message: "Tag created successfully",
@ -103,7 +120,7 @@ export const getTagById = async (req: Request, res: Response) => {
*/
export const updateTag = async (req: Request, res: Response) => {
const { id } = req.params;
const { tagName, tagDescription, isDashboardTag } = req.body;
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
// Get the current tag to check for existing image
const currentTag = await db.query.productTagInfo.findFirst({
@ -135,6 +152,18 @@ export const updateTag = async (req: Request, res: Response) => {
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
// Parse relatedStores if it's a string (from FormData)
let parsedRelatedStores: number[] | undefined;
if (relatedStores !== undefined) {
try {
parsedRelatedStores = typeof relatedStores === 'string'
? JSON.parse(relatedStores)
: relatedStores;
} catch (e) {
parsedRelatedStores = [];
}
}
const [updatedTag] = await db
.update(productTagInfo)
.set({
@ -142,10 +171,14 @@ export const updateTag = async (req: Request, res: Response) => {
tagDescription,
imageUrl,
isDashboardTag,
relatedStores: parsedRelatedStores,
})
.where(eq(productTagInfo.id, parseInt(id)))
.returning();
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(200).json({
tag: updatedTag,
message: "Tag updated successfully",
@ -180,6 +213,9 @@ export const deleteTag = async (req: Request, res: Response) => {
// Note: This will fail if tag is still assigned to products due to foreign key constraint
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(200).json({
message: "Tag deleted successfully",
});

View file

@ -173,6 +173,7 @@ export const productTagInfo = mf.table('product_tag_info', {
tagDescription: varchar('tag_description', { length: 500 }),
imageUrl: varchar('image_url', { length: 500 }),
isDashboardTag: boolean('is_dashboard_tag').notNull().default(false),
relatedStores: jsonb('related_stores').$defaultFn(() => []),
createdAt: timestamp('created_at').notNull().defaultNow(),
});

View file

@ -1,16 +1,19 @@
// import redisClient from './redis-client';
import redisClient from 'src/lib/redis-client';
import { db } from '../db/db_index';
import { productTagInfo } from '../db/schema';
import { eq } from 'drizzle-orm';
import { productTagInfo, productTags } from '../db/schema';
import { eq, inArray } from 'drizzle-orm';
import { generateSignedUrlFromS3Url } from 'src/lib/s3-client';
// Tag Type (matches getDashboardTags return)
interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores: number[];
productIds: number[];
}
export async function initializeProductTagStore(): Promise<void> {
@ -22,11 +25,32 @@ export async function initializeProductTagStore(): Promise<void> {
.select({
id: productTagInfo.id,
tagName: productTagInfo.tagName,
tagDescription: productTagInfo.tagDescription,
imageUrl: productTagInfo.imageUrl,
isDashboardTag: productTagInfo.isDashboardTag,
relatedStores: productTagInfo.relatedStores,
})
.from(productTagInfo);
// Fetch product IDs for each tag
const tagIds = tagsData.map(t => t.id);
const productTagsData = await db
.select({
tagId: productTags.tagId,
productId: productTags.productId,
})
.from(productTags)
.where(inArray(productTags.tagId, tagIds));
// Group product IDs by tag
const productIdsByTag = new Map<number, number[]>();
for (const pt of productTagsData) {
if (!productIdsByTag.has(pt.tagId)) {
productIdsByTag.set(pt.tagId, []);
}
productIdsByTag.get(pt.tagId)!.push(pt.productId);
}
// Store each tag in Redis
for (const tag of tagsData) {
const signedImageUrl = tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null;
@ -34,8 +58,11 @@ export async function initializeProductTagStore(): Promise<void> {
const tagObj: Tag = {
id: tag.id,
tagName: tag.tagName,
tagDescription: tag.tagDescription,
imageUrl: signedImageUrl,
isDashboardTag: tag.isDashboardTag,
relatedStores: (tag.relatedStores as number[]) || [],
productIds: productIdsByTag.get(tag.id) || [],
};
await redisClient.set(`tag:${tag.id}`, JSON.stringify(tagObj));
@ -113,3 +140,32 @@ export async function getDashboardTags(): Promise<Tag[]> {
return [];
}
}
export async function getTagsByStoreId(storeId: number): Promise<Tag[]> {
try {
// Get all keys matching the pattern "tag:*"
const keys = await redisClient.KEYS('tag:*');
if (keys.length === 0) {
return [];
}
// Get all tags using MGET for better performance
const tagsData = await redisClient.MGET(keys);
const storeTags: Tag[] = [];
for (const tagData of tagsData) {
if (tagData) {
const tag = JSON.parse(tagData) as Tag;
if (tag.relatedStores.includes(storeId)) {
storeTags.push(tag);
}
}
}
return storeTags;
} catch (error) {
console.error(`Error getting tags for store ${storeId}:`, error);
return [];
}
}

View file

@ -0,0 +1,28 @@
import { router, publicProcedure } from '../trpc-index';
import { z } from 'zod';
import { getTagsByStoreId } from '../../stores/product-tag-store';
import { ApiError } from '../../lib/api-error';
export const tagsRouter = router({
getTagsByStore: publicProcedure
.input(z.object({
storeId: z.number(),
}))
.query(async ({ input }) => {
const { storeId } = input;
// Get tags from cache that are related to this store
const tags = await getTagsByStoreId(storeId);
return {
tags: tags.map(tag => ({
id: tag.id,
tagName: tag.tagName,
tagDescription: tag.tagDescription,
imageUrl: tag.imageUrl,
productIds: tag.productIds,
})),
};
}),
});

View file

@ -12,6 +12,7 @@ import { userCouponRouter } from './coupon';
import { paymentRouter } from './payments';
import { storesRouter } from './stores';
import { fileUploadRouter } from './file-upload';
import { tagsRouter } from './tags';
export const userRouter = router({
address: addressRouter,
@ -27,6 +28,7 @@ export const userRouter = router({
payment: paymentRouter,
stores: storesRouter,
fileUpload: fileUploadRouter,
tags: tagsRouter,
});
export type UserRouter = typeof userRouter;

View file

@ -1,5 +1,5 @@
import React from "react";
import { View, Dimensions } from "react-native";
import React, { useState } from "react";
import { View, Dimensions, ScrollView, TouchableOpacity } from "react-native";
import { useRouter, useLocalSearchParams } from "expo-router";
import {
theme,
@ -19,10 +19,46 @@ import FloatingCartBar from "@/components/floating-cart-bar";
const { width: screenWidth } = Dimensions.get("window");
const itemWidth = (screenWidth - 48) / 2;
interface Tag {
id: number;
tagName: string;
productIds?: number[];
}
interface ChipProps {
tag: Tag;
isSelected: boolean;
onPress: () => void;
}
const Chip: React.FC<ChipProps> = ({ tag, isSelected, onPress }) => {
const productCount = tag.productIds?.length || 0;
return (
<TouchableOpacity
onPress={onPress}
style={tw`px-4 py-2 rounded-lg border ${
isSelected
? 'bg-brand500 border-brand500'
: 'bg-white border-brand500'
}`}
>
<MyText
style={tw`font-medium text-sm ${
isSelected ? 'text-white' : 'text-brand500'
}`}
>
{tag.tagName} ({productCount})
</MyText>
</TouchableOpacity>
);
};
export default function StoreDetail() {
const router = useRouter();
const { id: storeId } = useLocalSearchParams();
const storeIdNum = parseInt(storeId as string);
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const { data: storeData, isLoading, refetch, error } =
trpc.user.stores.getStoreWithProducts.useQuery(
@ -30,6 +66,21 @@ export default function StoreDetail() {
{ enabled: !!storeIdNum }
);
const { data: tagsData, isLoading: isLoadingTags } =
trpc.user.tags.getTagsByStore.useQuery(
{ storeId: storeIdNum },
{ enabled: !!storeIdNum }
);
// Filter products based on selected tag
const filteredProducts = selectedTagId
? storeData?.products.filter(product => {
const selectedTag = tagsData?.tags.find(t => t.id === selectedTagId);
return selectedTag?.productIds?.includes(product.id) ?? false;
}) || []
: storeData?.products || [];
useManualRefresh(() => {
refetch();
});
@ -63,7 +114,7 @@ export default function StoreDetail() {
return (
<View style={tw`flex-1 bg-gray-50 relative`}>
<MyFlatList
data={storeData?.products || []}
data={filteredProducts}
numColumns={2}
renderItem={({ item }) => (
<ProductCard
@ -109,12 +160,45 @@ export default function StoreDetail() {
</MyText>
)}
</View>
<View style={tw`flex-row items-center mt-6 mb-2`}>
{/* Tags Section */}
{tagsData && tagsData.tags.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw`gap-2 mt-6`}
>
{tagsData.tags.map((tag) => (
<Chip
key={tag.id}
tag={tag}
isSelected={selectedTagId === tag.id}
onPress={() => setSelectedTagId(selectedTagId === tag.id ? null : tag.id)}
/>
))}
</ScrollView>
)}
<View style={tw`flex-row items-center justify-between mt-6 mb-2`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="grid-view" size={20} color="#374151" />
<MyText style={tw`text-lg font-bold text-gray-900 ml-2`}>
Products from this Store
{selectedTagId
? `${tagsData?.tags.find(t => t.id === selectedTagId)?.tagName} items`
: `${filteredProducts.length} products`}
</MyText>
</View>
{selectedTagId && (
<TouchableOpacity
onPress={() => setSelectedTagId(null)}
style={tw`flex-row items-center`}
>
<MyText style={tw`text-brand500 text-sm font-medium mr-1`}>
Clear
</MyText>
<MaterialIcons name="close" size={16} color={theme.colors.brand500} />
</TouchableOpacity>
)}
</View>
</View>
}
/>