From 1122159552c4f442903f2234e479ad2984d49a9b Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:57:53 +0530 Subject: [PATCH] enh --- .../app/(drawer)/product-tags/add.tsx | 42 +--- .../app/(drawer)/product-tags/edit/index.tsx | 53 ++-- .../app/(drawer)/product-tags/index.tsx | 14 +- apps/admin-ui/components/StoreForm.tsx | 144 +++++++---- apps/admin-ui/src/api-hooks/tag.api.ts | 119 --------- apps/admin-ui/src/components/ProductForm.tsx | 5 +- apps/admin-ui/src/components/TagForm.tsx | 82 +++++-- apps/admin-ui/src/components/TagMenu.tsx | 6 +- apps/backend/.env | 4 +- apps/backend/assets/signed-url-cache.json | 2 +- apps/backend/index.ts | 1 + .../src/apis/admin-apis/apis/av-router.ts | 4 - .../apis/product-tags.controller.ts | 226 ------------------ .../src/apis/admin-apis/apis/tag.router.ts | 14 -- apps/backend/src/main-router.ts | 8 +- .../apis/admin-apis/apis/admin-trpc-index.ts | 2 + .../src/trpc/apis/admin-apis/apis/store.ts | 3 +- .../src/trpc/apis/admin-apis/apis/tag.ts | 214 +++++++++++++++++ .../apis/common-apis/common-trpc-index.ts | 4 +- .../src/trpc/apis/user-apis/apis/auth.ts | 146 ++++++++++- apps/user-ui/app/(auth)/register.tsx | 15 +- .../(drawer)/(tabs)/me/edit-profile/index.tsx | 23 +- apps/user-ui/components/registration-form.tsx | 64 +++-- apps/user-ui/src/api-hooks/auth.api.ts | 34 +-- apps/user-ui/src/contexts/AuthContext.tsx | 10 +- .../src/hooks/useUploadToObjectStorage.ts | 119 +++++++++ apps/user-ui/src/types/auth.ts | 5 +- packages/ui/index.ts | 2 + .../ui/src/components/ImageUploaderNeo.tsx | 89 +++++++ 29 files changed, 857 insertions(+), 597 deletions(-) delete mode 100644 apps/admin-ui/src/api-hooks/tag.api.ts delete mode 100644 apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts delete mode 100644 apps/backend/src/apis/admin-apis/apis/tag.router.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/apis/tag.ts create mode 100644 apps/user-ui/src/hooks/useUploadToObjectStorage.ts create mode 100644 packages/ui/src/components/ImageUploaderNeo.tsx diff --git a/apps/admin-ui/app/(drawer)/product-tags/add.tsx b/apps/admin-ui/app/(drawer)/product-tags/add.tsx index c362b4e..36d2821 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/add.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/add.tsx @@ -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 })) || []} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx index 9c91b46..f43224b 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx @@ -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 })) || []} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/product-tags/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/index.tsx index 27b74ce..e3de5f0 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/index.tsx @@ -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 = ({ 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 || []; diff --git a/apps/admin-ui/components/StoreForm.tsx b/apps/admin-ui/components/StoreForm.tsx index 16c4b6d..e57fa08 100644 --- a/apps/admin-ui/components/StoreForm.tsx +++ b/apps/admin-ui/components/StoreForm.tsx @@ -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((props, ref) => { const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery(); const { data: productsData } = trpc.admin.product.getProducts.useQuery(); - const [formInitialValues, setFormInitialValues] = useState(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(buildInitialValues()); // For edit mode, pre-select products belonging to this store const initialSelectedProducts = useMemo(() => { @@ -55,7 +80,7 @@ const StoreForm = forwardRef((props, ref) => { useEffect(() => { setFormInitialValues({ - ...initialValues, + ...buildInitialValues(), products: initialSelectedProducts, }); }, [initialValues, initialSelectedProducts]); @@ -65,42 +90,8 @@ const StoreForm = forwardRef((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 ( ((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 ( ((props, ref) => { /> Store Image - setFormInitialValues({ ...formInitialValues, imageUrl: undefined })} allowMultiple={false} /> diff --git a/apps/admin-ui/src/api-hooks/tag.api.ts b/apps/admin-ui/src/api-hooks/tag.api.ts deleted file mode 100644 index cd84dc9..0000000 --- a/apps/admin-ui/src/api-hooks/tag.api.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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, - }); -}; \ No newline at end of file diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 92baf32..1ba85b9 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -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(({ 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(), })) || []; diff --git a/apps/admin-ui/src/components/TagForm.tsx b/apps/admin-ui/src/components/TagForm.tsx index 8045cd7..a4d0879 100644 --- a/apps/admin-ui/src/components/TagForm.tsx +++ b/apps/admin-ui/src/components/TagForm.tsx @@ -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(({ 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(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(({ .max(500, 'Description must be less than 500 characters'), }); + // Display images for ImageUploader + const displayImages = newImage ? [{ uri: newImage.uri }] : []; + const existingImages = existingImageUrl ? [existingImageUrl] : []; + return ( 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(({ Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'} - 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} /> @@ -122,7 +164,7 @@ const TagForm = forwardRef(({ onPress={() => { const newValue = !isDashboardTagChecked; setIsDashboardTagChecked(newValue); - formikSetFieldValue('isDashboardTag', newValue); + setFieldValue('isDashboardTag', newValue); }} /> Mark as Dashboard Tag @@ -143,7 +185,7 @@ const TagForm = forwardRef(({ }))} 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(({ 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'}`} > - {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')} @@ -167,4 +209,4 @@ const TagForm = forwardRef(({ TagForm.displayName = 'TagForm'; -export default TagForm; \ No newline at end of file +export default TagForm; diff --git a/apps/admin-ui/src/components/TagMenu.tsx b/apps/admin-ui/src/components/TagMenu.tsx index 2bd9d8a..53a088f 100644 --- a/apps/admin-ui/src/components/TagMenu.tsx +++ b/apps/admin-ui/src/components/TagMenu.tsx @@ -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 = ({ }) => { 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 = ({ }; const performDelete = () => { - deleteTag(tagId, { + deleteTag.mutate({ id: tagId }, { onSuccess: () => { Alert.alert('Success', 'Tag deleted successfully'); onDeleteSuccess?.(); diff --git a/apps/backend/.env b/apps/backend/.env index e127cac..be2c9e2 100755 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -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 diff --git a/apps/backend/assets/signed-url-cache.json b/apps/backend/assets/signed-url-cache.json index 66e0901..a0d0fe8 100644 --- a/apps/backend/assets/signed-url-cache.json +++ b/apps/backend/assets/signed-url-cache.json @@ -1 +1 @@ -{"originalToSigned":{"tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=d598f1720e46f57433ba316d631824948a1a9641cac52d3bc40f74a7fc46a6a4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053527},"tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=4d2ea6b5dcf2139fe298f1a99af4c6a74c651324693e3638579dacef8db00ac8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053743},"tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=cb4097321dee3a761d99e57033324ad4fb50b37b948df3ae6813f3c06e904320&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053957},"tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T195535Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T195535Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=e858646d56956d900ca5f77e90f3b02a1608b35b71b44473d42deaf62a2b923b&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054173},"tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T202804Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T202804Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=48e40e485516a8511cfa7b3e01c3ac1f3fd90a1f7dfc78452aeaca6134e54664&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054467},"tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=f1f52a9e8837aa0ee27d70451713d875d7e988380fdc292edd77b55a14d0ca16&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054680},"store-images/1770429593455.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770429593455.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=fc800a140ad29be1a01c98adb94d9bd5829cb87fcafd2e13416e94e4d552c703&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446},"store-images/1770281046297.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281046297.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=ca09755e86928fcccd7978e956172ae5a0e8bc6c9b59422fe0a768870ab30347&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446},"store-images/1770281045021.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281045021.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=a9ed065f8f51e1657752e21204779e1399092a564c85785d2002c9f8632caedb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446}},"signedToOriginal":{"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=d598f1720e46f57433ba316d631824948a1a9641cac52d3bc40f74a7fc46a6a4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg","expiresAt":1774276053527},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=4d2ea6b5dcf2139fe298f1a99af4c6a74c651324693e3638579dacef8db00ac8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp","expiresAt":1774276053743},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=cb4097321dee3a761d99e57033324ad4fb50b37b948df3ae6813f3c06e904320&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png","expiresAt":1774276053957},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T195535Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=e858646d56956d900ca5f77e90f3b02a1608b35b71b44473d42deaf62a2b923b&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T195535Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject","expiresAt":1774276054173},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T202804Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=48e40e485516a8511cfa7b3e01c3ac1f3fd90a1f7dfc78452aeaca6134e54664&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T202804Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject","expiresAt":1774276054467},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=f1f52a9e8837aa0ee27d70451713d875d7e988380fdc292edd77b55a14d0ca16&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg","expiresAt":1774276054680},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770429593455.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=fc800a140ad29be1a01c98adb94d9bd5829cb87fcafd2e13416e94e4d552c703&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770429593455.jpg","expiresAt":1774277091446},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281046297.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=ca09755e86928fcccd7978e956172ae5a0e8bc6c9b59422fe0a768870ab30347&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770281046297.jpg","expiresAt":1774277091446},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281045021.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=a9ed065f8f51e1657752e21204779e1399092a564c85785d2002c9f8632caedb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770281045021.jpg","expiresAt":1774277091446}}} \ No newline at end of file +{"originalToSigned":{"tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=d598f1720e46f57433ba316d631824948a1a9641cac52d3bc40f74a7fc46a6a4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053527},"tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=4d2ea6b5dcf2139fe298f1a99af4c6a74c651324693e3638579dacef8db00ac8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053743},"tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=cb4097321dee3a761d99e57033324ad4fb50b37b948df3ae6813f3c06e904320&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276053957},"tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T195535Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T195535Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=e858646d56956d900ca5f77e90f3b02a1608b35b71b44473d42deaf62a2b923b&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054173},"tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T202804Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T202804Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=48e40e485516a8511cfa7b3e01c3ac1f3fd90a1f7dfc78452aeaca6134e54664&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054467},"tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=f1f52a9e8837aa0ee27d70451713d875d7e988380fdc292edd77b55a14d0ca16&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774276054680},"store-images/1770429593455.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770429593455.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=fc800a140ad29be1a01c98adb94d9bd5829cb87fcafd2e13416e94e4d552c703&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446},"store-images/1770281046297.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281046297.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=ca09755e86928fcccd7978e956172ae5a0e8bc6c9b59422fe0a768870ab30347&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446},"store-images/1770281045021.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281045021.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=a9ed065f8f51e1657752e21204779e1399092a564c85785d2002c9f8632caedb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774277091446},"product-images/1774116266063.jpg":{"value":"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/product-images/1774116266063.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260322%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260322T044828Z&X-Amz-Expires=259200&X-Amz-Signature=6e3aaab8be89b356d2ec53fa0f788404507388e63249c0bf802ee26c0b4b02b4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject","expiresAt":1774414048776}},"signedToOriginal":{"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=d598f1720e46f57433ba316d631824948a1a9641cac52d3bc40f74a7fc46a6a4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1763835253683-c9c3e293-0bef-4c58-a976-dd49c050cd36.jpeg","expiresAt":1774276053527},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=4d2ea6b5dcf2139fe298f1a99af4c6a74c651324693e3638579dacef8db00ac8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1763835293899-43b3fbe1-9b5b-441c-b4d4-d1691c3f02f3.webp","expiresAt":1774276053743},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142833Z&X-Amz-Expires=259200&X-Amz-Signature=cb4097321dee3a761d99e57033324ad4fb50b37b948df3ae6813f3c06e904320&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1768709725124-ebf421c5-ad52-49a9-b65c-1de008110b8a.png","expiresAt":1774276053957},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T195535Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=e858646d56956d900ca5f77e90f3b02a1608b35b71b44473d42deaf62a2b923b&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770321659633-1763869265110-e22b6d94-dac9-499f-babb-1e944d90b01a.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T195535Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3D917db15bcc60cab7ac5cd5e49d85d13a960fe77b4a5e327dd449048870494cf9%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject","expiresAt":1774276054173},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%253FX-Amz-Algorithm%253DAWS4-HMAC-SHA256%2526X-Amz-Content-Sha256%253DUNSIGNED-PAYLOAD%2526X-Amz-Credential%253D8fab47503efb9547b50e4fb317e35cc7%25252F20260205%25252Fapac%25252Fs3%25252Faws4_request%2526X-Amz-Date%253D20260205T202804Z%2526X-Amz-Expires%253D259200%2526X-Amz-Signature%253Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%2526X-Amz-SignedHeaders%253Dhost%2526x-amz-checksum-mode%253DENABLED%2526x-id%253DGetObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=48e40e485516a8511cfa7b3e01c3ac1f3fd90a1f7dfc78452aeaca6134e54664&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770323410499-1763869436182-bf82f7b4-a1f3-4113-985b-96311b7a910e.jpeg%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Content-Sha256%3DUNSIGNED-PAYLOAD%26X-Amz-Credential%3D8fab47503efb9547b50e4fb317e35cc7%252F20260205%252Fapac%252Fs3%252Faws4_request%26X-Amz-Date%3D20260205T202804Z%26X-Amz-Expires%3D259200%26X-Amz-Signature%3Dea436390b277935d843cae6b5cfa62aeed5799cb4a962ab31a0be4b132ca4b30%26X-Amz-SignedHeaders%3Dhost%26x-amz-checksum-mode%3DENABLED%26x-id%3DGetObject","expiresAt":1774276054467},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T142834Z&X-Amz-Expires=259200&X-Amz-Signature=f1f52a9e8837aa0ee27d70451713d875d7e988380fdc292edd77b55a14d0ca16&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"tags/1770323560823-fd0ec463-bed0-474e-aa14-dc6480ce36af.jpeg","expiresAt":1774276054680},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770429593455.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=fc800a140ad29be1a01c98adb94d9bd5829cb87fcafd2e13416e94e4d552c703&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770429593455.jpg","expiresAt":1774277091446},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281046297.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=ca09755e86928fcccd7978e956172ae5a0e8bc6c9b59422fe0a768870ab30347&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770281046297.jpg","expiresAt":1774277091446},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/store-images/1770281045021.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260320%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260320T144551Z&X-Amz-Expires=259200&X-Amz-Signature=a9ed065f8f51e1657752e21204779e1399092a564c85785d2002c9f8632caedb&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"store-images/1770281045021.jpg","expiresAt":1774277091446},"https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com/meatfarmer/product-images/1774116266063.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=8fab47503efb9547b50e4fb317e35cc7%2F20260322%2Fapac%2Fs3%2Faws4_request&X-Amz-Date=20260322T044828Z&X-Amz-Expires=259200&X-Amz-Signature=6e3aaab8be89b356d2ec53fa0f788404507388e63249c0bf802ee26c0b4b02b4&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject":{"value":"product-images/1774116266063.jpg","expiresAt":1774414048776}}} \ No newline at end of file diff --git a/apps/backend/index.ts b/apps/backend/index.ts index b450036..97c11bf 100755 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -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 { diff --git a/apps/backend/src/apis/admin-apis/apis/av-router.ts b/apps/backend/src/apis/admin-apis/apis/av-router.ts index 89685a5..c77111d 100755 --- a/apps/backend/src/apis/admin-apis/apis/av-router.ts +++ b/apps/backend/src/apis/admin-apis/apis/av-router.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts b/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts deleted file mode 100644 index e284afa..0000000 --- a/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts +++ /dev/null @@ -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", - }); -}; diff --git a/apps/backend/src/apis/admin-apis/apis/tag.router.ts b/apps/backend/src/apis/admin-apis/apis/tag.router.ts deleted file mode 100644 index dbcdb0d..0000000 --- a/apps/backend/src/apis/admin-apis/apis/tag.router.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/apps/backend/src/main-router.ts b/apps/backend/src/main-router.ts index 25086e6..446cc4c 100755 --- a/apps/backend/src/main-router.ts +++ b/apps/backend/src/main-router.ts @@ -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; \ No newline at end of file +export default mainRouter; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts index a49a59e..4e23b84 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts @@ -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; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/store.ts b/apps/backend/src/trpc/apis/admin-apis/apis/store.ts index 07f1153..5925843 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/store.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/store.ts @@ -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) diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts b/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts new file mode 100644 index 0000000..66dac83 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts @@ -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; diff --git a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts index d9ba7be..59696cb 100644 --- a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts +++ b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts @@ -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') { // diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index 56e9a3c..6f97d79 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -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'), diff --git a/apps/user-ui/app/(auth)/register.tsx b/apps/user-ui/app/(auth)/register.tsx index 157e069..bb61e9e 100644 --- a/apps/user-ui/app/(auth)/register.tsx +++ b/apps/user-ui/app/(auth)/register.tsx @@ -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() { Already have an account? - router.push('/(auth)/login')}> - + router.push('/(auth)/login')}> Sign in @@ -56,4 +61,4 @@ function Register() { ); } -export default Register; \ No newline at end of file +export default Register; diff --git a/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx index 2253fa1..8db5d44 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx @@ -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 ( diff --git a/apps/user-ui/components/registration-form.tsx b/apps/user-ui/components/registration-form.tsx index 3f98b1f..d48182c 100644 --- a/apps/user-ui/components/registration-form.tsx +++ b/apps/user-ui/components/registration-form.tsx @@ -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; + onSubmit: (data: { + name: string; + email: string; + mobile: string; + password: string; + imageKey?: string; + }) => void | Promise; isLoading?: boolean; initialValues?: Partial; 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(); - const [profileImageFile, setProfileImageFile] = useState(); 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")} {isEdit && ( @@ -482,4 +494,4 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = ); } -export default RegistrationForm; \ No newline at end of file +export default RegistrationForm; diff --git a/apps/user-ui/src/api-hooks/auth.api.ts b/apps/user-ui/src/api-hooks/auth.api.ts index 99dc48a..1f3fd26 100644 --- a/apps/user-ui/src/api-hooks/auth.api.ts +++ b/apps/user-ui/src/api-hooks/auth.api.ts @@ -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 => { - 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 => { const response = await axios.put('/uv/auth/profile', data, { headers: { @@ -50,11 +27,12 @@ const updateProfileApi = async (data: FormData): Promise }; // 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({ diff --git a/apps/user-ui/src/contexts/AuthContext.tsx b/apps/user-ui/src/contexts/AuthContext.tsx index 1226ec3..0f0c87b 100644 --- a/apps/user-ui/src/contexts/AuthContext.tsx +++ b/apps/user-ui/src/contexts/AuthContext.tsx @@ -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 = ({ 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 = ({ children }) => { }; - const register = async (data: FormData): Promise => { + const register = async (data: { name: string; email: string; mobile: string; password: string; imageKey?: string }): Promise => { 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 = ({ 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 = ({ children }) => { logout, updateUser, updateUserDetails, + refetchUser: async () => { await refetchSelfData(); }, }; return ( diff --git a/apps/user-ui/src/hooks/useUploadToObjectStorage.ts b/apps/user-ui/src/hooks/useUploadToObjectStorage.ts new file mode 100644 index 0000000..2975caf --- /dev/null +++ b/apps/user-ui/src/hooks/useUploadToObjectStorage.ts @@ -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(null); + const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null); + + const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); + + const upload = async (input: UploadBatchInput): Promise => { + 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); +} diff --git a/apps/user-ui/src/types/auth.ts b/apps/user-ui/src/types/auth.ts index 892a062..1df9e6c 100644 --- a/apps/user-ui/src/types/auth.ts +++ b/apps/user-ui/src/types/auth.ts @@ -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; loginWithToken: (token: string, user: User) => Promise; - register: (data: FormData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; updateUser: (user: Partial) => void; updateUserDetails: (userDetails: Partial) => void; + refetchUser: () => Promise; } \ No newline at end of file diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 7d350d2..eda5036 100755 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -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, diff --git a/packages/ui/src/components/ImageUploaderNeo.tsx b/packages/ui/src/components/ImageUploaderNeo.tsx new file mode 100644 index 0000000..66ed0d4 --- /dev/null +++ b/packages/ui/src/components/ImageUploaderNeo.tsx @@ -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 = ({ + 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 ( + + + {/* Render images */} + {images.map((image, index) => ( + + + onRemoveImage(image)} + style={tw`absolute top-1 right-1 bg-red-500 rounded-full p-1`} + > + + + + ))} + + {/* Upload button */} + + + {isMaxReached ? ( + + + Max {maxImages} images + + + ) : !allowMultiple && images.length >= 1 ? ( + + + Only one image allowed + + + ) : ( + <> + + Upload + + )} + + + + + {/* Image count indicator */} + {images.length > 0 && ( + + {images.length} image{images.length !== 1 ? 's' : ''} selected + {maxImages ? ` (max ${maxImages})` : ''} + + )} + + ); +}; + +export default ImageUploaderNeo;