This commit is contained in:
shafi54 2026-03-22 14:57:53 +05:30
parent 8f4cddee1a
commit 1122159552
29 changed files with 857 additions and 597 deletions

View file

@ -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,7 +56,7 @@ export default function AddTag() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isCreating}
isLoading={createTag.isPending}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>

View file

@ -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,7 +89,7 @@ export default function EditTag() {
initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit}
isLoading={isUpdating}
isLoading={updateTag.isPending}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/>
</View>

View file

@ -5,7 +5,17 @@ import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { TagMenu } from '@/src/components/TagMenu';
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores?: any;
createdAt?: string;
}
interface TagItemProps {
item: Tag;
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
export default function ProductTags() {
const router = useRouter();
const { data: tagsData, isLoading, error, refetch } = useGetTags();
const { data: tagsData, isLoading, error, refetch } = trpc.admin.tag.getTags.useQuery();
const [refreshing, setRefreshing] = useState(false);
const tags = tagsData?.tags || [];

View file

@ -2,7 +2,7 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
@ -16,6 +16,12 @@ export interface StoreFormData {
products: number[];
}
interface StoreImage {
uri: string;
mimeType: string;
isExisting: boolean;
}
export interface StoreFormRef {
// Add methods if needed
}
@ -28,6 +34,11 @@ interface StoreFormProps {
storeId?: number;
}
// Extend Formik values with images array
interface FormikStoreValues extends StoreFormData {
images: StoreImage[];
}
const validationSchema = Yup.object().shape({
name: Yup.string().required('Name is required'),
description: Yup.string(),
@ -41,9 +52,23 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
// Build initial form values with images array
const buildInitialValues = (): FormikStoreValues => {
const images: StoreImage[] = [];
if (initialValues.imageUrl) {
images.push({
uri: initialValues.imageUrl,
mimeType: 'image/jpeg',
isExisting: true,
});
}
return {
...initialValues,
images,
};
};
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
// For edit mode, pre-select products belonging to this store
const initialSelectedProducts = useMemo(() => {
@ -55,7 +80,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
useEffect(() => {
setFormInitialValues({
...initialValues,
...buildInitialValues(),
products: initialSelectedProducts,
});
}, [initialValues, initialSelectedProducts]);
@ -65,42 +90,8 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
value: staff.id,
})) || [];
const { uploadSingle, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setSelectedImages([]);
setDisplayImages([]);
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const blobPromises = files.map(async (asset) => {
const response = await fetch(asset.uri);
const blob = await response.blob();
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
});
const blobArray = await Promise.all(blobPromises);
setSelectedImages(blobArray);
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
},
multiple: false, // Single image for stores
});
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex(img => img.uri === uri);
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index);
const newFiles = selectedImages.filter((_, i) => i !== index);
setDisplayImages(newDisplay);
setSelectedImages(newFiles);
}
};
return (
<Formik
initialValues={formInitialValues}
@ -109,24 +100,78 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
// Image picker that adds to Formik field
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const newImages: StoreImage[] = files.map((asset) => ({
uri: asset.uri,
mimeType: asset.mimeType || 'image/jpeg',
isExisting: false,
}));
// Add to Formik images field
const currentImages = values.images || [];
setFieldValue('images', [...currentImages, ...newImages]);
},
multiple: false,
});
// Remove image - works for both existing and new
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
const currentImages = values.images || [];
const removedImage = currentImages.find(img => img.uri === image.uri);
const newImages = currentImages.filter(img => img.uri !== image.uri);
setFieldValue('images', newImages);
// If we removed an existing image, also clear the imageUrl
if (removedImage?.isExisting) {
setFieldValue('imageUrl', undefined);
}
};
const submit = async () => {
try {
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
const { blob, mimeType } = selectedImages[0];
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl;
// Get new images that need to be uploaded
const newImages = values.images.filter(img => !img.isExisting);
if (newImages.length > 0) {
// Upload the first new image (single image for stores)
const image = newImages[0];
const response = await fetch(image.uri);
const imageBlob = await response.blob();
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
imageUrl = key;
} else {
// Check if there's an existing image remaining
const existingImage = values.images.find(img => img.isExisting);
if (existingImage) {
imageUrl = existingImage.uri;
}
}
// Submit form with imageUrl
onSubmit({ ...values, imageUrl });
// Submit form with imageUrl (without images array)
const { images, ...submitValues } = values;
onSubmit({ ...submitValues, imageUrl });
} catch (error) {
console.error('Upload error:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
}
};
// Prepare images for ImageUploaderNeo (convert to expected format)
const imagesForUploader = (values.images || []).map(img => ({
uri: img.uri,
mimeType: img.mimeType,
}));
return (
<View>
<MyTextInput
@ -167,12 +212,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
/>
<View style={tw`mb-6`}>
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader
images={displayImages}
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
onAddImage={handleImagePick}
<ImageUploaderNeo
images={imagesForUploader}
onUploadImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
allowMultiple={false}
/>
</View>

View file

@ -1,119 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface UpdateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores: number[];
createdAt?: string;
}
export interface CreateTagResponse {
tag: Tag;
message: string;
}
export interface GetTagsResponse {
tags: Tag[];
message: string;
}
// API functions
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
const response = await axios.post('/av/product-tags', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
const response = await axios.put(`/av/product-tags/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
const response = await axios.delete(`/av/product-tags/${id}`);
return response.data;
};
const getTagsApi = async (): Promise<GetTagsResponse> => {
const response = await axios.get('/av/product-tags');
return response.data;
};
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
const response = await axios.get(`/av/product-tags/${id}`);
return response.data;
};
// Hooks
export const useCreateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useDeleteTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useGetTags = () => {
return useQuery({
queryKey: ['tags'],
queryFn: getTagsApi,
});
};
export const useGetTag = (id: number) => {
return useQuery({
queryKey: ['tags', id],
queryFn: () => getTagApi(id),
enabled: !!id,
});
};

View file

@ -7,7 +7,6 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api';
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
interface ProductFormData {
@ -71,8 +70,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id,
})) || [];
const { data: tagsData } = useGetTags();
const tagOptions = tagsData?.tags.map(tag => ({
const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
label: tag.tagName,
value: tag.id.toString(),
})) || [];

View file

@ -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,24 +37,35 @@ const TagForm = forwardRef<any, TagFormProps>(({
isLoading,
stores = [],
}, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null);
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const { uploadSingle, isUploading } = useUploadToObjectStorage();
// Update checkbox when initial values change
useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl})
}, [initialValues.isDashboardTag]);
const pickImage = usePickImage({
setFile: (files) => {
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setNewImage(null);
return;
}
setImage(files || null)
const asset = Array.isArray(assets) ? assets[0] : assets;
const response = await fetch(asset.uri);
const blob = await response.blob();
setNewImage({
blob,
mimeType: asset.mimeType || 'image/jpeg',
uri: asset.uri
});
},
multiple: false,
});
const validationSchema = Yup.object().shape({
tagName: Yup.string()
.required('Tag name is required')
@ -63,18 +75,44 @@ const TagForm = forwardRef<any, TagFormProps>(({
.max(500, 'Description must be less than 500 characters'),
});
// Display images for ImageUploader
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
const existingImages = existingImageUrl ? [existingImageUrl] : [];
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, image || undefined)}
onSubmit={async (values) => {
try {
let imageKey: string | undefined;
let deleteExistingImage = false;
// Handle image upload
if (newImage) {
const result = await uploadSingle(newImage.blob, newImage.mimeType, 'product_info');
imageKey = result.key;
// If we're uploading a new image and there's an existing one, mark it for deletion
if (existingImageUrl) {
deleteExistingImage = true;
}
} else if (mode === 'edit' && !newImage && existingImageUrl) {
// In edit mode, if no new image and existing was removed
// This would need UI to explicitly remove image
// For now, we don't support explicit deletion without replacement
}
onSubmit(values, imageKey, deleteExistingImage);
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
}
}}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImage(null);
setNewImage(null);
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
@ -107,11 +145,15 @@ const TagForm = forwardRef<any, TagFormProps>(({
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
</MyText>
<ImageUploader
images={image ? [image] : []}
images={displayImages}
existingImageUrls={mode === 'edit' ? existingImages : []}
onAddImage={pickImage}
onRemoveImage={() => setImage(null)}
onRemoveImage={() => setNewImage(null)}
onRemoveExistingImage={mode === 'edit' ? () => {
// In edit mode, this would trigger deletion of existing image
// But we need to implement this logic in the parent
} : undefined}
/>
</View>
@ -122,7 +164,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
onPress={() => {
const newValue = !isDashboardTagChecked;
setIsDashboardTagChecked(newValue);
formikSetFieldValue('isDashboardTag', newValue);
setFieldValue('isDashboardTag', newValue);
}}
/>
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
@ -143,7 +185,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
}))}
onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
formikSetFieldValue('relatedStores', numericValues);
setFieldValue('relatedStores', numericValues);
}}
multiple={true}
/>
@ -151,11 +193,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
<TouchableOpacity
onPress={() => handleSubmit()}
disabled={isLoading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
disabled={isLoading || isUploading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
</MyText>
</TouchableOpacity>
</View>

View file

@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router';
import { useDeleteTag } from '../api-hooks/tag.api';
import { trpc } from '../trpc-client';
export interface TagMenuProps {
tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
const deleteTag = trpc.admin.tag.deleteTag.useMutation();
const handleOpenMenu = () => {
setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
};
const performDelete = () => {
deleteTag(tagId, {
deleteTag.mutate({ id: tagId }, {
onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.();

View file

@ -17,10 +17,10 @@ S3_REGION=apac
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer
S3_BUCKET_NAME=meatfarmer-dev
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
JWT_SECRET=my_meatfarmer_jwt_secret_key
ASSETS_DOMAIN=https://assets.freshyo.in/
ASSETS_DOMAIN=https://assets2.freshyo.in/
API_CACHE_KEY=api-cache-dev
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh

File diff suppressed because one or more lines are too long

View file

@ -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 {

View file

@ -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;

View file

@ -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",
});
};

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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)

View file

@ -0,0 +1,214 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productTagInfo } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
export const tagRouter = router({
getTags: protectedProcedure
.query(async () => {
const tags = await db
.select()
.from(productTagInfo)
.orderBy(productTagInfo.tagName);
// Generate asset URLs for tag images
const tagsWithUrls = tags.map(tag => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}));
return {
tags: tagsWithUrls,
message: "Tags retrieved successfully",
};
}),
getTagById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input }) => {
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, input.id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Generate asset URL for tag image
const tagWithUrl = {
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
};
return {
tag: tagWithUrl,
message: "Tag retrieved successfully",
};
}),
createTag: protectedProcedure
.input(z.object({
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean().default(false),
relatedStores: z.array(z.number()).default([]),
imageKey: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
// Check for duplicate tag name
const existingTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.tagName, tagName.trim()),
});
if (existingTag) {
throw new ApiError("A tag with this name already exists", 400);
}
const [newTag] = await db
.insert(productTagInfo)
.values({
tagName: tagName.trim(),
tagDescription,
imageUrl: imageKey || null,
isDashboardTag,
relatedStores,
})
.returning();
// Claim upload URL if image was provided
if (imageKey) {
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
scheduleStoreInitialization();
return {
tag: newTag,
message: "Tag created successfully",
};
}),
updateTag: protectedProcedure
.input(z.object({
id: z.number(),
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean(),
relatedStores: z.array(z.number()),
imageKey: z.string().optional(),
deleteExistingImage: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, imageKey, deleteExistingImage, ...updateData } = input;
// Get current tag
const currentTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!currentTag) {
throw new ApiError("Tag not found", 404);
}
let newImageUrl = currentTag.imageUrl;
// Handle image deletion
if (deleteExistingImage && currentTag.imageUrl) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
newImageUrl = null;
}
// Handle new image upload (only if different from existing)
if (imageKey && imageKey !== currentTag.imageUrl) {
// Delete old image if exists and not already deleted
if (currentTag.imageUrl && !deleteExistingImage) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
}
newImageUrl = imageKey;
// Claim upload URL
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
const [updatedTag] = await db
.update(productTagInfo)
.set({
tagName: updateData.tagName.trim(),
tagDescription: updateData.tagDescription,
isDashboardTag: updateData.isDashboardTag,
relatedStores: updateData.relatedStores,
imageUrl: newImageUrl,
})
.where(eq(productTagInfo.id, id))
.returning();
scheduleStoreInitialization();
return {
tag: updatedTag,
message: "Tag updated successfully",
};
}),
deleteTag: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input }) => {
const { id } = input;
// Get tag to check for image
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, id),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Delete image from S3 if exists
if (tag.imageUrl) {
try {
await deleteS3Image(tag.imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
}
}
// Delete tag (will fail if tag is assigned to products due to FK constraint)
await db.delete(productTagInfo).where(eq(productTagInfo.id, id));
scheduleStoreInitialization();
return {
message: "Tag deleted successfully",
};
}),
});
export type TagRouter = typeof tagRouter;

View file

@ -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') {
//

View file

@ -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'),

View file

@ -11,10 +11,16 @@ function Register() {
const { register } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async (formData: FormData) => {
const handleRegister = async (data: {
name: string;
email: string;
mobile: string;
password: string;
imageKey?: string;
}) => {
setIsLoading(true);
try {
await register(formData);
await register(data);
// Auth context will handle navigation on successful registration
} catch (error: any) {
Alert.alert(
@ -45,8 +51,7 @@ function Register() {
<View style={tw`flex-row justify-center mt-2 mb-8`}>
<MyText style={tw`text-base text-gray-600`}>Already have an account? </MyText>
<MyTouchableOpacity onPress={() => router.push('/(auth)/login')}>
<MyText weight="semibold" style={tw`text-blue-600`}>
<MyTouchableOpacity onPress={() => router.push('/(auth)/login')}> <MyText weight="semibold" style={tw`text-blue-600`}>
Sign in
</MyText>
</MyTouchableOpacity>

View file

@ -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 (

View file

@ -2,8 +2,9 @@ import React, { useState } from "react";
import { View, TextInput, Alert } from "react-native";
import { useForm, Controller } from "react-hook-form";
import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog } from "common-ui";
import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog, MyTouchableOpacity } from "common-ui";
import { trpc } from "@/src/trpc-client";
import { useUploadToObjectStorage } from "@/src/hooks/useUploadToObjectStorage";
interface RegisterFormInputs {
name: string;
@ -16,19 +17,26 @@ interface RegisterFormInputs {
}
interface RegistrationFormProps {
onSubmit: (data: FormData) => void | Promise<void>;
onSubmit: (data: {
name: string;
email: string;
mobile: string;
password: string;
imageKey?: string;
}) => void | Promise<void>;
isLoading?: boolean;
initialValues?: Partial<RegisterFormInputs>;
isEdit?: boolean;
}
function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = false }: RegistrationFormProps) {
const [profileImage, setProfileImage] = useState<{ uri: string; mimeType: string } | null>(null);
const [profileImageUri, setProfileImageUri] = useState<string | undefined>();
const [profileImageFile, setProfileImageFile] = useState<any>();
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const updatePasswordMutation = trpc.user.auth.updatePassword.useMutation();
const { uploadSingle, isUploading } = useUploadToObjectStorage();
// Set initial profile image URI for edit mode
React.useEffect(() => {
@ -161,27 +169,28 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
return;
}
// Create FormData
const formData = new FormData();
formData.append('name', data.name.trim());
formData.append('email', data.email.trim().toLowerCase());
formData.append('mobile', data.mobile.replace(/\D/g, ''));
try {
let imageKey: string | undefined;
// Only include password if provided (for edit mode)
if (data.password) {
formData.append('password', data.password);
// Upload profile image if selected
if (profileImage) {
const response = await fetch(profileImage.uri);
const blob = await response.blob();
const result = await uploadSingle(blob, profileImage.mimeType, 'profile');
imageKey = result.key;
}
// Call onSubmit with data and imageKey
await onSubmit({
name: data.name.trim(),
email: data.email.trim().toLowerCase(),
mobile: data.mobile.replace(/\D/g, ''),
password: data.password,
imageKey,
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to upload image');
}
if (profileImageFile) {
formData.append('profileImage', {
uri: profileImageFile.uri,
type: profileImageFile.mimeType || 'image/jpeg',
name: profileImageFile.name || 'profile.jpg',
} as any);
}
await onSubmit(formData);
};
const handleUpdatePassword = async () => {
@ -213,7 +222,10 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
imageUri={profileImageUri}
onImageSelect={(uri, file) => {
setProfileImageUri(uri);
setProfileImageFile(file);
setProfileImage({
uri: file.uri,
mimeType: file.mimeType || 'image/jpeg',
});
}}
size={100}
editable={true}
@ -407,10 +419,10 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit =
fillColor="brand500"
textColor="white1"
fullWidth
disabled={isLoading}
disabled={isLoading || isUploading}
style={tw` rounded-lg`}
>
{isLoading ? (isEdit ? "Updating..." : "Creating Account...") : (isEdit ? "Update Profile" : "Create Account")}
{isUploading ? "Uploading Image..." : isLoading ? (isEdit ? "Updating..." : "Creating Account...") : (isEdit ? "Update Profile" : "Create Account")}
</MyButton>
{isEdit && (

View file

@ -1,19 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import axios from 'common-ui/src/services/axios';
import { LoginCredentials, RegisterData } from '@/src/types/auth';
// API response types
interface RegisterResponse {
token: string;
user: {
id: number;
name: string;
email: string;
mobile: string;
profileImage?: string;
createdAt: string;
};
}
interface UpdateProfileResponse {
token: string;
@ -31,15 +17,6 @@ interface UpdateProfileResponse {
}
// API functions
const registerApi = async (data: FormData): Promise<RegisterResponse> => {
const response = await axios.post('/uv/auth/register', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data; // response.data is {success, data}, we want the inner data
};
const updateProfileApi = async (data: FormData): Promise<UpdateProfileResponse> => {
const response = await axios.put('/uv/auth/profile', data, {
headers: {
@ -50,11 +27,12 @@ const updateProfileApi = async (data: FormData): Promise<UpdateProfileResponse>
};
// React Query hooks
export const useRegister = () => {
return useMutation({
mutationFn: registerApi,
});
};
// NOTE: useRegister has been migrated to tRPC in AuthContext
// export const useRegister = () => {
// return useMutation({
// mutationFn: registerApi,
// });
// };
export const useUpdateProfile = () => {
return useMutation({

View file

@ -1,7 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { getAuthToken, saveAuthToken, deleteAuthToken, saveUserId, getUserId } from '../../hooks/useJWT';
import { getCurrentUserId } from '@/utils/getCurrentUserId';
import { useRegister } from '@/src/api-hooks/auth.api';
import { AuthState, AuthContextType, LoginCredentials, RegisterData, User, UserDetails } from '@/src/types/auth';
import { trpc } from '@/src/trpc-client';
import { StorageServiceCasual } from 'common-ui';
@ -32,7 +31,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
// const loginMutation = useLogin();
const loginMutation = trpc.user.auth.login.useMutation();
const registerMutation = useRegister();
const registerMutation = trpc.user.auth.register.useMutation();
// Initialize auth state on app startup
useEffect(() => {
@ -228,12 +227,12 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
};
const register = async (data: FormData): Promise<void> => {
const register = async (data: { name: string; email: string; mobile: string; password: string; imageKey?: string }): Promise<void> => {
try {
setAuthState(prev => ({ ...prev, isLoading: true }));
const response = await registerMutation.mutateAsync(data);
const { token, user } = response;
const { token, user } = response.data;
await saveAuthToken(token);
await saveUserId(user.id.toString());
@ -245,7 +244,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
email: user.email,
mobile: user.mobile,
profileImage: user.profileImage,
createdAt: '',
createdAt: user.createdAt,
},
userDetails: user,
isAuthenticated: true,
@ -305,6 +304,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
logout,
updateUser,
updateUserDetails,
refetchUser: async () => { await refetchSelfData(); },
};
return (

View file

@ -0,0 +1,119 @@
import { useState } from 'react';
import { trpc } from '../trpc-client';
// import { trpc } from '../src/trpc-client';
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'profile';
interface UploadInput {
blob: Blob;
mimeType: string;
}
interface UploadBatchInput {
images: UploadInput[];
contextString: ContextString;
}
interface UploadResult {
keys: string[];
presignedUrls: string[];
}
export function useUploadToObjectStorage() {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
setIsUploading(true);
setError(null);
setProgress({ completed: 0, total: input.images.length });
try {
const { images, contextString } = input;
if (images.length === 0) {
return { keys: [], presignedUrls: [] };
}
// 1. Get presigned URLs from backend (one call for all images)
const mimeTypes = images.map(img => img.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString,
mimeTypes,
});
if (uploadUrls.length !== images.length) {
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
}
// 2. Upload all images in parallel
const uploadPromises = images.map(async (image, index) => {
const presignedUrl = uploadUrls[index];
const { blob, mimeType } = image;
const response = await fetch(presignedUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!response.ok) {
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
}
// Update progress
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
return {
key: extractKeyFromPresignedUrl(presignedUrl),
presignedUrl,
};
});
// Use Promise.all - if any fails, entire batch fails
const results = await Promise.all(uploadPromises);
return {
keys: results.map(r => r.key),
presignedUrls: results.map(r => r.presignedUrl),
};
} catch (err) {
const uploadError = err instanceof Error ? err : new Error('Upload failed');
setError(uploadError);
throw uploadError;
} finally {
setIsUploading(false);
setProgress(null);
}
};
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
const result = await upload({
images: [{ blob, mimeType }],
contextString,
});
return {
key: result.keys[0],
presignedUrl: result.presignedUrls[0],
};
};
return {
upload,
uploadSingle,
isUploading,
error,
progress,
isPending: generateUploadUrls.isPending
};
}
function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url);
let rawKey = u.pathname.replace(/^\/+/, '');
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
return decodeURIComponent(rawKey);
}

View file

@ -37,14 +37,15 @@ export interface RegisterData {
email: string;
mobile: string;
password: string;
profileImage?: string;
imageKey?: string;
}
export interface AuthContextType extends AuthState {
login: (credentials: LoginCredentials) => Promise<void>;
loginWithToken: (token: string, user: User) => Promise<void>;
register: (data: FormData) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
updateUser: (user: Partial<User>) => void;
updateUserDetails: (userDetails: Partial<UserDetails>) => void;
refetchUser: () => Promise<void>;
}

View file

@ -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,

View file

@ -0,0 +1,89 @@
import React from "react";
import { View, TouchableOpacity } from "react-native";
import { Image } from 'expo-image';
import MyText from "./text";
import tw from '../lib/tailwind';
import Ionicons from "@expo/vector-icons/Ionicons";
import { MaterialIcons } from "@expo/vector-icons";
interface ImageUploaderNeoProps {
images: { uri: string; mimeType: string }[];
onUploadImage: () => void;
onRemoveImage: (image: { uri: string; mimeType: string }) => void;
allowMultiple?: boolean;
maxImages?: number;
}
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
images,
onUploadImage,
onRemoveImage,
allowMultiple = true,
maxImages,
}) => {
// const canUploadMore = allowMultiple && (!maxImages || images.length < maxImages);
const isMaxReached = maxImages && images.length >= maxImages;
const canUploadMore = !isMaxReached
// console.log({isMaxReached, canUploadMore})
return (
<View style={tw`mb-4`}>
<View style={tw`flex-row flex-wrap -mx-1`}>
{/* Render images */}
{images.map((image, index) => (
<View key={`image-${index}-${image.uri}`} style={tw`w-1/3 px-1 mb-2 relative`}>
<Image
source={{ uri: image.uri }}
style={tw`w-full aspect-square rounded`}
/>
<TouchableOpacity
onPress={() => onRemoveImage(image)}
style={tw`absolute top-1 right-1 bg-red-500 rounded-full p-1`}
>
<Ionicons name="close" size={16} color="white" />
</TouchableOpacity>
</View>
))}
{/* Upload button */}
<TouchableOpacity
disabled={!canUploadMore}
onPress={onUploadImage}
style={tw`w-1/3 px-1 mb-2`}
>
<View
style={tw`w-full aspect-square bg-blue-50 border-2 border-dashed border-blue-300 rounded justify-center items-center ${!canUploadMore ? 'opacity-50' : ''}`}
>
{isMaxReached ? (
<View style={tw`absolute inset-0 bg-gray-100 rounded justify-center items-center px-2`}>
<MyText style={tw`text-center text-gray-500 text-xs`}>
Max {maxImages} images
</MyText>
</View>
) : !allowMultiple && images.length >= 1 ? (
<View style={tw`absolute inset-0 bg-gray-100 rounded justify-center items-center px-2`}>
<MyText style={tw`text-center text-gray-500 text-xs`}>
Only one image allowed
</MyText>
</View>
) : (
<>
<MaterialIcons name="cloud-upload" size={32} color="#3B82F6" />
<MyText style={tw`text-blue-500 text-xs mt-1`}>Upload</MyText>
</>
)}
</View>
</TouchableOpacity>
</View>
{/* Image count indicator */}
{images.length > 0 && (
<MyText style={tw`text-gray-500 text-xs mt-2`}>
{images.length} image{images.length !== 1 ? 's' : ''} selected
{maxImages ? ` (max ${maxImages})` : ''}
</MyText>
)}
</View>
);
};
export default ImageUploaderNeo;