copy base dmenu to dkala
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createAddress,
|
||||
getAddresses,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
setDefaultAddress,
|
||||
getAddressById,
|
||||
} from "../service/AddressService";
|
||||
|
||||
export const useGetAddresses = () => {
|
||||
return useQuery({
|
||||
queryKey: ["addresses"],
|
||||
queryFn: () => getAddresses(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetDefaultAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setDefaultAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAddressById = (id: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: ["address", id],
|
||||
queryFn: () => getAddressById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { AddressDisplay } from './AddressDisplay';
|
||||
import { AddressForm } from './AddressForm';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { Location } from 'iconsax-react';
|
||||
|
||||
interface AddressDetailsModalProps {
|
||||
visible: boolean;
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
isPending: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onChangePosition?: () => void;
|
||||
onFormDataChange: (data: Partial<AddressDetailsModalProps['formData']>) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export const AddressDetailsModal = ({
|
||||
visible,
|
||||
selectedAddress,
|
||||
formData,
|
||||
isPending,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onChangePosition,
|
||||
onFormDataChange,
|
||||
onSubmit,
|
||||
}: AddressDetailsModalProps) => {
|
||||
return (
|
||||
<AnimatedBottomSheet
|
||||
bgOpacity={0}
|
||||
inDuration={0}
|
||||
blurOpacity={null}
|
||||
noBlur
|
||||
visible={visible}
|
||||
title='آدرس'
|
||||
onClick={onClose}
|
||||
overrideClassName='bg-container!'
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='px-4'>
|
||||
<AddressDisplay selectedAddress={selectedAddress} />
|
||||
{editMode && onChangePosition && (
|
||||
<div className='mt-4 mb-6 flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChangePosition();
|
||||
}}
|
||||
className='bg-background! w-fit! text-foreground! flex items-center gap-2 text-xs'
|
||||
disabled={isPending}
|
||||
>
|
||||
<Location color='#6b7280' variant='Bold' className='size-4' />
|
||||
تغییر موقعیت روی نقشه
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AddressForm
|
||||
formData={formData}
|
||||
selectedAddress={selectedAddress}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
<div className='grid grid-cols-2 gap-4 mt-16'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (editMode ? 'در حال بهروزرسانی...' : 'در حال ثبت...') : (editMode ? 'بهروزرسانی' : 'تایید')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='bg-disabled! text-foreground!'
|
||||
disabled={isPending}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AnimatedBottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { formatSelectedAddress } from '../utils/formatAddress';
|
||||
|
||||
interface AddressDisplayProps {
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
}
|
||||
|
||||
export const AddressDisplay = ({ selectedAddress }: AddressDisplayProps) => {
|
||||
return (
|
||||
<div className='px-4 mt-2 bg-container'>
|
||||
<span className='text-sm'>
|
||||
{!selectedAddress ? (
|
||||
<Skeleton className='h-6 w-full' />
|
||||
) : (
|
||||
formatSelectedAddress(selectedAddress)
|
||||
)}
|
||||
</span>
|
||||
<hr className='border border-border mt-3 mb-10' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { useGetProfile } from '../../../hooks/userProfileData';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface AddressFormProps {
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
onFormDataChange: (data: Partial<AddressFormProps['formData']>) => void;
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
formData,
|
||||
selectedAddress,
|
||||
onFormDataChange,
|
||||
}: AddressFormProps) => {
|
||||
|
||||
const { data: profileData } = useGetProfile()
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData?.data?.phone && !formData.phone) {
|
||||
onFormDataChange({ phone: '0' + profileData.data.phone });
|
||||
}
|
||||
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
||||
|
||||
return (
|
||||
<div className='px-4'>
|
||||
<InputField
|
||||
htmlFor={'addressTitle'}
|
||||
labelText={'عنوان آدرس'}
|
||||
placeholder='مثال: منزل، محل کار ...'
|
||||
className='bg-inherit'
|
||||
inputClassName='text-xs!'
|
||||
value={formData.title}
|
||||
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
||||
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'addressDetails'}
|
||||
labelText={'جزئیات آدرس'}
|
||||
placeholder='میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.addressDetails}
|
||||
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'phone'}
|
||||
labelText={'شماره تلفن'}
|
||||
placeholder='09123456789'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.phone}
|
||||
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'postalCode'}
|
||||
labelText={'کد پستی'}
|
||||
placeholder={selectedAddress?.address?.postcode || '1234567890'}
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.postalCode || selectedAddress?.address?.postcode || ''}
|
||||
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<div className='inline-flex justify-between items-center w-full mt-8'>
|
||||
<label
|
||||
htmlFor={'isDefault'}
|
||||
className='text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer'
|
||||
>
|
||||
تنظیم به عنوان آدرس پیشفرض
|
||||
</label>
|
||||
<input
|
||||
name={'isDefault'}
|
||||
id={'isDefault'}
|
||||
className='h-4.5 w-4.5 checked:accent-primary cursor-pointer'
|
||||
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
||||
type='checkbox'
|
||||
checked={formData.isDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
|
||||
interface ConfirmPositionModalProps {
|
||||
visible: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmPositionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmPositionModalProps) => {
|
||||
return (
|
||||
<AnimatedBottomSheet
|
||||
bgOpacity={0}
|
||||
inDuration={0}
|
||||
blurOpacity={null}
|
||||
noBlur
|
||||
visible={visible}
|
||||
showCloseButton={false}
|
||||
showTitle={false}
|
||||
overrideClassName='!pt-6 bg-container!'
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-4 px-4'>
|
||||
<Button onClick={onConfirm}>
|
||||
انتخاب
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className='bg-disabled! text-foreground!'
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedBottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCreateAddress, useUpdateAddress } from '../../hooks/useAddressData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { CreateAddressType, UpdateAddressType, NominatimReverseGeocodingResponse, Address } from '../../types/Types';
|
||||
|
||||
interface UseAddressFormProps {
|
||||
selectedPosition: [number, number] | null;
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
initialPhone?: string;
|
||||
editMode?: boolean;
|
||||
addressData?: Address | null;
|
||||
}
|
||||
|
||||
export const useAddressForm = ({
|
||||
selectedPosition,
|
||||
selectedAddress,
|
||||
initialPhone = '',
|
||||
editMode = false,
|
||||
addressData = null,
|
||||
}: UseAddressFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
const { mutate: createAddress, isPending: isCreating } = useCreateAddress();
|
||||
const { mutate: updateAddress, isPending: isUpdating } = useUpdateAddress();
|
||||
const isPending = isCreating || isUpdating;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
addressDetails: '',
|
||||
phone: initialPhone,
|
||||
postalCode: '',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && addressData) {
|
||||
setFormData({
|
||||
title: addressData.title || '',
|
||||
addressDetails: addressData.address || '',
|
||||
phone: addressData.phone || initialPhone,
|
||||
postalCode: addressData.postalCode || '',
|
||||
isDefault: addressData.isDefault || false,
|
||||
});
|
||||
}
|
||||
}, [editMode, addressData, initialPhone]);
|
||||
|
||||
const handleFormDataChange = (data: Partial<typeof formData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const submitAddress = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedPosition || !selectedAddress) {
|
||||
toast('لطفا موقعیت را روی نقشه انتخاب کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
toast('لطفا عنوان آدرس را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.addressDetails.trim()) {
|
||||
toast('لطفا جزئیات آدرس را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.phone.trim()) {
|
||||
toast('لطفا شماره تلفن را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseAddressData = {
|
||||
title: formData.title,
|
||||
address: formData.addressDetails,
|
||||
city: selectedAddress.address.city || selectedAddress.address.district || '',
|
||||
province: selectedAddress.address.province || selectedAddress.address.state || '',
|
||||
postalCode: formData.postalCode || selectedAddress.address.postcode || '',
|
||||
latitude: selectedPosition[0],
|
||||
longitude: selectedPosition[1],
|
||||
phone: formData.phone,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
|
||||
if (editMode && addressData) {
|
||||
const updateData: UpdateAddressType = {
|
||||
...baseAddressData,
|
||||
id: addressData.id,
|
||||
};
|
||||
|
||||
updateAddress(updateData, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس با موفقیت بهروزرسانی شد', 'success');
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در بهروزرسانی آدرس';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const createData: CreateAddressType = baseAddressData;
|
||||
|
||||
createAddress(createData, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس با موفقیت ثبت شد', 'success');
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در ثبت آدرس';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
isPending,
|
||||
handleFormDataChange,
|
||||
submitAddress,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { ArrowLeft } from 'iconsax-react';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { useAuthStore } from '@/zustand/authStore';
|
||||
import { MarkerData, NominatimReverseGeocodingResponse } from '../types/Types';
|
||||
import { ConfirmPositionModal } from './components/ConfirmPositionModal';
|
||||
import { AddressDetailsModal } from './components/AddressDetailsModal';
|
||||
import { useAddressForm } from './hooks/useAddressForm';
|
||||
import { fetchAddressFromCoordinates } from './utils/fetchAddress';
|
||||
import { useGetAddressById } from '../hooks/useAddressData';
|
||||
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
|
||||
import { ActiveSearchbox } from '@/components/input/ActiveSearchbox';
|
||||
|
||||
const CustomMap = dynamic(
|
||||
() => import('@/components/map/CustomMap'),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
function OrderTrackingPage() {
|
||||
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const id = params.get('id');
|
||||
const editMode = !!id;
|
||||
|
||||
const { data: addressResponse, isLoading: isLoadingAddress } = useGetAddressById(id);
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const address = addressResponse?.data;
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [selectedPosition, setSelectedPosition] = useState<[number, number] | null>(null);
|
||||
const [selectedAddress, setSelectedAddress] = useState<NominatimReverseGeocodingResponse | null>(null);
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const [initialPositionSet, setInitialPositionSet] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{ id: string; title: string; position: [number, number] }>>([]);
|
||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const restaurant = aboutData?.data;
|
||||
|
||||
const [mapCenter, setMapCenter] = useState<[number, number]>(() => [
|
||||
Number(params?.get('lat')) || 35.6892,
|
||||
Number(params?.get('lon')) || 51.3890
|
||||
]);
|
||||
const { state: confirmModal, toggle: toggleConfirmModal, set: setConfirmModal } = useToggle();
|
||||
const { state: detailsModal, toggle: _toggleDetailsModal, set: setDetailsModal } = useToggle();
|
||||
|
||||
const { formData, isPending, handleFormDataChange, submitAddress } = useAddressForm({
|
||||
selectedPosition,
|
||||
selectedAddress,
|
||||
initialPhone: user?.number || '',
|
||||
editMode,
|
||||
addressData: address || null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && address) {
|
||||
setMapCenter([address.latitude, address.longitude]);
|
||||
} else if (restaurant?.latitude && restaurant?.longitude) {
|
||||
setMapCenter([restaurant.latitude, restaurant.longitude]);
|
||||
}
|
||||
}, [editMode, address, restaurant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && address && !initialPositionSet) {
|
||||
const position: [number, number] = [address.latitude, address.longitude];
|
||||
setSelectedPosition(position);
|
||||
setInitialPositionSet(true);
|
||||
|
||||
fetchAddressFromCoordinates(address.latitude, address.longitude).then((addr) => {
|
||||
if (addr) {
|
||||
setSelectedAddress(addr);
|
||||
setDetailsModal(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [editMode, address, initialPositionSet, setDetailsModal]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const initializeMarkers = async () => {
|
||||
const L = (await import('leaflet')).default;
|
||||
|
||||
const iconHtml = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; text-align: center;">
|
||||
<img src="/icons/restaurant-marker.svg" alt="موقعیت رستوران" style="width: 32px; height: 40px; display: block;" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
const restaurantPosition: [number, number] | null =
|
||||
restaurant?.latitude && restaurant?.longitude
|
||||
? [restaurant.latitude, restaurant.longitude]
|
||||
: null;
|
||||
|
||||
const markersToSet: MarkerData[] = [];
|
||||
|
||||
if (restaurantPosition) {
|
||||
markersToSet.push({
|
||||
position: restaurantPosition,
|
||||
title: 'موقعیت رستوران',
|
||||
icon: L.divIcon({
|
||||
html: iconHtml,
|
||||
className: 'custom-restaurant-marker',
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
popupAnchor: [0, -40],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
setMarkers(markersToSet);
|
||||
};
|
||||
|
||||
initializeMarkers();
|
||||
}, [restaurant]);
|
||||
|
||||
const handlePositionSelect = (position: [number, number]) => {
|
||||
setSelectedPosition((prev) => {
|
||||
if (prev && prev[0] === position[0] && prev[1] === position[1]) {
|
||||
return prev;
|
||||
}
|
||||
setSelectedAddress(null);
|
||||
return position;
|
||||
});
|
||||
if (editMode && detailsModal) {
|
||||
setDetailsModal(false);
|
||||
}
|
||||
setConfirmModal(position && true);
|
||||
};
|
||||
|
||||
const handleZoomChange = (zoom: number) => {
|
||||
console.log('Zoom level:', zoom);
|
||||
};
|
||||
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
console.log('Map clicked at:', e.latlng);
|
||||
};
|
||||
|
||||
const toggleDetailsModal = async () => {
|
||||
if (!selectedPosition) {
|
||||
setConfirmModal(false);
|
||||
setDetailsModal(false);
|
||||
return;
|
||||
}
|
||||
setConfirmModal(false);
|
||||
_toggleDetailsModal();
|
||||
if (!detailsModal) {
|
||||
const address = await fetchAddressFromCoordinates(
|
||||
selectedPosition[0],
|
||||
selectedPosition[1]
|
||||
);
|
||||
setSelectedAddress(address);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePosition = () => {
|
||||
setDetailsModal(false);
|
||||
setSelectedPosition(null);
|
||||
setSelectedAddress(null);
|
||||
};
|
||||
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const handleSearch = async (value: string) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
setSearchResults([]);
|
||||
setIsSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setIsSearchOpen(true);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const viewbox = '44.0,25.0,63.0,40.0';
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`format=json&` +
|
||||
`q=${encodeURIComponent(value)}&` +
|
||||
`countrycodes=ir&` +
|
||||
`viewbox=${viewbox}&` +
|
||||
`bounded=0&` +
|
||||
`limit=10&` +
|
||||
`namedetails=1&` +
|
||||
`accept-language=fa`
|
||||
, {
|
||||
headers: {
|
||||
'Accept-Language': 'fa,en;q=0.9'
|
||||
}
|
||||
});
|
||||
interface SearchResult {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
}
|
||||
const data: SearchResult[] = await response.json();
|
||||
setSearchResults(data.map((item) => ({
|
||||
id: item.place_id,
|
||||
title: item.display_name,
|
||||
position: [parseFloat(item.lat), parseFloat(item.lon)] as [number, number]
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
|
||||
const handleSearchResultSelect = (result: { id: string; title: string; position: [number, number] }) => {
|
||||
setSelectedPosition(result.position);
|
||||
setMapCenter(result.position);
|
||||
setIsSearchOpen(false);
|
||||
if (mapInstance) {
|
||||
mapInstance.setView(result.position, mapInstance.getZoom());
|
||||
}
|
||||
};
|
||||
|
||||
if (editMode && isLoadingAddress) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-sm2 text-disabled-text">در حال بارگذاری...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!restaurant) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasServiceArea = restaurant?.serviceArea &&
|
||||
restaurant.serviceArea.coordinates &&
|
||||
restaurant.serviceArea.coordinates.length > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-50">
|
||||
<div className="absolute inset-0">
|
||||
<CustomMap
|
||||
initialPosition={mapCenter}
|
||||
zoom={14}
|
||||
markers={markers}
|
||||
serviceArea={restaurant?.serviceArea || null}
|
||||
onPositionSelect={handlePositionSelect}
|
||||
onZoomChange={handleZoomChange}
|
||||
onClick={handleMapClick}
|
||||
searchEnabled={false}
|
||||
onMapReady={setMapInstance}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 right-4 z-1001">
|
||||
<div className="flex items-start gap-2" dir="ltr">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-full bg-container/90 backdrop-blur-sm shadow-sm shrink-0"
|
||||
>
|
||||
<ArrowLeft size={24} className="stroke-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
<div className="[&_.map-search-container]:static! [&_.map-search-container]:transform-none! [&_.map-search-container]:w-full! [&_.map-search-container]:max-w-none! [&_.map-search-container]:top-0!" dir="rtl">
|
||||
<ActiveSearchbox
|
||||
placeholder="جستجو"
|
||||
onSearch={handleSearch}
|
||||
isLoading={isSearching}
|
||||
results={searchResults}
|
||||
onResultSelect={(v) => {
|
||||
const result = searchResults.find(r => r.id === v.id && r.title === v.title);
|
||||
if (result) {
|
||||
handleSearchResultSelect(result);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
className="[&_div]:h-9! [&_input]:text-xs! [&_div]:px-3! [&_input]:px-2! [&_div]:dir-rtl! [&_input]:text-right! [&_input]:dir-rtl!"
|
||||
/>
|
||||
</div>
|
||||
{isSearchOpen && (isSearching || searchResults.length > 0) && (
|
||||
<div className="mt-2 flex items-start gap-2" dir="ltr">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchResults([]);
|
||||
}}
|
||||
className="p-2 rounded-full bg-container/90 backdrop-blur-sm shadow-sm shrink-0 mt-1"
|
||||
>
|
||||
<ArrowLeft size={24} className="stroke-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasServiceArea && (
|
||||
<div className="absolute bottom-4 left-4 z-1000 max-w-sm">
|
||||
<div className="bg-blue-50/90 border border-blue-200 rounded-lg p-3 shadow-sm">
|
||||
<p className="text-xs text-blue-800">
|
||||
محدوده سرویسدهی روی نقشه با رنگ آبی مشخص شده است
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-1000">
|
||||
<div className='relative w-full h-full'>
|
||||
<ConfirmPositionModal
|
||||
visible={confirmModal}
|
||||
onConfirm={toggleDetailsModal}
|
||||
onCancel={toggleConfirmModal}
|
||||
/>
|
||||
|
||||
<AddressDetailsModal
|
||||
visible={detailsModal}
|
||||
selectedAddress={selectedAddress}
|
||||
formData={formData}
|
||||
isPending={isPending}
|
||||
editMode={editMode}
|
||||
onClose={toggleDetailsModal}
|
||||
onChangePosition={handleChangePosition}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
onSubmit={submitAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderTrackingPage;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
|
||||
export const fetchAddressFromCoordinates = async (
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<NominatimReverseGeocodingResponse | null> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&addressdetails=1&accept-language=fa`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching address:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
|
||||
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
||||
if (!selectedAddress) {
|
||||
return "آدرس یافت نشد";
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
selectedAddress.address.state ?? selectedAddress.address.province,
|
||||
selectedAddress.address.city,
|
||||
selectedAddress.address.neighbourhood,
|
||||
!selectedAddress.address.road.startsWith('میدان') &&
|
||||
!selectedAddress.address.road.startsWith('بلوار') &&
|
||||
!selectedAddress.address.road.startsWith('خیابان') &&
|
||||
!selectedAddress.address.road.startsWith('کوچه') ?
|
||||
selectedAddress.type === 'secondary' ? `بلوار ${selectedAddress.address.road}` :
|
||||
selectedAddress.type === 'tertiary' ? `خیابان ${selectedAddress.address.road}` :
|
||||
selectedAddress.type === 'residential' ? `کوچه ${selectedAddress.address.road}` :
|
||||
selectedAddress.address.road : selectedAddress.address.road
|
||||
].filter(x => x).join('، ');
|
||||
} catch {
|
||||
if (selectedAddress.display_name) {
|
||||
return selectedAddress.display_name;
|
||||
} else {
|
||||
return "آدرس یافت نشد";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { ef } from '@/lib/helpers/utfNumbers';
|
||||
import { ArrowLeft, Edit2, TickCircle, Trash } from 'iconsax-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import { useGetAddresses, useSetDefaultAddress, useDeleteAddress } from './hooks/useAddressData';
|
||||
import { Address } from './types/Types';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { toast } from '@/components/Toast';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
|
||||
type Props = object
|
||||
|
||||
function UserAddressesPage({ }: Props) {
|
||||
|
||||
const { data: addressesResponse, isLoading } = useGetAddresses();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
const { mutate: setDefault, isPending: isSettingDefault } = useSetDefaultAddress();
|
||||
const { mutate: deleteAddressMutation, isPending: isDeleting } = useDeleteAddress();
|
||||
const { state: deleteModalVisible, set: setDeleteModal } = useToggle();
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
|
||||
|
||||
const addresses = addressesResponse?.data || [];
|
||||
|
||||
const handleSetDefault = (addressId: string) => {
|
||||
setDefault(addressId, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس به عنوان پیشفرض تنظیم شد', 'success');
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در تنظیم آدرس پیشفرض';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteClick = (addressId: string) => {
|
||||
setSelectedAddressId(addressId);
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!selectedAddressId) return;
|
||||
|
||||
deleteAddressMutation(selectedAddressId, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس با موفقیت حذف شد', 'success');
|
||||
setDeleteModal(false);
|
||||
setSelectedAddressId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در حذف آدرس';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteModal(false);
|
||||
setSelectedAddressId(null);
|
||||
};
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>افزودن و انتخاب آدرس</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 w-full py-0 flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center gap-4'>
|
||||
<p className='text-center text-sm2 text-disabled-text'>
|
||||
در حال بارگذاری...
|
||||
</p>
|
||||
</div>
|
||||
) : addresses.length === 0 ? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center gap-4'>
|
||||
<p className='text-center text-sm2 text-disabled-text'>
|
||||
شما هیچ آدرسی ثبت نکرده اید. برای ثبت آدرس دکمه افزودن را بزنید
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
addresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className={`bg-container rounded-container w-full shadow-xl px-4 py-4 flex flex-col justify-between ${address.isDefault ? 'border border-primary' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h2 className='text-sm font-medium'>{address.title}</h2>
|
||||
{address.isDefault && (
|
||||
<span className='text-xs bg-primary/10 text-primary px-2 py-1 rounded-full'>
|
||||
پیشفرض
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-sm2 mt-2'>
|
||||
{formatAddress(address)}
|
||||
</p>
|
||||
<p className='text-xs mt-2 text-disabled-text'>
|
||||
کد پستی: {ef(address.postalCode || 'ثبت نشده')}
|
||||
</p>
|
||||
<p className='text-xs mt-2 text-disabled-text'>
|
||||
تلفن: {ef(address.phone)}
|
||||
</p>
|
||||
<div className="flex justify-between items-center mt-4 gap-2">
|
||||
{!address.isDefault && (
|
||||
<Button
|
||||
onClick={() => handleSetDefault(address.id)}
|
||||
disabled={isSettingDefault}
|
||||
pending={isSettingDefault}
|
||||
className='bg-background! text-foreground! flex items-center gap-2 text-xs h-8 flex-1'
|
||||
>
|
||||
<TickCircle variant='Bold' className='fill-foreground stroke-background size-4.5 mb-0.5' />
|
||||
تنظیم به عنوان فعال
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Link href={`address/new?id=${address.id}`}>
|
||||
<Button className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'>
|
||||
<Edit2 className='stroke-foreground size-5 mb-0.5' />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => handleDeleteClick(address.id)}
|
||||
disabled={isDeleting}
|
||||
className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'
|
||||
>
|
||||
<Trash className='stroke-foreground size-5 mb-0.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='w-full text-center mt-6'>
|
||||
<Link href={'address/new'}>
|
||||
<Button>افزودن آدرس</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Prompt
|
||||
title='حذف آدرس'
|
||||
description='آیا از حذف این آدرس اطمینان دارید؟'
|
||||
textConfirm='بله، حذف کن'
|
||||
textCancel='انصراف'
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
onClick={handleDeleteCancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserAddressesPage
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api } from "@/lib/api/axiosInstance";
|
||||
import {
|
||||
CreateAddressType,
|
||||
AddressesResponse,
|
||||
AddressResponse,
|
||||
UpdateAddressType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getAddresses = async (): Promise<AddressesResponse> => {
|
||||
const { data } = await api.get<AddressesResponse>("/public/user/addresses");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createAddress = async (params: CreateAddressType) => {
|
||||
const { data } = await api.post("/public/user/addresses", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateAddress = async (params: UpdateAddressType) => {
|
||||
const { id, ...rest } = params;
|
||||
const { data } = await api.patch(`/public/user/addresses/${id}`, rest);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteAddress = async (id: string) => {
|
||||
const { data } = await api.delete(`/public/user/addresses/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setDefaultAddress = async (id: string) => {
|
||||
const { data } = await api.patch(`/public/user/addresses/${id}/default`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAddressById = async (id: string): Promise<AddressResponse> => {
|
||||
const { data } = await api.get<AddressResponse>(
|
||||
`/public/user/addresses/${id}`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Icon, DivIcon } from "leaflet";
|
||||
import type { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: string;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
phone: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export type AddressesResponse = BaseResponse<Address[]>;
|
||||
|
||||
export type AddressResponse = BaseResponse<Address>;
|
||||
|
||||
export type CreateAddressType = {
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
phone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type UpdateAddressType = CreateAddressType & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface MarkerData {
|
||||
position: [number, number];
|
||||
title: string;
|
||||
icon?: Icon | DivIcon;
|
||||
}
|
||||
|
||||
export interface NominatimReverseGeocodingResponse {
|
||||
place_id: number;
|
||||
licence: string;
|
||||
osm_type: string;
|
||||
osm_id: number;
|
||||
lat: string;
|
||||
lon: string;
|
||||
class: string;
|
||||
type: string;
|
||||
place_rank: number;
|
||||
importance: number;
|
||||
addresstype: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
address: NominatimReverseGeocodingResponseAddress;
|
||||
boundingbox: string[];
|
||||
}
|
||||
|
||||
export interface NominatimReverseGeocodingResponseAddress {
|
||||
road: string;
|
||||
neighbourhood: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
district: string;
|
||||
county: string;
|
||||
province: string;
|
||||
"ISO3166-2-lvl4": string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
state: string;
|
||||
country_code: string;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { Button as ShadButton } from '@/components/ui/button';
|
||||
import ComboBox from '@/components/combobox/Combobox';
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { PORFILE_EDIT_PAGE_ELEMENT } from '@/enums';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@radix-ui/react-popover';
|
||||
import { Calendar2, Camera } from 'iconsax-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import Calendar from '@/components/ui/calendar';
|
||||
import { getDateLib } from "react-day-picker/persian";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useGetProfile, useSingleUpload, useUpdateProfile } from '../hooks/userProfileData';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { UpdateProfileType } from '../types/Types';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
|
||||
type Props = object
|
||||
|
||||
function ProfileIndex({ }: Props) {
|
||||
|
||||
const { mutateAsync: singleUpload, isPending: isUploading } = useSingleUpload();
|
||||
const { data, isPending } = useGetProfile();
|
||||
const { mutate: updateProfile, isPending: isUpdating } = useUpdateProfile();
|
||||
const [showCalendar, setShowCalendar] = React.useState(false);
|
||||
const [uploadedImageUrl, setUploadedImageUrl] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const dateLib = getDateLib()
|
||||
const router = useRouter();
|
||||
|
||||
const userData = data?.data;
|
||||
|
||||
// تابع برای بررسی معتبر بودن Date
|
||||
const isValidDate = (date: Date | null | undefined): date is Date => {
|
||||
return date instanceof Date && !isNaN(date.getTime());
|
||||
};
|
||||
|
||||
// تابع برای تبدیل string تاریخ به Date بدون مشکل timezone
|
||||
const parseLocalDate = (dateString: string): Date | undefined => {
|
||||
try {
|
||||
if (!dateString) return undefined;
|
||||
|
||||
// اگر تاریخ به فرمت ISO یا کامل است، ابتدا به Date تبدیل میکنیم
|
||||
const date = new Date(dateString);
|
||||
if (isValidDate(date)) {
|
||||
// برای جلوگیری از مشکل timezone، فقط سال، ماه و روز را میگیریم
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
// اگر فرمت ISO کار نکرد، سعی میکنیم فرمت YYYY-MM-DD را parse کنیم
|
||||
const parts = dateString.split('T')[0].split('-');
|
||||
if (parts.length === 3) {
|
||||
const [year, month, day] = parts.map(Number);
|
||||
if (!isNaN(year) && !isNaN(month) && !isNaN(day)) {
|
||||
const parsedDate = new Date(year, month - 1, day);
|
||||
if (isValidDate(parsedDate)) {
|
||||
return parsedDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// تابع برای تبدیل Date به string تاریخ
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// تابع برای format کردن تاریخ با dateLib
|
||||
const formatDateForDisplay = (date: Date | null | undefined): string => {
|
||||
if (!date || !isValidDate(date)) {
|
||||
return "انتخاب کنید";
|
||||
}
|
||||
try {
|
||||
return dateLib.format(date, "yyyy/MM/dd");
|
||||
} catch {
|
||||
return "انتخاب کنید";
|
||||
}
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate?: Date | null;
|
||||
gender?: boolean;
|
||||
};
|
||||
|
||||
const validationSchema: Yup.ObjectSchema<FormValues> = Yup.object({
|
||||
firstName: Yup.string().required('نام اجباری است'),
|
||||
lastName: Yup.string().required('نام خانوادگی اجباری است'),
|
||||
}) as Yup.ObjectSchema<FormValues>;
|
||||
|
||||
const formik = useFormik<FormValues>({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
birthDate: undefined,
|
||||
gender: undefined,
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values) => {
|
||||
const { birthDate, ...submitValues } = values;
|
||||
const payload: UpdateProfileType = {
|
||||
firstName: submitValues.firstName,
|
||||
lastName: submitValues.lastName,
|
||||
birthDate: birthDate ? formatDateToString(birthDate) : undefined,
|
||||
gender: submitValues.gender,
|
||||
avatarUrl: uploadedImageUrl || undefined,
|
||||
};
|
||||
|
||||
updateProfile(payload, {
|
||||
onSuccess: () => {
|
||||
toast('اطلاعات با موفقیت بهروزرسانی شد', 'success');
|
||||
router.back();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
const birthDate = userData.birthDate ? parseLocalDate(userData.birthDate) : undefined;
|
||||
const values: FormValues = {
|
||||
firstName: userData.firstName || '',
|
||||
lastName: userData.lastName || '',
|
||||
birthDate: birthDate,
|
||||
gender: userData.gender !== undefined ? userData.gender : undefined,
|
||||
};
|
||||
formik.setValues(values);
|
||||
// اگر کاربر تصویر آواتار دارد، آن را نمایش بده
|
||||
if (userData.avatarUrl) {
|
||||
setUploadedImageUrl(userData.avatarUrl);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userData]);
|
||||
|
||||
const selectedGenderId = useMemo(() => {
|
||||
if (formik.values.gender === true) return '0';
|
||||
if (formik.values.gender === false) return '1';
|
||||
return '';
|
||||
}, [formik.values.gender]);
|
||||
|
||||
const changeGender = (e: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => {
|
||||
formik.setFieldValue('gender', index === 0);
|
||||
}
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const response = await singleUpload(file);
|
||||
if (response?.data?.url) {
|
||||
setUploadedImageUrl(response.data.url);
|
||||
toast('تصویر با موفقیت آپلود شد', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className='h-full noscrollbar overflow-y-auto flex flex-col items-center justify-center'>
|
||||
<p className='text-sm2 text-disabled-text'>در حال بارگذاری...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full noscrollbar overflow-y-auto flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="mt-8 flex-1 bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
<div className="bg-inherit">
|
||||
<div className="justify-self-center w-fit relative text-center pb-10">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button type="button" onClick={handleImageClick} disabled={isUploading} className="relative">
|
||||
{uploadedImageUrl && (
|
||||
<Image
|
||||
src={uploadedImageUrl}
|
||||
className='rounded-full'
|
||||
alt='user avatar'
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`w-24 h-24 ${uploadedImageUrl ? 'absolute top-0 left-0' : ''} rounded-full cursor-pointer flex items-center justify-center`}
|
||||
style={uploadedImageUrl ? {
|
||||
// background: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 51.56%, rgba(0, 0, 0, 0.5) 100%)'
|
||||
} : {
|
||||
background: 'rgba(0, 0, 0, 0.1)',
|
||||
border: '2px dashed rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{isUploading ? (
|
||||
<div className='text-disabled-text text-xs'>
|
||||
در حال آپلود...
|
||||
</div>
|
||||
) : (
|
||||
<Camera size={24} className={uploadedImageUrl ? 'stroke-white' : 'stroke-disabled-text'} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 mt-6 bg-inherit">
|
||||
<InputField
|
||||
name="firstName"
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className='bg-inherit'
|
||||
labelText='نام'
|
||||
htmlFor="firstName"
|
||||
value={formik.values.firstName}
|
||||
valid={!(formik.touched.firstName && formik.errors.firstName)}
|
||||
aria-errormessage={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : undefined}
|
||||
/>
|
||||
<InputField
|
||||
name="lastName"
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className='bg-inherit'
|
||||
labelText='نام خانوادگی'
|
||||
htmlFor="lastName"
|
||||
value={formik.values.lastName}
|
||||
valid={!(formik.touched.lastName && formik.errors.lastName)}
|
||||
aria-errormessage={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : undefined}
|
||||
/>
|
||||
<Popover open={showCalendar} onOpenChange={setShowCalendar}>
|
||||
<PopoverTrigger asChild>
|
||||
<ShadButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
id="date"
|
||||
className="w-full bg-container text-border dark:text-border justify-between font-normal relative h-11 rounded-normal hover:bg-white"
|
||||
>
|
||||
<div className='text-foreground'>
|
||||
{formatDateForDisplay(formik.values.birthDate)}
|
||||
</div>
|
||||
<Calendar2 className='stroke-disabled2 dark:stroke-foreground' size={24} />
|
||||
<span className='absolute text-foreground text-xs top-0 -translate-y-2 right-2 bg-container px-2'>تاریخ تولد</span>
|
||||
</ShadButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0 z-20 box-shadow-normal rounded-lg" align="center">
|
||||
<Calendar
|
||||
className='bg-white dark:bg-background z-20 min-w-64 rounded-lg'
|
||||
mode="single"
|
||||
defaultMonth={formik.values.birthDate || undefined}
|
||||
selected={formik.values.birthDate || undefined}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
formik.setFieldValue('birthDate', date || undefined);
|
||||
setShowCalendar(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className='relative'>
|
||||
<ComboBox
|
||||
searchable={false}
|
||||
title=""
|
||||
options={[{ id: '0', title: 'آقا', label: 'آقا' }, { id: '1', title: 'خانوم', label: 'خانوم' }]}
|
||||
id={PORFILE_EDIT_PAGE_ELEMENT.INPUT_GENDER}
|
||||
selectedId={selectedGenderId}
|
||||
onSelectionChange={changeGender} />
|
||||
<span className='absolute text-xs top-0 -translate-y-2 right-2 bg-container px-2'>جنسیت</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full text-center mt-16 grid grid-cols-2 gap-4'>
|
||||
<Button type="submit" disabled={isUpdating || isUploading || !formik.isValid}>
|
||||
{isUpdating ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className='bg-neutral-200! dark:bg-neutral-600! text-disabled-text!'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileIndex
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/ProfileService";
|
||||
import { getToken } from "@/lib/api/func";
|
||||
|
||||
export const useGetProfile = () => {
|
||||
return useQuery({
|
||||
queryKey: ["profile"],
|
||||
queryFn: api.getProfile,
|
||||
retry: false,
|
||||
enabled: !!getToken(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProfile = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.updateProfile,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSingleUpload = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.singleUpload,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
// import { useProfile } from '@/hooks/auth/useProfile';
|
||||
// import { useAuthStore } from '@/zustand/authStore';
|
||||
import { ArrowLeft, Calendar2, CallCalling, Profile } from 'iconsax-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import { useGetProfile } from './hooks/userProfileData';
|
||||
|
||||
type Props = object
|
||||
|
||||
function ProfileIndex({ }: Props) {
|
||||
|
||||
const router = useRouter();
|
||||
const { data, isPending } = useGetProfile();
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const userData = data?.data;
|
||||
const fullName = userData ? `${userData.firstName} ${userData.lastName}` : '';
|
||||
const genderText = userData?.gender ? 'آقا' : 'خانوم';
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>پروفایل کاربری</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
|
||||
<div className="">
|
||||
<div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border">
|
||||
<Image
|
||||
src={userData?.avatarUrl ? userData.avatarUrl : '/assets/images/avatar.svg'}
|
||||
className='rounded-full'
|
||||
alt='user avatar'
|
||||
width={65}
|
||||
height={65}
|
||||
/>
|
||||
<div className='block'>
|
||||
<div className='font-medium text-base '>
|
||||
{isPending ? 'در حال بارگذاری...' : fullName || '-'}
|
||||
</div>
|
||||
{/* <div className='text-xs text-primary dark:text-neutral-200 mt-3 inline-flex items-center gap-2'>
|
||||
<Verify size={16} className='stroke-primary dark:stroke-foreground mb-0.5' />
|
||||
<span>
|
||||
شناسه کاربری:
|
||||
</span>
|
||||
<span>{isPending ? '...' : userData?.id || '-'}</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 mt-6">
|
||||
<div className='inline-flex items-center gap-2.5'>
|
||||
<CallCalling size={16} className='stroke-disabled2 dark:stroke-gray-300 mb-0.5' />
|
||||
<span className='text-sm2 text-disabled2 dark:text-gray-300'>شماره همراه:</span>
|
||||
<span className='text-sm2'>{isPending ? '...' : userData?.phone || '-'}</span>
|
||||
</div>
|
||||
<div className='inline-flex items-center gap-2.5'>
|
||||
<Calendar2 size={16} className='stroke-disabled2 dark:stroke-gray-300 mb-0.5' />
|
||||
<span className='text-sm2 text-disabled2 dark:text-gray-300'>تاریخ تولد:</span>
|
||||
<span className='text-sm2'>
|
||||
{isPending ? '...' : (userData?.birthDate ? formatDate(userData.birthDate) : '-')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='inline-flex items-center gap-2.5'>
|
||||
<Profile size={16} className='stroke-disabled2 dark:stroke-gray-300 mb-0.5' />
|
||||
<span className='text-sm2 text-disabled2 dark:text-gray-300'>جنسیت:</span>
|
||||
<span className='text-sm2'>{isPending ? '...' : genderText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full text-center mt-6'>
|
||||
<Link href={'profile/edit'}>
|
||||
<Button>ویرایش اطلاعات</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileIndex
|
||||
@@ -0,0 +1,33 @@
|
||||
import { api } from "@/config/axios";
|
||||
import {
|
||||
GetProfileResponse,
|
||||
SingleUploadResponse,
|
||||
UpdateProfileType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getProfile = async (): Promise<GetProfileResponse> => {
|
||||
const { data } = await api.get<GetProfileResponse>("/public/user/me");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateProfile = async (params: UpdateProfileType) => {
|
||||
const { data } = await api.patch("/public/user/update", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const singleUpload = async (
|
||||
file: File
|
||||
): Promise<SingleUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await api.post<SingleUploadResponse>(
|
||||
`/public/single-file`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import PasswordField from '@/components/input/PasswordField';
|
||||
import TabContainer from '@/components/tab/TabContainer'
|
||||
import { TabHeader } from '@/components/tab/TabHeader'
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import Accordion from '@/components/utils/Accordion';
|
||||
import Seperator from '@/components/utils/Seperator';
|
||||
import { SmartIcon } from '@/components/utils/SmartIcon';
|
||||
import { getI18nObject } from '@/lib/i18n';
|
||||
import { SettingsData } from '@/types/i18n/settings';
|
||||
import { Key, KeySquare, Notification1, ArrowLeft, TickCircle } from 'iconsax-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function UserSettingsIndex() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('settings');
|
||||
const data: SettingsData = getI18nObject(t, "data");
|
||||
const [statisticalParticipation, setStatisticalParticipation] = useState(true);
|
||||
|
||||
const onSwitched = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
console.log('Save changed setting?', target.id);
|
||||
}
|
||||
|
||||
const handleStatisticalToggle = (checked: boolean) => {
|
||||
setStatisticalParticipation(checked);
|
||||
console.log('Statistical participation:', checked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const notifsTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-title" className='py-4 flex-1 h-full'>
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className='text-sm leading-5'>{data.TabNotifications.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<div className='p-2 pt-0'>
|
||||
{data.TabNotifications.Accordions.map((accordion, i) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<Accordion
|
||||
title={accordion.Heading}
|
||||
group={'accordions'}
|
||||
icon={accordion.Icon}
|
||||
>
|
||||
<div className='grid gap-4 mb-3'>
|
||||
{accordion.Items.map((item, j) => {
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={item.HtmlName}
|
||||
className="flex w-full h-11 items-center justify-between gap-2 px-3 py-4 relative bg-container/29 rounded-xl border border-solid border-neutral-200 dark:border-neutral-600 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5 text-sm2">
|
||||
<SmartIcon icon={item.Icon} />
|
||||
{item.Label}
|
||||
</span>
|
||||
<Switch
|
||||
onClick={onSwitched}
|
||||
id={item.HtmlName}
|
||||
name={item.HtmlName}
|
||||
className="w-12 h-6 scale-[0.9]"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Accordion>
|
||||
<Seperator className='last:hidden' />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const passwordTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className='py-4'>
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className='text-sm leading-5'>{data.TabPassword.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className='grid gap-6 p-2'
|
||||
>
|
||||
{data.TabPassword.Inputs.map((input, i) => {
|
||||
return (
|
||||
<PasswordField
|
||||
labelText={input.Label}
|
||||
className='bg-container'
|
||||
key={i}
|
||||
htmlFor={input.HtmlName ?? ''}
|
||||
placeholder={input.Placeholder}
|
||||
autoComplete={input.AutoComplete}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
{data.TabPassword.Rules.map((rule, i) => {
|
||||
return (
|
||||
<section
|
||||
key={i}
|
||||
className='bg-background rounded-normal p-4 text-xs flex flex-col gap-2 font-light'
|
||||
>
|
||||
<h6 className='text-sm mb-2'>{rule.Heading}</h6>
|
||||
{rule.Items.map((item, i) => {
|
||||
return (
|
||||
<li key={i}>{item}</li>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button className='mt-4'>
|
||||
<div className='flex items-center justify-center gap-1'>
|
||||
<TickCircle className='stroke-white' size={20} />
|
||||
<span className='mt-0.5'>
|
||||
{data.TabPassword.ButtonSubmit}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const authenticatorTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className='py-4'>
|
||||
<div
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="pb-4">
|
||||
<h2 className='text-sm leading-5'>{data.TabAuthenticator.Heading}</h2>
|
||||
<p className='text-xs mt-3 text-disabled-text font-light'>{data.TabAuthenticator.Description}</p>
|
||||
</div>
|
||||
|
||||
<Seperator />
|
||||
|
||||
<div className='p-2'>
|
||||
<div className='mt-14 bg-blue-100 p-4 place-self-center rounded-full'>
|
||||
<Key size={32} className='stroke-blue-500' />
|
||||
</div>
|
||||
<p className='text-sm2 mt-4 font-light place-self-center'>
|
||||
{data.TabAuthenticator.Tip}
|
||||
</p>
|
||||
|
||||
<div className='place-self-center mt-2'>
|
||||
<Button className='mt-4 px-14 py-2'>
|
||||
<div className='flex items-center justify-center gap-1'>
|
||||
<TickCircle className='stroke-white' size={20} />
|
||||
<span className='mt-0.5'>
|
||||
{data.TabAuthenticator.ButtonSubmit}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.TabAuthenticator.Guides.map((guide, i) => {
|
||||
return (
|
||||
<section
|
||||
key={i}
|
||||
className='bg-background rounded-normal mt-14 p-4 text-xs flex flex-col gap-2 font-light'
|
||||
>
|
||||
<h6 className='text-sm'>{guide.Heading}</h6>
|
||||
<p className='text-disabled-text'>{guide.Description}</p>
|
||||
{guide.Items.map((item, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className='ml-2 mt-2 bg-blue-100 inline-block size-6 text-center pt-[5px] pr-[1px] text-blue-500 rounded-full'>
|
||||
{item.Mark}
|
||||
</div>
|
||||
<span>
|
||||
{item.Label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{guide.Notes.map((note, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='mt-4 p-3 text-yellow-800 rounded-lg bg-yellow-50 border border-solid border-yellow-200'
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col gap-6'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{data.Heading}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className='bg-container rounded-container shadow-container p-4'>
|
||||
<div className='flex justify-between items-center pb-4'>
|
||||
<h2 className='text-sm leading-5'>{data.StatisticalParticipation.Heading}</h2>
|
||||
<Switch
|
||||
checked={statisticalParticipation}
|
||||
onCheckedChange={handleStatisticalToggle}
|
||||
id={data.StatisticalParticipation.HtmlName}
|
||||
name={data.StatisticalParticipation.HtmlName}
|
||||
className="w-12 h-6 scale-[0.9]"
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-disabled-text font-light'>
|
||||
{data.StatisticalParticipation.Description}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserSettingsIndex
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
referrer: string | null;
|
||||
isActive: boolean;
|
||||
gender: boolean;
|
||||
wallet: number;
|
||||
points: number;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface GetProfileResponse {
|
||||
statusCode: number;
|
||||
success: boolean;
|
||||
data: UserProfile;
|
||||
}
|
||||
|
||||
export interface UpdateProfileType {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate?: string;
|
||||
marriageDate?: string;
|
||||
gender?: boolean;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export type UploadedFile = {
|
||||
key: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
};
|
||||
|
||||
export type SingleUploadResponseData = {
|
||||
file: UploadedFile;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SingleUploadResponse = BaseResponse<SingleUploadResponseData>;
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { ef, ue } from '@/lib/helpers/utfNumbers';
|
||||
import { ArrowLeft, ArrowRight, WalletMinus } from 'iconsax-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react'
|
||||
|
||||
type Props = object
|
||||
|
||||
function UserWalletIndex({ }: Props) {
|
||||
const router = useRouter();
|
||||
const [chargeValue, setChargeValue] = useState<string>('0');
|
||||
const chargeAction = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget)
|
||||
const value = Number(ue(String(data.get('chargeValue') ?? 0))) + 0;
|
||||
console.log("Charge for: ", value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>پروفایل کاربری</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={chargeAction}
|
||||
className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
|
||||
<div className="">
|
||||
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
|
||||
<div className='block'>
|
||||
<div className='font-medium text-sm flex items-center gap-2'>
|
||||
<WalletMinus className='size-5 stroke-foreground mb-0.5' />
|
||||
موجودی کیف پول
|
||||
</div>
|
||||
<div className='text-sm text-primary mt-3 inline-flex dark:text-neutral-200 items-center gap-2'>
|
||||
{ef('250.000 تومان')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 mt-6">
|
||||
<div className='inline-flex items-center mb-3 gap-2.5 text-sm2'>
|
||||
برای شارژ کیف پول خود میتوانید بر روی یکی از مبالغ زیر کلیک کنید و یا مبلغ دلخواه خود را وارد کنید
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('50000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('50,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('150000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('150,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('300000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('300,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<InputField
|
||||
className='bg-container mt-7'
|
||||
labelText='مبلغ دلخواه (تومان)'
|
||||
dir='ltr'
|
||||
inputMode='decimal'
|
||||
htmlFor='chargeValue'
|
||||
placeholder=''
|
||||
value={ef(chargeValue)}
|
||||
onChange={(e) => setChargeValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full text-center mt-6'>
|
||||
<Button
|
||||
type='submit'
|
||||
>
|
||||
شارژ کیف پول
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserWalletIndex
|
||||
Reference in New Issue
Block a user