enh
This commit is contained in:
parent
8f4cddee1a
commit
1122159552
29 changed files with 857 additions and 597 deletions
|
|
@ -3,7 +3,6 @@ import { View, Alert } from 'react-native';
|
|||
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 {
|
||||
|
|
@ -15,36 +14,17 @@ interface TagFormData {
|
|||
|
||||
export default function AddTag() {
|
||||
const router = useRouter();
|
||||
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||
const createTag = trpc.admin.tag.createTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
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, {
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
createTag.mutate({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag created successfully', [
|
||||
{
|
||||
|
|
@ -76,10 +56,10 @@ export default function AddTag() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
isLoading={createTag.isPending}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { View, Alert } from 'react-native';
|
|||
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 {
|
||||
|
|
@ -11,7 +10,6 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
existingImageUrl?: string;
|
||||
}
|
||||
|
||||
export default function EditTag() {
|
||||
|
|
@ -19,39 +17,25 @@ 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.tag.getTagById.useQuery(
|
||||
{ id: tagIdNum! },
|
||||
{ enabled: !!tagIdNum }
|
||||
);
|
||||
const updateTag = trpc.admin.tag.updateTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
if (!tagIdNum) return;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
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 }, {
|
||||
updateTag.mutate({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
deleteExistingImage: deleteExistingImage,
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag updated successfully', [
|
||||
{
|
||||
|
|
@ -92,8 +76,7 @@ export default function EditTag() {
|
|||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: tag.relatedStores || [],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -106,10 +89,10 @@ export default function EditTag() {
|
|||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
isLoading={updateTag.isPending}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@ 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 Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: any;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
item: Tag;
|
||||
|
|
@ -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.tag.getTags.useQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const tags = tagsData?.tags || [];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
|
|||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
|
||||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
|
|
@ -16,6 +16,12 @@ export interface StoreFormData {
|
|||
products: number[];
|
||||
}
|
||||
|
||||
interface StoreImage {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
isExisting: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFormRef {
|
||||
// Add methods if needed
|
||||
}
|
||||
|
|
@ -28,6 +34,11 @@ interface StoreFormProps {
|
|||
storeId?: number;
|
||||
}
|
||||
|
||||
// Extend Formik values with images array
|
||||
interface FormikStoreValues extends StoreFormData {
|
||||
images: StoreImage[];
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required'),
|
||||
description: Yup.string(),
|
||||
|
|
@ -41,9 +52,23 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
// Build initial form values with images array
|
||||
const buildInitialValues = (): FormikStoreValues => {
|
||||
const images: StoreImage[] = [];
|
||||
if (initialValues.imageUrl) {
|
||||
images.push({
|
||||
uri: initialValues.imageUrl,
|
||||
mimeType: 'image/jpeg',
|
||||
isExisting: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...initialValues,
|
||||
images,
|
||||
};
|
||||
};
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
|
||||
|
||||
// For edit mode, pre-select products belonging to this store
|
||||
const initialSelectedProducts = useMemo(() => {
|
||||
|
|
@ -55,7 +80,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
|
||||
useEffect(() => {
|
||||
setFormInitialValues({
|
||||
...initialValues,
|
||||
...buildInitialValues(),
|
||||
products: initialSelectedProducts,
|
||||
});
|
||||
}, [initialValues, initialSelectedProducts]);
|
||||
|
|
@ -65,42 +90,8 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
value: staff.id,
|
||||
})) || [];
|
||||
|
||||
|
||||
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setSelectedImages([]);
|
||||
setDisplayImages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const blobPromises = files.map(async (asset) => {
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
||||
});
|
||||
|
||||
const blobArray = await Promise.all(blobPromises);
|
||||
setSelectedImages(blobArray);
|
||||
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
||||
},
|
||||
multiple: false, // Single image for stores
|
||||
});
|
||||
|
||||
const handleRemoveImage = (uri: string) => {
|
||||
const index = displayImages.findIndex(img => img.uri === uri);
|
||||
if (index !== -1) {
|
||||
const newDisplay = displayImages.filter((_, i) => i !== index);
|
||||
const newFiles = selectedImages.filter((_, i) => i !== index);
|
||||
setDisplayImages(newDisplay);
|
||||
setSelectedImages(newFiles);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
|
|
@ -109,24 +100,78 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||
// Image picker that adds to Formik field
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const newImages: StoreImage[] = files.map((asset) => ({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
isExisting: false,
|
||||
}));
|
||||
|
||||
// Add to Formik images field
|
||||
const currentImages = values.images || [];
|
||||
setFieldValue('images', [...currentImages, ...newImages]);
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Remove image - works for both existing and new
|
||||
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
|
||||
const currentImages = values.images || [];
|
||||
const removedImage = currentImages.find(img => img.uri === image.uri);
|
||||
const newImages = currentImages.filter(img => img.uri !== image.uri);
|
||||
|
||||
setFieldValue('images', newImages);
|
||||
|
||||
// If we removed an existing image, also clear the imageUrl
|
||||
if (removedImage?.isExisting) {
|
||||
setFieldValue('imageUrl', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
const { blob, mimeType } = selectedImages[0];
|
||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||
imageUrl = presignedUrl;
|
||||
// Get new images that need to be uploaded
|
||||
const newImages = values.images.filter(img => !img.isExisting);
|
||||
|
||||
if (newImages.length > 0) {
|
||||
// Upload the first new image (single image for stores)
|
||||
const image = newImages[0];
|
||||
const response = await fetch(image.uri);
|
||||
const imageBlob = await response.blob();
|
||||
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
|
||||
imageUrl = key;
|
||||
} else {
|
||||
// Check if there's an existing image remaining
|
||||
const existingImage = values.images.find(img => img.isExisting);
|
||||
if (existingImage) {
|
||||
imageUrl = existingImage.uri;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit form with imageUrl
|
||||
onSubmit({ ...values, imageUrl });
|
||||
// Submit form with imageUrl (without images array)
|
||||
const { images, ...submitValues } = values;
|
||||
onSubmit({ ...submitValues, imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare images for ImageUploaderNeo (convert to expected format)
|
||||
const imagesForUploader = (values.images || []).map(img => ({
|
||||
uri: img.uri,
|
||||
mimeType: img.mimeType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View>
|
||||
<MyTextInput
|
||||
|
|
@ -167,12 +212,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
/>
|
||||
<View style={tw`mb-6`}>
|
||||
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
||||
onAddImage={handleImagePick}
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={imagesForUploader}
|
||||
onUploadImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -7,7 +7,6 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
|
|||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface ProductFormData {
|
||||
|
|
@ -71,8 +70,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
value: store.id,
|
||||
})) || [];
|
||||
|
||||
const { data: tagsData } = useGetTags();
|
||||
const tagOptions = tagsData?.tags.map(tag => ({
|
||||
const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
|
||||
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
|
||||
label: tag.tagName,
|
||||
value: tag.id.toString(),
|
||||
})) || [];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { View, TouchableOpacity, Alert } 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 MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface StoreOption {
|
||||
id: number;
|
||||
|
|
@ -23,7 +24,7 @@ interface TagFormProps {
|
|||
mode: 'create' | 'edit';
|
||||
initialValues: TagFormData;
|
||||
existingImageUrl?: string;
|
||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void;
|
||||
isLoading: boolean;
|
||||
stores?: StoreOption[];
|
||||
}
|
||||
|
|
@ -36,23 +37,34 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
isLoading,
|
||||
stores = [],
|
||||
}, ref) => {
|
||||
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
||||
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
existingImageUrl && setImage({uri:existingImageUrl})
|
||||
}, [initialValues.isDashboardTag]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => {
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setNewImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = Array.isArray(assets) ? assets[0] : assets;
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
setImage(files || null)
|
||||
setNewImage({
|
||||
blob,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
uri: asset.uri
|
||||
});
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
tagName: Yup.string()
|
||||
|
|
@ -63,18 +75,44 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
.max(500, 'Description must be less than 500 characters'),
|
||||
});
|
||||
|
||||
// Display images for ImageUploader
|
||||
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
|
||||
const existingImages = existingImageUrl ? [existingImageUrl] : [];
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
let deleteExistingImage = false;
|
||||
|
||||
// Handle image upload
|
||||
if (newImage) {
|
||||
const result = await uploadSingle(newImage.blob, newImage.mimeType, 'product_info');
|
||||
imageKey = result.key;
|
||||
// If we're uploading a new image and there's an existing one, mark it for deletion
|
||||
if (existingImageUrl) {
|
||||
deleteExistingImage = true;
|
||||
}
|
||||
} else if (mode === 'edit' && !newImage && existingImageUrl) {
|
||||
// In edit mode, if no new image and existing was removed
|
||||
// This would need UI to explicitly remove image
|
||||
// For now, we don't support explicit deletion without replacement
|
||||
}
|
||||
|
||||
onSubmit(values, imageKey, deleteExistingImage);
|
||||
} catch (error) {
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
}
|
||||
}}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImage(null);
|
||||
|
||||
setNewImage(null);
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -107,11 +145,15 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||
</MyText>
|
||||
|
||||
|
||||
<ImageUploader
|
||||
images={image ? [image] : []}
|
||||
images={displayImages}
|
||||
existingImageUrls={mode === 'edit' ? existingImages : []}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={() => setImage(null)}
|
||||
onRemoveImage={() => setNewImage(null)}
|
||||
onRemoveExistingImage={mode === 'edit' ? () => {
|
||||
// In edit mode, this would trigger deletion of existing image
|
||||
// But we need to implement this logic in the parent
|
||||
} : undefined}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
@ -122,7 +164,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
onPress={() => {
|
||||
const newValue = !isDashboardTagChecked;
|
||||
setIsDashboardTagChecked(newValue);
|
||||
formikSetFieldValue('isDashboardTag', newValue);
|
||||
setFieldValue('isDashboardTag', newValue);
|
||||
}}
|
||||
/>
|
||||
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
||||
|
|
@ -143,7 +185,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
||||
formikSetFieldValue('relatedStores', numericValues);
|
||||
setFieldValue('relatedStores', numericValues);
|
||||
}}
|
||||
multiple={true}
|
||||
/>
|
||||
|
|
@ -151,11 +193,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -167,4 +209,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 '../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.tag.deleteTag.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?.();
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ S3_REGION=apac
|
|||
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
||||
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
||||
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||
S3_BUCKET_NAME=meatfarmer
|
||||
S3_BUCKET_NAME=meatfarmer-dev
|
||||
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||
ASSETS_DOMAIN=https://assets.freshyo.in/
|
||||
ASSETS_DOMAIN=https://assets2.freshyo.in/
|
||||
API_CACHE_KEY=api-cache-dev
|
||||
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
|
||||
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -75,6 +75,7 @@ app.use('/api/trpc', createExpressMiddleware({
|
|||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
console.log({authHeader})
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply staff authentication to all admin routes
|
||||
router.use(authenticateStaff);
|
||||
|
||||
// Tag routes
|
||||
router.use("/product-tags", tagRouter);
|
||||
|
||||
const avRouter = router;
|
||||
|
||||
export default avRouter;
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index";
|
||||
import { productTagInfo } from "@/src/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, scaffoldAssetUrl } 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 db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.tagName, 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 [newTag] = await db
|
||||
.insert(productTagInfo)
|
||||
.values({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag: isDashboardTag || false,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 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 db
|
||||
.select()
|
||||
.from(productTagInfo)
|
||||
.orderBy(productTagInfo.tagName);
|
||||
|
||||
// Generate signed URLs for tag images
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(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 db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate signed URL for tag image
|
||||
const tagWithSignedUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(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 db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, 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 db
|
||||
.update(productTagInfo)
|
||||
.set({
|
||||
tagName: tagName?.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.where(eq(productTagInfo.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
res.status(200).json({
|
||||
tag: updatedTag,
|
||||
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 db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, 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 db.delete(productTagInfo).where(eq(productTagInfo.id, 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;
|
||||
|
|
@ -34,12 +34,6 @@ router.use('/v1', v1Router);
|
|||
// router.use('/av', avRouter);
|
||||
router.use('/test', testController);
|
||||
|
||||
// User REST APIs
|
||||
router.post('/uv/complaints/raise',
|
||||
uploadHandler.array('images', 5),
|
||||
raiseComplaint
|
||||
);
|
||||
|
||||
// Global error handling middleware
|
||||
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
|
@ -62,4 +56,4 @@ router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
const mainRouter = router;
|
||||
|
||||
export default mainRouter;
|
||||
export default mainRouter;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
|||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
|
||||
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
|
||||
|
||||
export const adminRouter = router({
|
||||
complaint: complaintRouter,
|
||||
|
|
@ -32,6 +33,7 @@ export const adminRouter = router({
|
|||
user: userRouter,
|
||||
const: constRouter,
|
||||
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
|
||||
tag: tagRouter,
|
||||
});
|
||||
|
||||
export type AdminRouter = typeof adminRouter;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ export const storeRouter = router({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const { name, description, imageUrl, owner, products } = input;
|
||||
|
||||
const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
|
||||
// const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
|
||||
const imageKey = imageUrl
|
||||
|
||||
const [newStore] = await db
|
||||
.insert(storeInfo)
|
||||
|
|
|
|||
214
apps/backend/src/trpc/apis/admin-apis/apis/tag.ts
Normal file
214
apps/backend/src/trpc/apis/admin-apis/apis/tag.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productTagInfo } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
||||
import { deleteS3Image } from '@/src/lib/delete-image'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
|
||||
export const tagRouter = router({
|
||||
getTags: protectedProcedure
|
||||
.query(async () => {
|
||||
const tags = await db
|
||||
.select()
|
||||
.from(productTagInfo)
|
||||
.orderBy(productTagInfo.tagName);
|
||||
|
||||
// Generate asset URLs for tag images
|
||||
const tagsWithUrls = tags.map(tag => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
tags: tagsWithUrls,
|
||||
message: "Tags retrieved successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
getTagById: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, input.id),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate asset URL for tag image
|
||||
const tagWithUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
};
|
||||
|
||||
return {
|
||||
tag: tagWithUrl,
|
||||
message: "Tag retrieved successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
createTag: protectedProcedure
|
||||
.input(z.object({
|
||||
tagName: z.string().min(1),
|
||||
tagDescription: z.string().optional(),
|
||||
isDashboardTag: z.boolean().default(false),
|
||||
relatedStores: z.array(z.number()).default([]),
|
||||
imageKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
|
||||
|
||||
// Check for duplicate tag name
|
||||
const existingTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.tagName, tagName.trim()),
|
||||
});
|
||||
|
||||
if (existingTag) {
|
||||
throw new ApiError("A tag with this name already exists", 400);
|
||||
}
|
||||
|
||||
const [newTag] = await db
|
||||
.insert(productTagInfo)
|
||||
.values({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl: imageKey || null,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Claim upload URL if image was provided
|
||||
if (imageKey) {
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
tag: newTag,
|
||||
message: "Tag created successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
updateTag: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
tagName: z.string().min(1),
|
||||
tagDescription: z.string().optional(),
|
||||
isDashboardTag: z.boolean(),
|
||||
relatedStores: z.array(z.number()),
|
||||
imageKey: z.string().optional(),
|
||||
deleteExistingImage: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, imageKey, deleteExistingImage, ...updateData } = input;
|
||||
|
||||
// Get current tag
|
||||
const currentTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, id),
|
||||
});
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
let newImageUrl = currentTag.imageUrl;
|
||||
|
||||
// Handle image deletion
|
||||
if (deleteExistingImage && currentTag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
|
||||
}
|
||||
newImageUrl = null;
|
||||
}
|
||||
|
||||
// Handle new image upload (only if different from existing)
|
||||
if (imageKey && imageKey !== currentTag.imageUrl) {
|
||||
// Delete old image if exists and not already deleted
|
||||
if (currentTag.imageUrl && !deleteExistingImage) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
|
||||
}
|
||||
}
|
||||
newImageUrl = imageKey;
|
||||
|
||||
// Claim upload URL
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedTag] = await db
|
||||
.update(productTagInfo)
|
||||
.set({
|
||||
tagName: updateData.tagName.trim(),
|
||||
tagDescription: updateData.tagDescription,
|
||||
isDashboardTag: updateData.isDashboardTag,
|
||||
relatedStores: updateData.relatedStores,
|
||||
imageUrl: newImageUrl,
|
||||
})
|
||||
.where(eq(productTagInfo.id, id))
|
||||
.returning();
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
tag: updatedTag,
|
||||
message: "Tag updated successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
deleteTag: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Get tag to check for image
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, id),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Delete image from S3 if exists
|
||||
if (tag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(tag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete tag (will fail if tag is assigned to products due to FK constraint)
|
||||
await db.delete(productTagInfo).where(eq(productTagInfo.id, id));
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
message: "Tag deleted successfully",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export type TagRouter = typeof tagRouter;
|
||||
|
|
@ -69,7 +69,7 @@ export const commonApiRouter = router({
|
|||
|
||||
generateUploadUrls: protectedProcedure
|
||||
.input(z.object({
|
||||
contextString: z.enum(['review', 'product_info', 'store', 'notification']),
|
||||
contextString: z.enum(['review', 'product_info', 'store', 'notification', 'profile']),
|
||||
mimeTypes: z.array(z.string()),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
||||
|
|
@ -87,6 +87,8 @@ export const commonApiRouter = router({
|
|||
folder = 'product-images';
|
||||
} else if (contextString === 'store') {
|
||||
folder = 'store-images';
|
||||
} else if (contextString === 'profile') {
|
||||
folder = 'profile-images';
|
||||
}
|
||||
// else if (contextString === 'review_response') {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
orderItems, orderStatus, orders, payments, refunds,
|
||||
productReviews, reservedCoupons
|
||||
} from '@/src/db/schema';
|
||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';
|
||||
import { generateSignedUrlFromS3Url, claimUploadUrl, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||
import { deleteS3Image } from '@/src/lib/delete-image';
|
||||
import { ApiError } from '@/src/lib/api-error';
|
||||
import catchAsync from '@/src/lib/catch-async';
|
||||
import { jwtSecret } from '@/src/lib/env-exporter';
|
||||
|
|
@ -150,9 +151,10 @@ export const authRouter = router({
|
|||
email: z.string().email('Invalid email format'),
|
||||
mobile: z.string().min(1, 'Mobile is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
imageKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { name, email, mobile, password }: RegisterRequest = input;
|
||||
const { name, email, mobile, password, imageKey } = input;
|
||||
|
||||
if (!name || !email || !mobile || !password) {
|
||||
throw new ApiError('All fields are required', 400);
|
||||
|
|
@ -215,11 +217,39 @@ export const authRouter = router({
|
|||
userPassword: hashedPassword,
|
||||
});
|
||||
|
||||
// Create user details with profile image if provided
|
||||
if (imageKey) {
|
||||
await tx.insert(userDetails).values({
|
||||
userId: user.id,
|
||||
profileImage: imageKey,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
// Claim upload URL if image was provided
|
||||
if (imageKey) {
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateToken(newUser.id);
|
||||
|
||||
// Get user details for profile image
|
||||
const [userDetail] = await db
|
||||
.select()
|
||||
.from(userDetails)
|
||||
.where(eq(userDetails.userId, newUser.id))
|
||||
.limit(1);
|
||||
|
||||
const profileImageUrl = userDetail?.profileImage
|
||||
? scaffoldAssetUrl(userDetail.profileImage)
|
||||
: null;
|
||||
|
||||
const response: AuthResponse = {
|
||||
token,
|
||||
user: {
|
||||
|
|
@ -228,7 +258,7 @@ export const authRouter = router({
|
|||
email: newUser.email,
|
||||
mobile: newUser.mobile,
|
||||
createdAt: newUser.createdAt.toISOString(),
|
||||
profileImage: null,
|
||||
profileImage: profileImageUrl,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -351,6 +381,17 @@ export const authRouter = router({
|
|||
throw new ApiError('User not found', 404);
|
||||
}
|
||||
|
||||
// Get user details for profile image
|
||||
const [userDetail] = await db
|
||||
.select()
|
||||
.from(userDetails)
|
||||
.where(eq(userDetails.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const profileImageUrl = userDetail?.profileImage
|
||||
? scaffoldAssetUrl(userDetail.profileImage)
|
||||
: null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
|
@ -358,10 +399,109 @@ export const authRouter = router({
|
|||
name: user.name,
|
||||
email: user.email,
|
||||
mobile: user.mobile,
|
||||
profileImage: profileImageUrl,
|
||||
bio: userDetail?.bio || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth || null,
|
||||
gender: userDetail?.gender || null,
|
||||
occupation: userDetail?.occupation || null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
updateProfile: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1, 'Name is required').optional(),
|
||||
email: z.string().email('Invalid email format').optional(),
|
||||
bio: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.string().optional(),
|
||||
occupation: z.string().optional(),
|
||||
imageKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.user.userId;
|
||||
const { imageKey, ...updateData } = input;
|
||||
|
||||
if (!userId) {
|
||||
throw new ApiError('User not authenticated', 401);
|
||||
}
|
||||
|
||||
// Get current user details
|
||||
const currentDetail = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userId),
|
||||
});
|
||||
|
||||
let newImageUrl: string | null | undefined = currentDetail?.profileImage;
|
||||
|
||||
// Handle new image upload (only if different from existing)
|
||||
if (imageKey && imageKey !== currentDetail?.profileImage) {
|
||||
// Delete old image if exists
|
||||
if (currentDetail?.profileImage) {
|
||||
try {
|
||||
await deleteS3Image(currentDetail.profileImage);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old image: ${currentDetail.profileImage}`, e);
|
||||
}
|
||||
}
|
||||
newImageUrl = imageKey;
|
||||
|
||||
// Claim upload URL
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user name if provided
|
||||
if (updateData.name) {
|
||||
await db.update(users)
|
||||
.set({ name: updateData.name.trim() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// Update user email if provided
|
||||
if (updateData.email) {
|
||||
// Check if email already exists (but belongs to different user)
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, updateData.email.toLowerCase().trim()))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser && existingUser.id !== userId) {
|
||||
throw new ApiError('Email already in use by another account', 409);
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ email: updateData.email.toLowerCase().trim() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// Upsert user details
|
||||
if (currentDetail) {
|
||||
// Update existing
|
||||
await db.update(userDetails)
|
||||
.set({
|
||||
...updateData,
|
||||
profileImage: newImageUrl,
|
||||
})
|
||||
.where(eq(userDetails.userId, userId));
|
||||
} else {
|
||||
// Insert new
|
||||
await db.insert(userDetails).values({
|
||||
userId: userId,
|
||||
...updateData,
|
||||
profileImage: newImageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
};
|
||||
}),
|
||||
|
||||
deleteAccount: protectedProcedure
|
||||
.input(z.object({
|
||||
mobile: z.string().min(10, 'Mobile number is required'),
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@ function Register() {
|
|||
const { register } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async (formData: FormData) => {
|
||||
const handleRegister = async (data: {
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
imageKey?: string;
|
||||
}) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await register(formData);
|
||||
await register(data);
|
||||
// Auth context will handle navigation on successful registration
|
||||
} catch (error: any) {
|
||||
Alert.alert(
|
||||
|
|
@ -45,8 +51,7 @@ function Register() {
|
|||
|
||||
<View style={tw`flex-row justify-center mt-2 mb-8`}>
|
||||
<MyText style={tw`text-base text-gray-600`}>Already have an account? </MyText>
|
||||
<MyTouchableOpacity onPress={() => router.push('/(auth)/login')}>
|
||||
<MyText weight="semibold" style={tw`text-blue-600`}>
|
||||
<MyTouchableOpacity onPress={() => router.push('/(auth)/login')}> <MyText weight="semibold" style={tw`text-blue-600`}>
|
||||
Sign in
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
|
|
@ -56,4 +61,4 @@ function Register() {
|
|||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
export default Register;
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { View, ScrollView, TextInput, Alert } from "react-native";
|
|||
import { AppContainer, MyButton, MyText, tw , BottomDialog } from "common-ui";
|
||||
import RegistrationForm from "@/components/registration-form";
|
||||
import { useUserDetails, useAuth } from "@/src/contexts/AuthContext";
|
||||
import { useUpdateProfile } from "@/src/api-hooks/auth.api";
|
||||
import { router } from "expo-router";
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
function EditProfile() {
|
||||
const userDetails = useUserDetails();
|
||||
const { updateUserDetails, logout } = useAuth();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const { logout, refetchUser } = useAuth();
|
||||
const updateProfileMutation = trpc.user.auth.updateProfile.useMutation();
|
||||
|
||||
// State for mobile verification modal
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
|
@ -20,14 +19,16 @@ function EditProfile() {
|
|||
// Prevent unnecessary re-renders
|
||||
const mobileInputValue = useMemo(() => enteredMobile, [enteredMobile]);
|
||||
|
||||
const handleUpdate = async (data: FormData) => {
|
||||
const handleUpdate = async (data: { name: string; email: string; mobile: string; password: string; imageKey?: string }) => {
|
||||
try {
|
||||
const response = await updateProfileMutation.mutateAsync(data);
|
||||
await updateProfileMutation.mutateAsync({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
imageKey: data.imageKey,
|
||||
});
|
||||
|
||||
// Update the context with new user details
|
||||
if (response.user) {
|
||||
updateUserDetails(response.user);
|
||||
}
|
||||
// Refetch user data to get updated values
|
||||
await refetchUser();
|
||||
|
||||
// Navigate back to profile/me page
|
||||
router.replace('/(drawer)/(tabs)/me');
|
||||
|
|
@ -76,6 +77,10 @@ function EditProfile() {
|
|||
email: userDetails.email || '',
|
||||
mobile: userDetails.mobile || '',
|
||||
profileImageUri: userDetails.profileImage || undefined,
|
||||
// Password fields not needed for edit mode
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAccepted: true,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import React, { useState } from "react";
|
|||
import { View, TextInput, Alert } from "react-native";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog } from "common-ui";
|
||||
import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog, MyTouchableOpacity } from "common-ui";
|
||||
import { trpc } from "@/src/trpc-client";
|
||||
import { useUploadToObjectStorage } from "@/src/hooks/useUploadToObjectStorage";
|
||||
|
||||
interface RegisterFormInputs {
|
||||
name: string;
|
||||
|
|
@ -16,19 +17,26 @@ interface RegisterFormInputs {
|
|||
}
|
||||
|
||||
interface RegistrationFormProps {
|
||||
onSubmit: (data: FormData) => void | Promise<void>;
|
||||
onSubmit: (data: {
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
imageKey?: string;
|
||||
}) => void | Promise<void>;
|
||||
isLoading?: boolean;
|
||||
initialValues?: Partial<RegisterFormInputs>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = false }: RegistrationFormProps) {
|
||||
const [profileImage, setProfileImage] = useState<{ uri: string; mimeType: string } | null>(null);
|
||||
const [profileImageUri, setProfileImageUri] = useState<string | undefined>();
|
||||
const [profileImageFile, setProfileImageFile] = useState<any>();
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const updatePasswordMutation = trpc.user.auth.updatePassword.useMutation();
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
// Set initial profile image URI for edit mode
|
||||
React.useEffect(() => {
|
||||
|
|
@ -161,27 +169,28 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
|
|||
return;
|
||||
}
|
||||
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name.trim());
|
||||
formData.append('email', data.email.trim().toLowerCase());
|
||||
formData.append('mobile', data.mobile.replace(/\D/g, ''));
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
|
||||
// Only include password if provided (for edit mode)
|
||||
if (data.password) {
|
||||
formData.append('password', data.password);
|
||||
// Upload profile image if selected
|
||||
if (profileImage) {
|
||||
const response = await fetch(profileImage.uri);
|
||||
const blob = await response.blob();
|
||||
const result = await uploadSingle(blob, profileImage.mimeType, 'profile');
|
||||
imageKey = result.key;
|
||||
}
|
||||
|
||||
// Call onSubmit with data and imageKey
|
||||
await onSubmit({
|
||||
name: data.name.trim(),
|
||||
email: data.email.trim().toLowerCase(),
|
||||
mobile: data.mobile.replace(/\D/g, ''),
|
||||
password: data.password,
|
||||
imageKey,
|
||||
});
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to upload image');
|
||||
}
|
||||
|
||||
if (profileImageFile) {
|
||||
|
||||
formData.append('profileImage', {
|
||||
uri: profileImageFile.uri,
|
||||
type: profileImageFile.mimeType || 'image/jpeg',
|
||||
name: profileImageFile.name || 'profile.jpg',
|
||||
} as any);
|
||||
}
|
||||
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async () => {
|
||||
|
|
@ -213,7 +222,10 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
|
|||
imageUri={profileImageUri}
|
||||
onImageSelect={(uri, file) => {
|
||||
setProfileImageUri(uri);
|
||||
setProfileImageFile(file);
|
||||
setProfileImage({
|
||||
uri: file.uri,
|
||||
mimeType: file.mimeType || 'image/jpeg',
|
||||
});
|
||||
}}
|
||||
size={100}
|
||||
editable={true}
|
||||
|
|
@ -407,10 +419,10 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
|
|||
fillColor="brand500"
|
||||
textColor="white1"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw` rounded-lg`}
|
||||
>
|
||||
{isLoading ? (isEdit ? "Updating..." : "Creating Account...") : (isEdit ? "Update Profile" : "Create Account")}
|
||||
{isUploading ? "Uploading Image..." : isLoading ? (isEdit ? "Updating..." : "Creating Account...") : (isEdit ? "Update Profile" : "Create Account")}
|
||||
</MyButton>
|
||||
|
||||
{isEdit && (
|
||||
|
|
@ -482,4 +494,4 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
|
|||
);
|
||||
}
|
||||
|
||||
export default RegistrationForm;
|
||||
export default RegistrationForm;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,5 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from 'common-ui/src/services/axios';
|
||||
import { LoginCredentials, RegisterData } from '@/src/types/auth';
|
||||
|
||||
// API response types
|
||||
interface RegisterResponse {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
profileImage?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateProfileResponse {
|
||||
token: string;
|
||||
|
|
@ -31,15 +17,6 @@ interface UpdateProfileResponse {
|
|||
}
|
||||
|
||||
// API functions
|
||||
const registerApi = async (data: FormData): Promise<RegisterResponse> => {
|
||||
const response = await axios.post('/uv/auth/register', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data.data; // response.data is {success, data}, we want the inner data
|
||||
};
|
||||
|
||||
const updateProfileApi = async (data: FormData): Promise<UpdateProfileResponse> => {
|
||||
const response = await axios.put('/uv/auth/profile', data, {
|
||||
headers: {
|
||||
|
|
@ -50,11 +27,12 @@ const updateProfileApi = async (data: FormData): Promise<UpdateProfileResponse>
|
|||
};
|
||||
|
||||
// React Query hooks
|
||||
export const useRegister = () => {
|
||||
return useMutation({
|
||||
mutationFn: registerApi,
|
||||
});
|
||||
};
|
||||
// NOTE: useRegister has been migrated to tRPC in AuthContext
|
||||
// export const useRegister = () => {
|
||||
// return useMutation({
|
||||
// mutationFn: registerApi,
|
||||
// });
|
||||
// };
|
||||
|
||||
export const useUpdateProfile = () => {
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { getAuthToken, saveAuthToken, deleteAuthToken, saveUserId, getUserId } from '../../hooks/useJWT';
|
||||
import { getCurrentUserId } from '@/utils/getCurrentUserId';
|
||||
import { useRegister } from '@/src/api-hooks/auth.api';
|
||||
import { AuthState, AuthContextType, LoginCredentials, RegisterData, User, UserDetails } from '@/src/types/auth';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { StorageServiceCasual } from 'common-ui';
|
||||
|
|
@ -32,7 +31,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
|
||||
// const loginMutation = useLogin();
|
||||
const loginMutation = trpc.user.auth.login.useMutation();
|
||||
const registerMutation = useRegister();
|
||||
const registerMutation = trpc.user.auth.register.useMutation();
|
||||
|
||||
// Initialize auth state on app startup
|
||||
useEffect(() => {
|
||||
|
|
@ -228,12 +227,12 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
};
|
||||
|
||||
|
||||
const register = async (data: FormData): Promise<void> => {
|
||||
const register = async (data: { name: string; email: string; mobile: string; password: string; imageKey?: string }): Promise<void> => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await registerMutation.mutateAsync(data);
|
||||
const { token, user } = response;
|
||||
const { token, user } = response.data;
|
||||
|
||||
await saveAuthToken(token);
|
||||
await saveUserId(user.id.toString());
|
||||
|
|
@ -245,7 +244,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
email: user.email,
|
||||
mobile: user.mobile,
|
||||
profileImage: user.profileImage,
|
||||
createdAt: '',
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
userDetails: user,
|
||||
isAuthenticated: true,
|
||||
|
|
@ -305,6 +304,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
logout,
|
||||
updateUser,
|
||||
updateUserDetails,
|
||||
refetchUser: async () => { await refetchSelfData(); },
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
119
apps/user-ui/src/hooks/useUploadToObjectStorage.ts
Normal file
119
apps/user-ui/src/hooks/useUploadToObjectStorage.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useState } from 'react';
|
||||
import { trpc } from '../trpc-client';
|
||||
// import { trpc } from '../src/trpc-client';
|
||||
|
||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'profile';
|
||||
|
||||
interface UploadInput {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface UploadBatchInput {
|
||||
images: UploadInput[];
|
||||
contextString: ContextString;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
keys: string[];
|
||||
presignedUrls: string[];
|
||||
}
|
||||
|
||||
export function useUploadToObjectStorage() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setProgress({ completed: 0, total: input.images.length });
|
||||
|
||||
try {
|
||||
const { images, contextString } = input;
|
||||
|
||||
if (images.length === 0) {
|
||||
return { keys: [], presignedUrls: [] };
|
||||
}
|
||||
|
||||
// 1. Get presigned URLs from backend (one call for all images)
|
||||
const mimeTypes = images.map(img => img.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString,
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
if (uploadUrls.length !== images.length) {
|
||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
||||
}
|
||||
|
||||
// 2. Upload all images in parallel
|
||||
const uploadPromises = images.map(async (image, index) => {
|
||||
const presignedUrl = uploadUrls[index];
|
||||
const { blob, mimeType } = image;
|
||||
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
||||
|
||||
return {
|
||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
||||
presignedUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// Use Promise.all - if any fails, entire batch fails
|
||||
const results = await Promise.all(uploadPromises);
|
||||
|
||||
return {
|
||||
keys: results.map(r => r.key),
|
||||
presignedUrls: results.map(r => r.presignedUrl),
|
||||
};
|
||||
} catch (err) {
|
||||
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
||||
setError(uploadError);
|
||||
throw uploadError;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType }],
|
||||
contextString,
|
||||
});
|
||||
return {
|
||||
key: result.keys[0],
|
||||
presignedUrl: result.presignedUrls[0],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploadSingle,
|
||||
isUploading,
|
||||
error,
|
||||
progress,
|
||||
isPending: generateUploadUrls.isPending
|
||||
};
|
||||
}
|
||||
|
||||
function extractKeyFromPresignedUrl(url: string): string {
|
||||
const u = new URL(url);
|
||||
let rawKey = u.pathname.replace(/^\/+/, '');
|
||||
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
||||
return decodeURIComponent(rawKey);
|
||||
}
|
||||
|
|
@ -37,14 +37,15 @@ export interface RegisterData {
|
|||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
profileImage?: string;
|
||||
imageKey?: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
loginWithToken: (token: string, user: User) => Promise<void>;
|
||||
register: (data: FormData) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
updateUserDetails: (userDetails: Partial<UserDetails>) => void;
|
||||
refetchUser: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import ImageCarousel from "./src/components/ImageCarousel";
|
|||
import ImageGallery from "./src/components/ImageGallery";
|
||||
import ImageGalleryWithDelete from "./src/components/ImageGalleryWithDelete";
|
||||
import ImageUploader from "./src/components/ImageUploader";
|
||||
import ImageUploaderNeo from "./src/components/ImageUploaderNeo";
|
||||
import ProfileImage from "./src/components/profile-image";
|
||||
import Checkbox from "./src/components/checkbox";
|
||||
import AppContainer from "./src/components/app-container";
|
||||
|
|
@ -100,6 +101,7 @@ export {
|
|||
ImageGallery,
|
||||
ImageGalleryWithDelete,
|
||||
ImageUploader,
|
||||
ImageUploaderNeo,
|
||||
ProfileImage,
|
||||
Checkbox,
|
||||
AppContainer,
|
||||
|
|
|
|||
89
packages/ui/src/components/ImageUploaderNeo.tsx
Normal file
89
packages/ui/src/components/ImageUploaderNeo.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { Image } from 'expo-image';
|
||||
import MyText from "./text";
|
||||
import tw from '../lib/tailwind';
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
interface ImageUploaderNeoProps {
|
||||
images: { uri: string; mimeType: string }[];
|
||||
onUploadImage: () => void;
|
||||
onRemoveImage: (image: { uri: string; mimeType: string }) => void;
|
||||
allowMultiple?: boolean;
|
||||
maxImages?: number;
|
||||
}
|
||||
|
||||
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
|
||||
images,
|
||||
onUploadImage,
|
||||
onRemoveImage,
|
||||
allowMultiple = true,
|
||||
maxImages,
|
||||
}) => {
|
||||
// const canUploadMore = allowMultiple && (!maxImages || images.length < maxImages);
|
||||
const isMaxReached = maxImages && images.length >= maxImages;
|
||||
const canUploadMore = !isMaxReached
|
||||
// console.log({isMaxReached, canUploadMore})
|
||||
return (
|
||||
<View style={tw`mb-4`}>
|
||||
<View style={tw`flex-row flex-wrap -mx-1`}>
|
||||
{/* Render images */}
|
||||
{images.map((image, index) => (
|
||||
<View key={`image-${index}-${image.uri}`} style={tw`w-1/3 px-1 mb-2 relative`}>
|
||||
<Image
|
||||
source={{ uri: image.uri }}
|
||||
style={tw`w-full aspect-square rounded`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveImage(image)}
|
||||
style={tw`absolute top-1 right-1 bg-red-500 rounded-full p-1`}
|
||||
>
|
||||
<Ionicons name="close" size={16} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Upload button */}
|
||||
<TouchableOpacity
|
||||
disabled={!canUploadMore}
|
||||
onPress={onUploadImage}
|
||||
style={tw`w-1/3 px-1 mb-2`}
|
||||
>
|
||||
<View
|
||||
style={tw`w-full aspect-square bg-blue-50 border-2 border-dashed border-blue-300 rounded justify-center items-center ${!canUploadMore ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{isMaxReached ? (
|
||||
<View style={tw`absolute inset-0 bg-gray-100 rounded justify-center items-center px-2`}>
|
||||
<MyText style={tw`text-center text-gray-500 text-xs`}>
|
||||
Max {maxImages} images
|
||||
</MyText>
|
||||
</View>
|
||||
) : !allowMultiple && images.length >= 1 ? (
|
||||
<View style={tw`absolute inset-0 bg-gray-100 rounded justify-center items-center px-2`}>
|
||||
<MyText style={tw`text-center text-gray-500 text-xs`}>
|
||||
Only one image allowed
|
||||
</MyText>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="cloud-upload" size={32} color="#3B82F6" />
|
||||
<MyText style={tw`text-blue-500 text-xs mt-1`}>Upload</MyText>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Image count indicator */}
|
||||
{images.length > 0 && (
|
||||
<MyText style={tw`text-gray-500 text-xs mt-2`}>
|
||||
{images.length} image{images.length !== 1 ? 's' : ''} selected
|
||||
{maxImages ? ` (max ${maxImages})` : ''}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploaderNeo;
|
||||
Loading…
Add table
Reference in a new issue