fixed order, image carousel
This commit is contained in:
@@ -25,7 +25,7 @@ export default function CartPage() {
|
||||
useState<DeliveryType>("SELECTED_DELIVERY");
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>("");
|
||||
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
|
||||
const [note, setNote] = useState<string>("");
|
||||
const [notes, setNote] = useState<string>("");
|
||||
const [phone, setPhone] = useState<string>("+993 ");
|
||||
const [name, setName] = useState<string>("");
|
||||
const [lastName, setLastName] = useState<string>("");
|
||||
@@ -117,7 +117,7 @@ export default function CartPage() {
|
||||
shipping_method: "standart",
|
||||
payment_type_id: paymentType.id,
|
||||
region: selectedRegion,
|
||||
note: note || undefined,
|
||||
notes: notes || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -171,7 +171,7 @@ export default function CartPage() {
|
||||
{Object.entries(itemsBySeller).map(
|
||||
([sellerId, { seller, items }]) => (
|
||||
<div key={sellerId} className="mb-6">
|
||||
{/* <p className="text-base font-semibold mb-3">{seller.name}</p> */}
|
||||
<p className="text-base font-semibold mb-3">{seller.name}</p>
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => {
|
||||
const price = parseFloat(
|
||||
@@ -237,7 +237,7 @@ export default function CartPage() {
|
||||
deliveryType={deliveryType}
|
||||
selectedRegion={selectedRegion}
|
||||
selectedProvince={selectedProvince}
|
||||
note={note}
|
||||
notes={notes}
|
||||
regionGroups={regionGroups}
|
||||
availableRegions={availableRegions}
|
||||
paymentTypes={paymentTypes}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import Logo from "@/public/logo.webp";
|
||||
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth";
|
||||
import { useLogin, useRegister, useVerifyToken } from "@/lib/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AuthDialogProps {
|
||||
@@ -20,19 +20,28 @@ interface AuthDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AuthStep = "phone" | "register" | "verify";
|
||||
|
||||
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
const [phone, setPhone] = useState("+993 ");
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [authStep, setAuthStep] = useState<AuthStep>("phone");
|
||||
const [isNewUser, setIsNewUser] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const { mutate: login, isPending: isLoginLoading } = useLogin();
|
||||
const { mutate: register, isPending: isRegisterLoading } = useRegister();
|
||||
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
|
||||
|
||||
const resetDialog = useCallback(() => {
|
||||
setOtpSent(false);
|
||||
setAuthStep("phone");
|
||||
setPhone("+993 ");
|
||||
setName("");
|
||||
setAddress("");
|
||||
setOtp("");
|
||||
setIsNewUser(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -50,7 +59,6 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
}
|
||||
|
||||
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
|
||||
|
||||
const limitedDigits = digitsOnly.substring(0, 8);
|
||||
|
||||
let formattedPhone = prefix;
|
||||
@@ -70,7 +78,7 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
return phoneDigits.length === 8;
|
||||
};
|
||||
|
||||
const handleSendOtp = useCallback(() => {
|
||||
const handleCheckPhone = useCallback(() => {
|
||||
if (!isPhoneValid()) {
|
||||
toast.error(t("invalid_phone"));
|
||||
return;
|
||||
@@ -78,21 +86,71 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
|
||||
const phoneNumber = formatPhoneForBackend(phone);
|
||||
|
||||
// Try to login first to check if user exists
|
||||
login(
|
||||
{ phone_number: parseInt(phoneNumber, 10) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("code_sent"));
|
||||
setOtpSent(true);
|
||||
setIsNewUser(false);
|
||||
setAuthStep("verify");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Check if error indicates user not found
|
||||
const errorMessage = error?.response?.data?.message || "";
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
if (
|
||||
lowerMessage.includes("tapylmady") ||
|
||||
lowerMessage.includes("not found") ||
|
||||
lowerMessage.includes("does not exist") ||
|
||||
lowerMessage.includes("not exist") ||
|
||||
error?.response?.status === 404
|
||||
) {
|
||||
// User doesn't exist, show registration form
|
||||
setIsNewUser(true);
|
||||
setAuthStep("register");
|
||||
} else {
|
||||
toast.error(errorMessage || t("error_occurred"));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [phone, login, t]);
|
||||
|
||||
const handleRegister = useCallback(() => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("name_required") || "Adyňyzy giriziň");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!address.trim()) {
|
||||
toast.error(t("address_required") || "Salgyňyzy giriziň");
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneNumber = formatPhoneForBackend(phone);
|
||||
|
||||
register(
|
||||
{
|
||||
phone_number: phoneNumber,
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("registration_success") || "Hasaba alyndy! Kody giriziň",
|
||||
);
|
||||
setAuthStep("verify");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.response?.data?.message || t("error_occurred"));
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [phone, login, t]);
|
||||
}, [phone, name, address, register, t]);
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
const handleVerify = useCallback(() => {
|
||||
if (otp.length < 4) {
|
||||
toast.error(t("invalid_code"));
|
||||
return;
|
||||
@@ -114,7 +172,7 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.response?.data?.message || t("wrong_code"));
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [otp, phone, verifyToken, resetDialog, t]);
|
||||
|
||||
@@ -124,9 +182,35 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const getTitle = () => {
|
||||
switch (authStep) {
|
||||
case "phone":
|
||||
return t("common.enterPhone");
|
||||
case "register":
|
||||
return t("register_title") || "Hasaba alyş";
|
||||
case "verify":
|
||||
return t("verify_title") || "Kody giriziň";
|
||||
default:
|
||||
return t("common.enterPhone");
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
switch (authStep) {
|
||||
case "phone":
|
||||
return t("common.weWillSendCode");
|
||||
case "register":
|
||||
return t("register_description") || "Maglumatyňyzy dolduryň";
|
||||
case "verify":
|
||||
return t("verify_description") || "Telefonyňyza gelen kody giriziň";
|
||||
default:
|
||||
return t("common.weWillSendCode");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -137,14 +221,15 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-2xl text-center">
|
||||
{t("common.enterPhone")}
|
||||
{getTitle()}
|
||||
</DialogTitle>
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
{t("common.weWillSendCode")}
|
||||
{getDescription()}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Phone Input - Always shown but disabled after first step */}
|
||||
<div>
|
||||
<Input
|
||||
type="tel"
|
||||
@@ -152,13 +237,40 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
value={phone}
|
||||
onChange={handlePhoneChange}
|
||||
className="h-12 rounded-xl"
|
||||
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
|
||||
disabled={otpSent || isLoginLoading}
|
||||
onKeyDown={(e) => handleKeyPress(e, handleCheckPhone)}
|
||||
disabled={authStep !== "phone" || isLoginLoading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
|
||||
</div>
|
||||
|
||||
{otpSent && (
|
||||
{/* Registration Form */}
|
||||
{authStep === "register" && (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("name_placeholder") || "Adyňyz"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
disabled={isRegisterLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
t("address_placeholder") || "Salgyňyz (mysal: Tejen)"
|
||||
}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
onKeyDown={(e) => handleKeyPress(e, handleRegister)}
|
||||
disabled={isRegisterLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Verification Code Input */}
|
||||
{authStep === "verify" && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("common.code")}
|
||||
@@ -167,29 +279,64 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||
setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))
|
||||
}
|
||||
className="h-12 rounded-xl"
|
||||
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
|
||||
onKeyDown={(e) => handleKeyPress(e, handleVerify)}
|
||||
disabled={isVerifyLoading}
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
onClick={otpSent ? handleLogin : handleSendOtp}
|
||||
onClick={
|
||||
authStep === "phone"
|
||||
? handleCheckPhone
|
||||
: authStep === "register"
|
||||
? handleRegister
|
||||
: handleVerify
|
||||
}
|
||||
className="w-full cursor-pointer h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
|
||||
size="lg"
|
||||
disabled={
|
||||
isLoginLoading || isVerifyLoading || (!otpSent && !isPhoneValid())
|
||||
isLoginLoading ||
|
||||
isRegisterLoading ||
|
||||
isVerifyLoading ||
|
||||
(authStep === "phone" && !isPhoneValid())
|
||||
}
|
||||
>
|
||||
{isLoginLoading
|
||||
? t("sending")
|
||||
: isVerifyLoading
|
||||
? t("verifying")
|
||||
: otpSent
|
||||
? t("verify")
|
||||
: t("common.send")}
|
||||
? t("checking") || "Barlanýar..."
|
||||
: isRegisterLoading
|
||||
? t("registering") || "Hasaba alynýar..."
|
||||
: isVerifyLoading
|
||||
? t("verifying")
|
||||
: authStep === "phone"
|
||||
? t("common.send")
|
||||
: authStep === "register"
|
||||
? t("register_button") || "Hasaba al"
|
||||
: t("verify")}
|
||||
</Button>
|
||||
|
||||
{/* Back Button for Register and Verify steps */}
|
||||
{authStep !== "phone" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (authStep === "register") {
|
||||
setAuthStep("phone");
|
||||
setName("");
|
||||
setAddress("");
|
||||
} else if (authStep === "verify") {
|
||||
setAuthStep(isNewUser ? "register" : "phone");
|
||||
setOtp("");
|
||||
}
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={isLoginLoading || isRegisterLoading || isVerifyLoading}
|
||||
>
|
||||
{t("back") || "Yza"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -32,10 +32,12 @@ export default function AuthWrapper({
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
// Only fetch guest token once on initial mount if no token exists
|
||||
if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
|
||||
getGuestToken();
|
||||
}
|
||||
}, [isLoading, getGuestToken, isGettingGuestToken]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]); // Only run when isLoading changes (initial mount)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isGettingGuestToken) return;
|
||||
|
||||
@@ -40,7 +40,7 @@ interface OrderSummaryProps {
|
||||
deliveryType: DeliveryType;
|
||||
selectedRegion: string;
|
||||
selectedProvince: number | null;
|
||||
note: string;
|
||||
notes: string;
|
||||
regionGroups: Record<string, Province[]>;
|
||||
availableRegions: string[];
|
||||
paymentTypes: PaymentType[];
|
||||
@@ -54,7 +54,7 @@ interface OrderSummaryProps {
|
||||
onDeliveryTypeChange: (type: DeliveryType) => void;
|
||||
onRegionChange: (regionCode: string) => void;
|
||||
onProvinceChange: (provinceId: number) => void;
|
||||
onNoteChange: (note: string) => void;
|
||||
onNoteChange: (notes: string) => void;
|
||||
onCompleteOrder: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default function OrderSummary({
|
||||
deliveryType,
|
||||
selectedRegion,
|
||||
selectedProvince,
|
||||
note,
|
||||
notes,
|
||||
regionGroups,
|
||||
availableRegions,
|
||||
paymentTypes,
|
||||
@@ -303,7 +303,7 @@ export default function OrderSummary({
|
||||
<div className="mb-6">
|
||||
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
|
||||
<Textarea
|
||||
value={note}
|
||||
value={notes}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
className="rounded-xl resize-none"
|
||||
rows={3}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useAddToCart() {
|
||||
|
||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||
const idx = updated.data.findIndex(
|
||||
(item: any) => item.product?.id === pendingId
|
||||
(item: any) => item.product?.id === pendingId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
updated.data[idx] = {
|
||||
@@ -162,7 +162,7 @@ export function useAddToCart() {
|
||||
});
|
||||
|
||||
const existingItem = updated.data.find(
|
||||
(item: any) => item.product?.id === productId
|
||||
(item: any) => item.product?.id === productId,
|
||||
);
|
||||
|
||||
if (existingItem) {
|
||||
@@ -172,7 +172,7 @@ export function useAddToCart() {
|
||||
...item,
|
||||
product_quantity: item.product_quantity + quantity,
|
||||
}
|
||||
: item
|
||||
: item,
|
||||
);
|
||||
} else {
|
||||
updated.data = [
|
||||
@@ -185,7 +185,7 @@ export function useAddToCart() {
|
||||
}
|
||||
|
||||
const finalItem = updated.data.find(
|
||||
(item: any) => item.product?.id === productId
|
||||
(item: any) => item.product?.id === productId,
|
||||
);
|
||||
if (finalItem) {
|
||||
pendingUpdates.set(productId, finalItem.product_quantity);
|
||||
@@ -261,7 +261,7 @@ export function useRemoveFromCart() {
|
||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||
if (pendingId !== productId) {
|
||||
const idx = updated.data.findIndex(
|
||||
(item: any) => item.product?.id === pendingId
|
||||
(item: any) => item.product?.id === pendingId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
updated.data[idx] = {
|
||||
@@ -273,7 +273,7 @@ export function useRemoveFromCart() {
|
||||
});
|
||||
|
||||
updated.data = updated.data.filter(
|
||||
(item: any) => item.product?.id !== productId
|
||||
(item: any) => item.product?.id !== productId,
|
||||
);
|
||||
|
||||
pendingUpdates.delete(productId);
|
||||
@@ -413,7 +413,7 @@ export function useUpdateCartItemQuantity() {
|
||||
|
||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||
const idx = updated.data.findIndex(
|
||||
(item: any) => item.product?.id === pendingId
|
||||
(item: any) => item.product?.id === pendingId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
updated.data[idx] = {
|
||||
@@ -426,7 +426,7 @@ export function useUpdateCartItemQuantity() {
|
||||
updated.data = updated.data.map((item: any) =>
|
||||
item.product?.id === productId
|
||||
? { ...item, product_quantity: quantity }
|
||||
: item
|
||||
: item,
|
||||
);
|
||||
|
||||
pendingUpdates.set(productId, quantity);
|
||||
@@ -470,14 +470,16 @@ export function useCreateOrder() {
|
||||
delivery_time?: string;
|
||||
delivery_at?: string;
|
||||
region: string;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
}) => {
|
||||
const response = await apiClient.post("/orders", payload);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data && data.payment_url) {
|
||||
window.open(data.payment_url, '_blank')?.focus();
|
||||
// Handle payment URL - check both data.payment_url and data.data.payment_url
|
||||
const paymentUrl = data?.data?.payment_url || data?.payment_url;
|
||||
if (paymentUrl) {
|
||||
window.open(paymentUrl, "_blank")?.focus();
|
||||
}
|
||||
|
||||
pendingUpdates.clear();
|
||||
@@ -491,7 +493,7 @@ export function useCreateOrder() {
|
||||
onError: (error: any) => {
|
||||
console.error(
|
||||
"Create order error:",
|
||||
error.response?.data?.message || error.message
|
||||
error.response?.data?.message || error.message,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -502,7 +504,7 @@ export function useCartCount() {
|
||||
return (
|
||||
data?.data?.reduce(
|
||||
(sum: number, item: any) => sum + (item.product_quantity || 0),
|
||||
0
|
||||
0,
|
||||
) || 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { X, ZoomIn, ZoomOut, RotateCw, RotateCcw, Maximize2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
RotateCcw,
|
||||
Maximize2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
interface ProductImageGalleryProps {
|
||||
images: string[];
|
||||
@@ -20,10 +29,14 @@ export function ProductImageGallery({
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const t = useTranslations();
|
||||
const t = useTranslations();
|
||||
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const modalImageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedImage(0);
|
||||
}, [images]);
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length <= 1 || isModalOpen) return;
|
||||
|
||||
@@ -61,7 +74,7 @@ export function ProductImageGallery({
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[images.length, isModalOpen]
|
||||
[images.length, isModalOpen],
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
@@ -99,8 +112,8 @@ export function ProductImageGallery({
|
||||
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsDragging(true);
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
||||
setDragStart({
|
||||
x: clientX - position.x,
|
||||
y: clientY - position.y,
|
||||
@@ -110,8 +123,8 @@ export function ProductImageGallery({
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (isDragging && zoom > 1) {
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
||||
setPosition({
|
||||
x: clientX - dragStart.x,
|
||||
y: clientY - dragStart.y,
|
||||
@@ -143,18 +156,19 @@ export function ProductImageGallery({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="contents max-w-2xl">
|
||||
<div className="w-full lg:flex-1 max-w-2xl">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative aspect-square w-full rounded-xl md:rounded-2xl overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100 cursor-pointer group shadow-sm hover:shadow-md transition-all"
|
||||
onClick={openModal}
|
||||
>
|
||||
{images.length > 0 ? (
|
||||
{images.length > 0 && images[selectedImage] ? (
|
||||
<>
|
||||
<Image
|
||||
src={images[selectedImage]}
|
||||
alt={productName}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-contain transition-transform group-hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
@@ -173,25 +187,25 @@ export function ProductImageGallery({
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleImageSelect(index)}
|
||||
className={`relative w-16 h-16 shrink-0 rounded cursor-pointer overflow-hidden border-2 transition-all ${
|
||||
selectedImage === index
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt={`${productName} ${index + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleImageSelect(index)}
|
||||
className={`relative w-16 h-16 shrink-0 rounded cursor-pointer overflow-hidden border-2 transition-all ${
|
||||
selectedImage === index
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt={`${productName} ${index + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +218,9 @@ export function ProductImageGallery({
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0 flex-1">
|
||||
<div className="w-1 h-4 md:h-6 bg-blue-500 rounded-full shrink-0" />
|
||||
<span className="text-white font-medium text-sm md:text-base truncate">{productName}</span>
|
||||
<span className="text-white font-medium text-sm md:text-base truncate">
|
||||
{productName}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
@@ -241,12 +257,17 @@ export function ProductImageGallery({
|
||||
onTouchMove={handleMouseMove}
|
||||
onTouchEnd={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default" }}
|
||||
style={{
|
||||
cursor:
|
||||
zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom}) rotate(${rotation}deg)`,
|
||||
transition: isDragging ? "none" : "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transition: isDragging
|
||||
? "none"
|
||||
: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
className="relative w-[90vw] h-[60vh] md:w-[75vw] md:h-[70vh]"
|
||||
>
|
||||
@@ -314,7 +335,9 @@ export function ProductImageGallery({
|
||||
<ZoomOut className="w-4 h-4 text-white mx-auto" />
|
||||
</button>
|
||||
<div className="px-2 py-1 bg-white/10 rounded text-center min-w-[50px]">
|
||||
<span className="text-white text-xs font-medium">{Math.round(zoom * 100)}%</span>
|
||||
<span className="text-white text-xs font-medium">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
@@ -372,7 +395,9 @@ export function ProductImageGallery({
|
||||
<ZoomOut className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<div className="px-3 py-1 bg-white/10 rounded min-w-[60px] text-center">
|
||||
<span className="text-white text-sm font-medium">{Math.round(zoom * 100)}%</span>
|
||||
<span className="text-white text-sm font-medium">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
refetch: refetchProduct,
|
||||
} = useProductsBySlug(slug);
|
||||
const { isFavorite, isLoading: isFavLoading } = useIsFavorite(
|
||||
product?.id || 0
|
||||
product?.id || 0,
|
||||
);
|
||||
const cartOptions = useMemo(
|
||||
() => ({
|
||||
@@ -80,7 +80,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
}),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const { mutate: toggleFavoriteMutation } = useToggleFavorite();
|
||||
const {
|
||||
@@ -100,7 +100,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
|
||||
const cartItem = useMemo(() => {
|
||||
const item = cartData?.data?.find(
|
||||
(item: any) => item.product?.id === product?.id
|
||||
(item: any) => item.product?.id === product?.id,
|
||||
);
|
||||
|
||||
return item;
|
||||
@@ -109,19 +109,26 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
const isInCart = !!cartItem;
|
||||
const availableStock = product?.stock || 0;
|
||||
|
||||
const imageUrls = useMemo(
|
||||
() =>
|
||||
product?.media?.map(
|
||||
(m) => m.images_800x800 || m.images_720x720 || m.thumbnail
|
||||
) || [],
|
||||
[product]
|
||||
);
|
||||
const imageUrls = useMemo(() => {
|
||||
|
||||
if (!product?.media || product.media.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urls = product.media
|
||||
.map((m) => {
|
||||
const url = m.images_800x800 || m.images_720x720 || m.images_400x400 || m.thumbnail;
|
||||
return url;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return urls;
|
||||
}, [product]);
|
||||
|
||||
const reviews = useMemo(() => product?.reviews_resources || [], [product]);
|
||||
const averageRating = useMemo(
|
||||
() =>
|
||||
product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0,
|
||||
[product]
|
||||
[product],
|
||||
);
|
||||
|
||||
const transformedRelatedProducts = useMemo(() => {
|
||||
@@ -173,13 +180,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
PENDING_PRODUCT_UPDATES_KEY,
|
||||
JSON.stringify(pending)
|
||||
JSON.stringify(pending),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save pending update:", error);
|
||||
}
|
||||
},
|
||||
[product?.id]
|
||||
[product?.id],
|
||||
);
|
||||
|
||||
const clearPendingUpdate = useCallback(() => {
|
||||
@@ -194,7 +201,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
} else {
|
||||
sessionStorage.setItem(
|
||||
PENDING_PRODUCT_UPDATES_KEY,
|
||||
JSON.stringify(pending)
|
||||
JSON.stringify(pending),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +232,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
syncToServerRef.current?.(quantity);
|
||||
}, delay);
|
||||
},
|
||||
[t]
|
||||
[t],
|
||||
);
|
||||
|
||||
retrySyncRef.current = retrySync;
|
||||
@@ -288,7 +295,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
cartItem,
|
||||
clearPendingUpdate,
|
||||
t,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
syncToServerRef.current = syncToServer;
|
||||
@@ -401,7 +408,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
toast.success(
|
||||
data?.wasAdded
|
||||
? t("added_to_favorites")
|
||||
: t("removed_from_favorites")
|
||||
: t("removed_from_favorites"),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -409,10 +416,10 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
description: "Try again later",
|
||||
});
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[product?.id, isFavorite, toggleFavoriteMutation, t]
|
||||
[product?.id, isFavorite, toggleFavoriteMutation, t],
|
||||
);
|
||||
|
||||
const handleSubmitReview = useCallback(
|
||||
@@ -442,7 +449,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[product?.id, submitReviewMutation, refetchProduct, t]
|
||||
[product?.id, submitReviewMutation, refetchProduct, t],
|
||||
);
|
||||
|
||||
const loadingSkeleton = useMemo(
|
||||
@@ -464,7 +471,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
if (productLoading) return loadingSkeleton;
|
||||
|
||||
@@ -190,8 +190,19 @@
|
||||
"enter_email": "Введите email",
|
||||
"uploadPatent": "Загрузить патент",
|
||||
"outOfStock": "Нет в наличии",
|
||||
"requiredField": "Обязательное поле",
|
||||
"fileRequired": "Файл загрузить"
|
||||
"requiredField": "Обязательное поле",
|
||||
"fileRequired": "Файл загрузить",
|
||||
"register_title": "Регистрация",
|
||||
"register_description": "Заполните ваши данные",
|
||||
"register_button": "Зарегистрироваться",
|
||||
"name_required": "Введите ваше имя",
|
||||
"address_required": "Введите ваш адрес",
|
||||
"name_placeholder": "Ваше имя",
|
||||
"address_placeholder": "Ваш адрес (например: Теджен)",
|
||||
"checking": "Проверка...",
|
||||
"registering": "Регистрация...",
|
||||
"registration_success": "Регистрация успешна! Введите код",
|
||||
"verify_title": "Введите код",
|
||||
"verify_description": "Введите код, отправленный на ваш телефон",
|
||||
"back": "Назад"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -191,5 +191,18 @@
|
||||
"uploadPatent": "Patent goş",
|
||||
"outOfStock": "Ammarda ýok",
|
||||
"requiredField": "Zerur maglumat",
|
||||
"fileRequired": "Fayl goş"
|
||||
"fileRequired": "Fayl goş",
|
||||
"register_title": "Hasaba alyş",
|
||||
"register_description": "Maglumatyňyzy dolduryň",
|
||||
"register_button": "Hasaba al",
|
||||
"name_required": "Adyňyzy giriziň",
|
||||
"address_required": "Salgyňyzy giriziň",
|
||||
"name_placeholder": "Adyňyz",
|
||||
"address_placeholder": "Salgyňyz (mysal: Tejen)",
|
||||
"checking": "Barlanýar...",
|
||||
"registering": "Hasaba alynýar...",
|
||||
"registration_success": "Hasaba alyndy! Kody giriziň",
|
||||
"verify_title": "Kody giriziň",
|
||||
"verify_description": "Telefonyňyza gelen kody giriziň",
|
||||
"back": "Yza"
|
||||
}
|
||||
|
||||
74
lib/api.ts
74
lib/api.ts
@@ -1,6 +1,10 @@
|
||||
// lib/api.ts
|
||||
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
} from "axios";
|
||||
import TokenStorage from "./tokenStorage";
|
||||
|
||||
const localeToApiLang = (locale: string): string => {
|
||||
@@ -42,27 +46,32 @@ class APIClient {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add language parameter
|
||||
let lang = "tm";
|
||||
// Add language parameter (except for POST requests to /orders)
|
||||
const url = config.url || "";
|
||||
const isOrderPost =
|
||||
config.method?.toLowerCase() === "post" && url.includes("/orders");
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if ((window as any).i18n?.language) {
|
||||
lang = localeToApiLang((window as any).i18n.language);
|
||||
} else {
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale === "tm" || pathLocale === "ru") {
|
||||
lang = localeToApiLang(pathLocale);
|
||||
if (!isOrderPost) {
|
||||
let lang = "tm";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if ((window as any).i18n?.language) {
|
||||
lang = localeToApiLang((window as any).i18n.language);
|
||||
} else {
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale === "tm" || pathLocale === "ru") {
|
||||
lang = localeToApiLang(pathLocale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = config.url || "";
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
config.url = `${url}${separator}lang=${lang}`;
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
config.url = `${url}${separator}lang=${lang}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
@@ -92,10 +101,11 @@ class APIClient {
|
||||
"Content-Type": "application/json",
|
||||
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data;
|
||||
const newToken =
|
||||
guestTokenResponse.data?.token || guestTokenResponse.data?.data;
|
||||
|
||||
if (newToken) {
|
||||
TokenStorage.setGuestToken(newToken);
|
||||
@@ -131,7 +141,7 @@ class APIClient {
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,23 +156,41 @@ class APIClient {
|
||||
this.failedQueue = [];
|
||||
}
|
||||
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
get<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, config);
|
||||
}
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.put<T>(url, data, config);
|
||||
}
|
||||
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
patch<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.patch<T>(url, data, config);
|
||||
}
|
||||
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
delete<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.delete<T>(url, config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ interface LoginCredentials {
|
||||
|
||||
interface RegisterData {
|
||||
phone_number: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface VerifyTokenData {
|
||||
@@ -52,31 +52,31 @@ function extractToken(data: AuthResponse): string {
|
||||
|
||||
function handleAuthError(error: unknown): AuthError {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return {
|
||||
message: "Request timeout - server not responding",
|
||||
code: "TIMEOUT",
|
||||
statusCode: 408
|
||||
statusCode: 408,
|
||||
};
|
||||
}
|
||||
if (error.response) {
|
||||
return {
|
||||
message: error.response.data?.message || "Authentication failed",
|
||||
code: error.response.data?.code || "AUTH_ERROR",
|
||||
statusCode: error.response.status
|
||||
statusCode: error.response.status,
|
||||
};
|
||||
}
|
||||
if (error.request) {
|
||||
return {
|
||||
message: "Network error - cannot reach server",
|
||||
code: "NETWORK_ERROR",
|
||||
statusCode: 0
|
||||
statusCode: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
code: "UNKNOWN_ERROR"
|
||||
code: "UNKNOWN_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export function useGetGuestToken() {
|
||||
{},
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeout: 10000
|
||||
}
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
clearTimeout(timeoutId);
|
||||
return extractToken(response.data);
|
||||
@@ -123,7 +123,7 @@ export function useGetGuestToken() {
|
||||
console.error("[Guest Token] Failed:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
statusCode: error.statusCode
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
@@ -147,7 +147,7 @@ export function useLogin() {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
"/auth/login",
|
||||
credentials,
|
||||
{ timeout: 15000 }
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
return extractToken(response.data);
|
||||
},
|
||||
@@ -172,7 +172,7 @@ export function useRegister() {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
"/auth/register",
|
||||
userData,
|
||||
{ timeout: 15000 }
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
return extractToken(response.data);
|
||||
},
|
||||
@@ -197,7 +197,7 @@ export function useVerifyToken() {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
"/auth/verify",
|
||||
verifyData,
|
||||
{ timeout: 15000 }
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
return extractToken(response.data);
|
||||
},
|
||||
@@ -222,7 +222,9 @@ export function useLogout() {
|
||||
try {
|
||||
await apiClient.post("/auth/logout", {}, { timeout: 5000 });
|
||||
} catch (error) {
|
||||
console.warn("[Logout] Server call failed, clearing local state anyway");
|
||||
console.warn(
|
||||
"[Logout] Server call failed, clearing local state anyway",
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -235,7 +235,7 @@ export interface CreateOrderRequest {
|
||||
delivery_time?: string;
|
||||
delivery_at?: string;
|
||||
region: string;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderPayload {
|
||||
@@ -247,7 +247,7 @@ export interface CreateOrderPayload {
|
||||
delivery_time?: string;
|
||||
delivery_at?: string;
|
||||
region: string;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Pagination Types
|
||||
|
||||
Reference in New Issue
Block a user