enh
This commit is contained in:
parent
ca7d8df1c8
commit
128e3b6a58
14 changed files with 326 additions and 505 deletions
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
|
|
@ -15,50 +15,47 @@ interface TagFormData {
|
|||
|
||||
export default function AddTag() {
|
||||
const router = useRouter();
|
||||
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||
const createTag = trpc.admin.product.createProductTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const formData = new FormData();
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
|
||||
try {
|
||||
let imageUrl: string | null | undefined;
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
const blob = await response.blob()
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||
contextString: 'tags',
|
||||
})
|
||||
imageUrl = result.keys[0]
|
||||
uploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
await createTag.mutateAsync({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
Alert.alert('Success', 'Tag created successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
])
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to create tag'
|
||||
Alert.alert('Error', errorMessage)
|
||||
}
|
||||
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';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
createTag(formData, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag created successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error.message || 'Failed to create tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const initialValues: TagFormData = {
|
||||
tagName: '',
|
||||
|
|
@ -76,10 +73,10 @@ export default function AddTag() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
isLoading={createTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
|
|
@ -19,53 +19,56 @@ export default function EditTag() {
|
|||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
|
||||
{ id: tagIdNum || 0 },
|
||||
{ enabled: !!tagIdNum }
|
||||
)
|
||||
const updateTag = trpc.admin.product.updateProductTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
|
||||
if (!tagIdNum) return;
|
||||
|
||||
const formData = new FormData();
|
||||
try {
|
||||
let imageUrl: string | null | undefined
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
const blob = await response.blob()
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||
contextString: 'tags',
|
||||
})
|
||||
imageUrl = result.keys[0]
|
||||
uploadUrls = result.presignedUrls
|
||||
} else if (removedExisting) {
|
||||
imageUrl = null
|
||||
}
|
||||
|
||||
await updateTag.mutateAsync({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
Alert.alert('Success', 'Tag updated successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
])
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to update tag'
|
||||
Alert.alert('Error', errorMessage)
|
||||
}
|
||||
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';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
updateTag({ id: tagIdNum, formData }, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag updated successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error.message || 'Failed to update tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoadingTag) {
|
||||
return (
|
||||
|
|
@ -92,7 +95,7 @@ export default function EditTag() {
|
|||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: tag.relatedStores || [],
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
};
|
||||
|
||||
|
|
@ -106,10 +109,10 @@ export default function EditTag() {
|
|||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
isLoading={updateTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,20 @@ import { useRouter } from 'expo-router';
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||
import { TagMenu } from '@/src/components/TagMenu';
|
||||
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagItemData {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: unknown;
|
||||
createdAt: string | Date;
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
item: Tag;
|
||||
item: TagItemData;
|
||||
onDeleteSuccess: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
|||
|
||||
export default function ProductTags() {
|
||||
const router = useRouter();
|
||||
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const tags = tagsData?.tags || [];
|
||||
|
|
@ -129,4 +139,4 @@ export default function ProductTags() {
|
|||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
|
||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile';
|
||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' | 'tags';
|
||||
|
||||
interface UploadInput {
|
||||
blob: Blob;
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from '../../services/axios-admin-ui';
|
||||
|
||||
// Types
|
||||
export interface CreateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTagResponse {
|
||||
tag: Tag;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GetTagsResponse {
|
||||
tags: Tag[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
|
||||
const response = await axios.post('/av/product-tags', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
|
||||
const response = await axios.put(`/av/product-tags/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
|
||||
const response = await axios.delete(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagsApi = async (): Promise<GetTagsResponse> => {
|
||||
const response = await axios.get('/av/product-tags');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
|
||||
const response = await axios.get(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useCreateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: getTagsApi,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTag = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['tags', id],
|
||||
queryFn: () => getTagApi(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
|
@ -5,7 +5,6 @@ import * as Yup from 'yup';
|
|||
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
|
|
@ -71,7 +70,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
value: store.id,
|
||||
})) || [];
|
||||
|
||||
const { data: tagsData } = useGetTags();
|
||||
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
|
||||
const tagOptions = tagsData?.tags.map(tag => ({
|
||||
label: tag.tagName,
|
||||
value: tag.id.toString(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||
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, BottomDropdown } from 'common-ui';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface StoreOption {
|
||||
|
|
@ -23,7 +21,7 @@ interface TagFormProps {
|
|||
mode: 'create' | 'edit';
|
||||
initialValues: TagFormData;
|
||||
existingImageUrl?: string;
|
||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
|
||||
isLoading: boolean;
|
||||
stores?: StoreOption[];
|
||||
}
|
||||
|
|
@ -36,22 +34,20 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
isLoading,
|
||||
stores = [],
|
||||
}, ref) => {
|
||||
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
|
||||
const [removedExisting, setRemovedExisting] = useState(false)
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
existingImageUrl && setImage({uri:existingImageUrl})
|
||||
}, [initialValues.isDashboardTag]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => {
|
||||
|
||||
setImage(files || null)
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
if (existingImageUrl) {
|
||||
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
|
||||
} else {
|
||||
setImages([])
|
||||
}
|
||||
setRemovedExisting(false)
|
||||
}, [existingImageUrl, initialValues.isDashboardTag]);
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
|
|
@ -67,17 +63,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||
onSubmit={(values) => onSubmit(values, images, removedExisting)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImage(null);
|
||||
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([])
|
||||
setRemovedExisting(false)
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
||||
useFocusCallback(clearForm);
|
||||
|
||||
|
|
@ -108,10 +104,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
</MyText>
|
||||
|
||||
|
||||
<ImageUploader
|
||||
images={image ? [image] : []}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={() => setImage(null)}
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
|
||||
setImages((prev) => [...prev, ...payload.map((img) => ({
|
||||
imgUrl: img.url,
|
||||
mimeType: img.mimeType,
|
||||
}))])
|
||||
}}
|
||||
onImageRemove={(payload) => {
|
||||
if (payload.mimeType === null) {
|
||||
setRemovedExisting(true)
|
||||
}
|
||||
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
|
||||
}}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
@ -167,4 +174,4 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
|
||||
TagForm.displayName = 'TagForm';
|
||||
|
||||
export default TagForm;
|
||||
export default TagForm;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
|
|||
import { Entypo } from '@expo/vector-icons';
|
||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useDeleteTag } from '../api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export interface TagMenuProps {
|
||||
tagId: number;
|
||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
||||
const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true);
|
||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
};
|
||||
|
||||
const performDelete = () => {
|
||||
deleteTag(tagId, {
|
||||
deleteTag.mutate({ id: tagId }, {
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Tag deleted successfully');
|
||||
onDeleteSuccess?.();
|
||||
|
|
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
const errorMessage = error.message || 'Failed to delete tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
const options = [
|
||||
|
|
@ -116,4 +116,4 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
</BottomDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
||||
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -11,9 +10,6 @@ router.use(authenticateStaff);
|
|||
// Product routes
|
||||
router.use("/products", productRouter);
|
||||
|
||||
// Tag routes
|
||||
router.use("/product-tags", tagRouter);
|
||||
|
||||
const avRouter = router;
|
||||
|
||||
export default avRouter;
|
||||
export default avRouter;
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import {
|
||||
checkProductTagExistsByName,
|
||||
createProductTag,
|
||||
deleteProductTag,
|
||||
getAllProductTagInfos,
|
||||
getProductTagInfoById,
|
||||
updateProductTag,
|
||||
} from '@/src/dbService'
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
||||
|
||||
|
||||
/**
|
||||
* Create a new product tag
|
||||
*/
|
||||
export const createTag = async (req: Request, res: Response) => {
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
if (!tagName) {
|
||||
throw new ApiError("Tag name is required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate tag name
|
||||
const existingTag = await checkProductTagExistsByName(tagName.trim())
|
||||
|
||||
if (existingTag) {
|
||||
throw new ApiError("A tag with this name already exists", 400);
|
||||
}
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
// Handle image upload if file is provided
|
||||
if (req.file) {
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
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 createdTag = await createProductTag({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag: isDashboardTag || false,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
|
||||
const { products, ...newTag } = createdTag
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
res.status(201).json({
|
||||
tag: newTag,
|
||||
message: "Tag created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all product tags
|
||||
*/
|
||||
export const getAllTags = async (req: Request, res: Response) => {
|
||||
const tags = await getAllProductTagInfos()
|
||||
|
||||
// Generate signed URLs for tag images
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
}))
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
tags: tagsWithSignedUrls,
|
||||
message: "Tags retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single product tag by ID
|
||||
*/
|
||||
export const getTagById = async (req: Request, res: Response) => {
|
||||
const id = req.params.id as string
|
||||
|
||||
const tag = await getProductTagInfoById(parseInt(id))
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate signed URL for tag image
|
||||
const tagWithSignedUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
tag: tagWithSignedUrl,
|
||||
message: "Tag retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product tag
|
||||
*/
|
||||
export const updateTag = async (req: Request, res: Response) => {
|
||||
const id = req.params.id as string
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
// Get the current tag to check for existing image
|
||||
const currentTag = await getProductTagInfoById(parseInt(id))
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
let imageUrl = currentTag.imageUrl;
|
||||
|
||||
// Handle image upload if new file is provided
|
||||
if (req.file) {
|
||||
// Delete old image if it exists
|
||||
if (currentTag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete old image:", error);
|
||||
// Continue with update even if delete fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Upload new image
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
console.log('file', key)
|
||||
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 updateProductTag(parseInt(id), {
|
||||
tagName: tagName?.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
|
||||
const { products, ...updatedTagInfo } = updatedTag
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
res.status(200).json({
|
||||
tag: updatedTagInfo,
|
||||
message: "Tag updated successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a product tag
|
||||
*/
|
||||
export const deleteTag = async (req: Request, res: Response) => {
|
||||
const id = req.params.id as string
|
||||
|
||||
// Check if tag exists
|
||||
const tag = await getProductTagInfoById(parseInt(id))
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Delete image from S3 if it exists
|
||||
if (tag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(tag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete image from S3:", error);
|
||||
// Continue with deletion even if image delete fails
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This will fail if tag is still assigned to products due to foreign key constraint
|
||||
await deleteProductTag(parseInt(id))
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
res.status(200).json({
|
||||
message: "Tag deleted successfully",
|
||||
});
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
||||
import uploadHandler from '@/src/lib/upload-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Tag routes
|
||||
router.post("/", uploadHandler.single('image'), createTag);
|
||||
router.get("/", getAllTags);
|
||||
router.get("/:id", getTagById);
|
||||
router.put("/:id", uploadHandler.single('image'), updateTag);
|
||||
router.delete("/:id", deleteTag);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { generateSignedUrlsFromS3Urls, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
|
||||
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import {
|
||||
getAllProducts as getAllProductsInDb,
|
||||
|
|
@ -26,6 +26,12 @@ import {
|
|||
getProductImagesById,
|
||||
updateProduct as updateProductInDb,
|
||||
updateProductDeals,
|
||||
checkProductTagExistsByName,
|
||||
createProductTag as createProductTagInDb,
|
||||
updateProductTag as updateProductTagInDb,
|
||||
deleteProductTag as deleteProductTagInDb,
|
||||
getAllProductTagInfos as getAllProductTagInfosInDb,
|
||||
getProductTagInfoById as getProductTagInfoByIdInDb,
|
||||
} from '@/src/dbService'
|
||||
import type {
|
||||
AdminProduct,
|
||||
|
|
@ -818,18 +824,18 @@ export const productRouter = router({
|
|||
}
|
||||
}),
|
||||
|
||||
updateProductPrices: protectedProcedure
|
||||
.input(z.object({
|
||||
updates: z.array(z.object({
|
||||
productId: z.number(),
|
||||
price: z.number().optional(),
|
||||
marketPrice: z.number().nullable().optional(),
|
||||
flashPrice: z.number().nullable().optional(),
|
||||
isFlashAvailable: z.boolean().optional(),
|
||||
})),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<AdminUpdateProductPricesResult> => {
|
||||
const { updates } = input;
|
||||
updateProductPrices: protectedProcedure
|
||||
.input(z.object({
|
||||
updates: z.array(z.object({
|
||||
productId: z.number(),
|
||||
price: z.number().optional(),
|
||||
marketPrice: z.number().nullable().optional(),
|
||||
flashPrice: z.number().nullable().optional(),
|
||||
isFlashAvailable: z.boolean().optional(),
|
||||
})),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<AdminUpdateProductPricesResult> => {
|
||||
const { updates } = input;
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new ApiError('No updates provided', 400)
|
||||
|
|
@ -889,9 +895,158 @@ export const productRouter = router({
|
|||
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: `Updated prices for ${result.updatedCount} product(s)`,
|
||||
updatedCount: result.updatedCount,
|
||||
}
|
||||
return {
|
||||
message: `Updated prices for ${result.updatedCount} product(s)`,
|
||||
updatedCount: result.updatedCount,
|
||||
}
|
||||
}),
|
||||
|
||||
getProductTags: protectedProcedure
|
||||
.query(async (): Promise<{ tags: Array<{ id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }>; message: string }> => {
|
||||
const tags = await getAllProductTagInfosInDb()
|
||||
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
tags: tagsWithSignedUrls,
|
||||
message: 'Tags retrieved successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
getProductTagById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
|
||||
const tag = await getProductTagInfoByIdInDb(input.id)
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError('Tag not found', 404)
|
||||
}
|
||||
|
||||
const tagWithSignedUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
}
|
||||
|
||||
return {
|
||||
tag: tagWithSignedUrl,
|
||||
message: 'Tag retrieved successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
createProductTag: protectedProcedure
|
||||
.input(z.object({
|
||||
tagName: z.string().min(1, 'Tag name is required'),
|
||||
tagDescription: z.string().optional(),
|
||||
imageUrl: z.string().optional().nullable(),
|
||||
isDashboardTag: z.boolean().optional().default(false),
|
||||
relatedStores: z.array(z.number()).optional().default([]),
|
||||
uploadUrls: z.array(z.string()).optional().default([]),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
|
||||
const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
||||
|
||||
const existingTag = await checkProductTagExistsByName(tagName.trim())
|
||||
if (existingTag) {
|
||||
throw new ApiError('A tag with this name already exists', 400)
|
||||
}
|
||||
|
||||
const createdTag = await createProductTagInDb({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl: imageUrl ?? null,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
})
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
|
||||
}
|
||||
|
||||
scheduleStoreInitialization()
|
||||
|
||||
const { products, ...createdTagInfo } = createdTag
|
||||
|
||||
return {
|
||||
tag: {
|
||||
...createdTagInfo,
|
||||
imageUrl: createdTagInfo.imageUrl ? await generateSignedUrlFromS3Url(createdTagInfo.imageUrl) : null,
|
||||
},
|
||||
message: 'Tag created successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
updateProductTag: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
tagName: z.string().min(1).optional(),
|
||||
tagDescription: z.string().optional().nullable(),
|
||||
imageUrl: z.string().optional().nullable(),
|
||||
isDashboardTag: z.boolean().optional(),
|
||||
relatedStores: z.array(z.number()).optional(),
|
||||
uploadUrls: z.array(z.string()).optional().default([]),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => {
|
||||
const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input
|
||||
|
||||
const currentTag = await getProductTagInfoByIdInDb(id)
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError('Tag not found', 404)
|
||||
}
|
||||
|
||||
if (imageUrl !== undefined && imageUrl !== currentTag.imageUrl) {
|
||||
if (currentTag.imageUrl) {
|
||||
await deleteImageUtil({ keys: [currentTag.imageUrl] })
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTag = await updateProductTagInDb(id, {
|
||||
tagName: tagName?.trim(),
|
||||
tagDescription: tagDescription ?? undefined,
|
||||
imageUrl: imageUrl ?? undefined,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
})
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
await Promise.all(uploadUrls.map((url) => claimUploadUrl(url)))
|
||||
}
|
||||
|
||||
scheduleStoreInitialization()
|
||||
|
||||
const { products, ...updatedTagInfo } = updatedTag
|
||||
|
||||
return {
|
||||
tag: {
|
||||
...updatedTagInfo,
|
||||
imageUrl: updatedTagInfo.imageUrl ? await generateSignedUrlFromS3Url(updatedTagInfo.imageUrl) : null,
|
||||
},
|
||||
message: 'Tag updated successfully',
|
||||
}
|
||||
}),
|
||||
|
||||
deleteProductTag: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }): Promise<{ message: string }> => {
|
||||
const tag = await getProductTagInfoByIdInDb(input.id)
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError('Tag not found', 404)
|
||||
}
|
||||
|
||||
if (tag.imageUrl) {
|
||||
await deleteImageUtil({ keys: [tag.imageUrl] })
|
||||
}
|
||||
|
||||
await deleteProductTagInDb(input.id)
|
||||
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return { message: 'Tag deleted successfully' }
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const commonApiRouter = router({
|
|||
|
||||
generateUploadUrls: protectedProcedure
|
||||
.input(z.object({
|
||||
contextString: z.enum(['review', 'review_response', 'product_info', 'notification', 'store', 'complaint', 'profile']),
|
||||
contextString: z.enum(['review', 'review_response', 'product_info', 'notification', 'store', 'complaint', 'profile', 'tags']),
|
||||
mimeTypes: z.array(z.string()),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
||||
|
|
@ -106,6 +106,8 @@ export const commonApiRouter = router({
|
|||
folder = 'complaint-images';
|
||||
} else if (contextString === 'profile') {
|
||||
folder = 'profile-images';
|
||||
} else if (contextString === 'tags') {
|
||||
folder = 'tags';
|
||||
} else {
|
||||
folder = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ApiError } from '@/src/lib/api-error';
|
|||
export const fileUploadRouter = router({
|
||||
generateUploadUrls: protectedProcedure
|
||||
.input(z.object({
|
||||
contextString: z.enum(['review', 'product_info', 'notification', 'complaint', 'profile']),
|
||||
contextString: z.enum(['review', 'product_info', 'notification', 'complaint', 'profile', 'tags']),
|
||||
mimeTypes: z.array(z.string()),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
||||
|
|
@ -32,6 +32,8 @@ export const fileUploadRouter = router({
|
|||
folder = 'complaint-images'
|
||||
} else if (contextString === 'profile') {
|
||||
folder = 'profile-images'
|
||||
} else if (contextString === 'tags') {
|
||||
folder = 'tags'
|
||||
} else {
|
||||
folder = '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue