copy base dmenu to dkala

This commit is contained in:
hamid zarghami
2026-02-07 15:31:22 +03:30
commit c9e37f6177
521 changed files with 57786 additions and 0 deletions
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getFavorites } from "../service/Service";
export const useGetFavorites = () => {
return useQuery({
queryKey: ["favorites"],
queryFn: getFavorites,
});
};
+107
View File
@@ -0,0 +1,107 @@
'use client'
import React, { useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft } from 'iconsax-react'
import Image from 'next/image'
import { useGetFavorites } from './hooks/useFavoriteData'
import MenuItem from '@/components/listview/MenuItem'
import MenuItemRenderer from '@/components/listview/MenuItemRenderer'
import VerticalScrollView from '@/components/listview/VerticalScrollView'
import type { Favorite } from './types/Types'
import type { Food } from '@/app/[name]/(Main)/types/Types'
function FavoritePage() {
const router = useRouter()
const { data: favoritesData, isLoading } = useGetFavorites()
const favorites = useMemo(() => {
return favoritesData?.data || []
}, [favoritesData?.data])
const foodItems = useMemo(() => {
return favorites.map((favorite: Favorite) => {
const favoriteFood = favorite.food
// تبدیل FavoriteFood به Food برای استفاده در MenuItem
const food: Food = {
id: favoriteFood.id,
createdAt: favoriteFood.createdAt,
updatedAt: favoriteFood.updatedAt,
deletedAt: favoriteFood.deletedAt,
restaurant: favoriteFood.restaurant as unknown as Food['restaurant'],
category: favoriteFood.category as unknown as Food['category'],
inventory: {} as Food['inventory'],
title: favoriteFood.title,
desc: favoriteFood.desc,
content: favoriteFood.content,
price: favoriteFood.price,
order: favoriteFood.order,
prepareTime: favoriteFood.prepareTime,
weekDays: favoriteFood.weekDays,
mealTypes: favoriteFood.mealTypes,
isActive: favoriteFood.isActive,
images: favoriteFood.images,
inPlaceServe: favoriteFood.inPlaceServe,
pickupServe: favoriteFood.pickupServe,
score: favoriteFood.score,
discount: favoriteFood.discount,
isSpecialOffer: favoriteFood.isSpecialOffer,
reviews: [],
favorites: [],
isFavorite: true,
name: favoriteFood.title,
description: favoriteFood.desc,
}
return food
})
}, [favorites])
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">
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
<p>در حال دریافت علاقهمندیها...</p>
</div>
) : foodItems.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-4 h-full'>
<Image
src='/assets/images/favorite.png'
alt='favorite'
width={120}
height={120}
className='object-contain'
/>
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
<p>شما هیچ مورد علاقهمندی ثبت نکردهاید</p>
<p className='text-xs'>
برای افزودن غذا به علاقهمندیها، به صفحه جزئیات غذا بروید
</p>
</div>
</div>
) : (
<VerticalScrollView className="overflow-y-auto h-full">
{foodItems.map((food) => (
<MenuItemRenderer key={food.id}>
<MenuItem food={food} />
</MenuItemRenderer>
))}
</VerticalScrollView>
)}
</div>
</div>
)
}
export default FavoritePage
@@ -0,0 +1,7 @@
import { api } from "@/config/axios";
import { FavoritesResponse } from "../types/Types";
export const getFavorites = async (): Promise<FavoritesResponse> => {
const { data } = await api.get<FavoritesResponse>("/public/foods/favorite");
return data;
};
@@ -0,0 +1,39 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
export type MealType = "breakfast" | "lunch" | "dinner" | "snack";
export interface FavoriteFood {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
category: string;
title: string;
desc: string;
content: string[];
price: number;
order: number | null;
prepareTime: number | null;
weekDays: number[];
mealTypes: MealType[];
isActive: boolean;
images: string[];
inPlaceServe: boolean;
pickupServe: boolean;
score: number;
discount: number;
isSpecialOffer: boolean;
}
export interface Favorite {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
user: string;
food: FavoriteFood;
}
export type FavoritesResponse = BaseResponse<Favorite[]>;
+16
View File
@@ -0,0 +1,16 @@
'use client';
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
import React from 'react'
function DialogsLayout({ children }: { children: React.ReactNode }) {
return (
<ClientSideWrapper>
<main className='h-full w-full bg-background pt-4 pb-6 px-6'>
{children}
</main>
</ClientSideWrapper>
)
}
export default DialogsLayout
@@ -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,
});
};
+102
View File
@@ -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