Compare commits
8 Commits
bcd29eb03e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab9eab656 | ||
|
|
c13a4655bf | ||
|
|
db7889fb7a | ||
|
|
1b378ccf79 | ||
|
|
bf5980e3b3 | ||
|
|
b546deeac0 | ||
| a1b766fb3b | |||
| a51a84409f |
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# API
|
||||||
|
NEXT_PUBLIC_API_URL=http://shop.post.tm:8080
|
||||||
|
NEXT_PUBLIC_API_TOKEN=hello-bad-mf-s
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
NODE_ENV=development
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -1,36 +1,15 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
# Production
|
||||||
# or
|
npm run build
|
||||||
pnpm dev
|
|
||||||
# or
|
# PM2
|
||||||
bun dev
|
pm2 start "npm run start" --name postshop-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ import {
|
|||||||
useCreateOrder,
|
useCreateOrder,
|
||||||
useRegions,
|
useRegions,
|
||||||
usePaymentTypes,
|
usePaymentTypes,
|
||||||
|
useOrderDeliveries,
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { DeliveryType, PaymentType } from "@/lib/types/api";
|
import type { PaymentType, OrderDelivery } from "@/lib/types/api";
|
||||||
import EmptyCart from "@/features/cart/components/EmptyCart";
|
import EmptyCart from "@/features/cart/components/EmptyCart";
|
||||||
import ErrorPage from "@/components/ErrorPage";
|
import ErrorPage from "@/components/ErrorPage";
|
||||||
|
|
||||||
export default function CartPage() {
|
export default function CartPage() {
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
|
const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
|
||||||
const [deliveryType, setDeliveryType] =
|
const [selectedOrderDelivery, setSelectedOrderDelivery] = useState<OrderDelivery | null>(null);
|
||||||
useState<DeliveryType>("SELECTED_DELIVERY");
|
|
||||||
const [selectedRegion, setSelectedRegion] = useState<string>("");
|
const [selectedRegion, setSelectedRegion] = useState<string>("");
|
||||||
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
|
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
|
||||||
const [note, setNote] = useState<string>("");
|
const [notes, setNote] = useState<string>("");
|
||||||
const [phone, setPhone] = useState<string>("+993 ");
|
const [phone, setPhone] = useState<string>("+993 ");
|
||||||
const [name, setName] = useState<string>("");
|
const [name, setName] = useState<string>("");
|
||||||
const [lastName, setLastName] = useState<string>("");
|
const [lastName, setLastName] = useState<string>("");
|
||||||
@@ -36,15 +36,22 @@ export default function CartPage() {
|
|||||||
const { data: provinces = [], isLoading: provincesLoading } = useRegions();
|
const { data: provinces = [], isLoading: provincesLoading } = useRegions();
|
||||||
const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
|
const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
|
||||||
usePaymentTypes();
|
usePaymentTypes();
|
||||||
|
const { data: orderDeliveries = [], isLoading: deliveriesLoading } = useOrderDeliveries();
|
||||||
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
|
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
|
||||||
|
|
||||||
const cartItems = cartResponse?.data || [];
|
const cartItems = cartResponse?.data || [];
|
||||||
const isLoading = cartLoading || provincesLoading || paymentTypesLoading;
|
const isLoading = cartLoading || provincesLoading || paymentTypesLoading || deliveriesLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRegionChange = (region: string) => {
|
||||||
|
setSelectedRegion(region);
|
||||||
|
setSelectedProvince(null);
|
||||||
|
setSelectedOrderDelivery(null);
|
||||||
|
};
|
||||||
|
|
||||||
const regionGroups = useMemo(() => {
|
const regionGroups = useMemo(() => {
|
||||||
return provinces.reduce((acc, province) => {
|
return provinces.reduce((acc, province) => {
|
||||||
if (!acc[province.region]) {
|
if (!acc[province.region]) {
|
||||||
@@ -77,25 +84,25 @@ export default function CartPage() {
|
|||||||
}, [cartItems]);
|
}, [cartItems]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return cartItems.reduce((sum, item) => {
|
const productsTotal = cartItems.reduce((sum, item) => {
|
||||||
const price = parseFloat(item.product.price_amount || "0");
|
const price = parseFloat(item.product.price_amount || "0");
|
||||||
return sum + price * item.product_quantity;
|
return sum + price * item.product_quantity;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
return productsTotal;
|
||||||
}, [cartItems]);
|
}, [cartItems]);
|
||||||
|
|
||||||
const handleDeliveryTypeChange = (type: DeliveryType) => {
|
const finalTotal = useMemo(() => {
|
||||||
setDeliveryType(type);
|
const shippingPrice = selectedOrderDelivery?.price || 0;
|
||||||
setSelectedProvince(null);
|
return totalAmount + shippingPrice;
|
||||||
};
|
}, [totalAmount, selectedOrderDelivery]);
|
||||||
|
|
||||||
|
|
||||||
const formatPhoneForBackend = (phoneNumber: string): string => {
|
const formatPhoneForBackend = (phoneNumber: string): string => {
|
||||||
|
|
||||||
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
|
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteOrder = () => {
|
const handleCompleteOrder = () => {
|
||||||
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) {
|
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name || !selectedOrderDelivery) {
|
||||||
console.warn("Missing required fields for order");
|
console.warn("Missing required fields for order");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,12 +119,13 @@ export default function CartPage() {
|
|||||||
createOrder(
|
createOrder(
|
||||||
{
|
{
|
||||||
customer_name: `${name} ${lastName}`.trim(),
|
customer_name: `${name} ${lastName}`.trim(),
|
||||||
customer_phone: parseInt(phoneDigits, 10),
|
customer_phone: phoneDigits,
|
||||||
customer_address: selectedProvinceData.name,
|
customer_address: selectedProvinceData.name,
|
||||||
shipping_method: "standart",
|
shipping_method: selectedOrderDelivery.name,
|
||||||
|
shipping_price: selectedOrderDelivery.price,
|
||||||
payment_type_id: paymentType.id,
|
payment_type_id: paymentType.id,
|
||||||
region: selectedRegion,
|
region: selectedRegion,
|
||||||
note: note || undefined,
|
notes: notes || undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -226,18 +234,28 @@ export default function CartPage() {
|
|||||||
title: t("products"),
|
title: t("products"),
|
||||||
value: `${totalAmount.toFixed(2)} TMT`,
|
value: `${totalAmount.toFixed(2)} TMT`,
|
||||||
},
|
},
|
||||||
|
...(selectedOrderDelivery
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("shipping_method"),
|
||||||
|
value: `${selectedOrderDelivery.price.toFixed(2)} TMT`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
footer: {
|
footer: {
|
||||||
title: t("total_price"),
|
title: t("total_price"),
|
||||||
value: `${totalAmount.toFixed(2)} TMT`,
|
value: `${finalTotal.toFixed(2)} TMT`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
paymentType={paymentType}
|
paymentType={paymentType}
|
||||||
deliveryType={deliveryType}
|
orderDeliveries={orderDeliveries}
|
||||||
|
selectedOrderDelivery={selectedOrderDelivery}
|
||||||
|
onOrderDeliveryChange={setSelectedOrderDelivery}
|
||||||
selectedRegion={selectedRegion}
|
selectedRegion={selectedRegion}
|
||||||
selectedProvince={selectedProvince}
|
selectedProvince={selectedProvince}
|
||||||
note={note}
|
notes={notes}
|
||||||
regionGroups={regionGroups}
|
regionGroups={regionGroups}
|
||||||
availableRegions={availableRegions}
|
availableRegions={availableRegions}
|
||||||
paymentTypes={paymentTypes}
|
paymentTypes={paymentTypes}
|
||||||
@@ -248,8 +266,7 @@ export default function CartPage() {
|
|||||||
onNameChange={setName}
|
onNameChange={setName}
|
||||||
onLastNameChange={setLastName}
|
onLastNameChange={setLastName}
|
||||||
onPaymentTypeChange={setPaymentType}
|
onPaymentTypeChange={setPaymentType}
|
||||||
onDeliveryTypeChange={handleDeliveryTypeChange}
|
onRegionChange={handleRegionChange}
|
||||||
onRegionChange={setSelectedRegion}
|
|
||||||
onProvinceChange={setSelectedProvince}
|
onProvinceChange={setSelectedProvince}
|
||||||
onNoteChange={setNote}
|
onNoteChange={setNote}
|
||||||
onCompleteOrder={handleCompleteOrder}
|
onCompleteOrder={handleCompleteOrder}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Logo from "@/public/logo.webp";
|
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";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface AuthDialogProps {
|
interface AuthDialogProps {
|
||||||
@@ -20,19 +20,28 @@ interface AuthDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthStep = "phone" | "register" | "verify";
|
||||||
|
|
||||||
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
||||||
const [phone, setPhone] = useState("+993 ");
|
const [phone, setPhone] = useState("+993 ");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
const [otp, setOtp] = 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 t = useTranslations();
|
||||||
|
|
||||||
const { mutate: login, isPending: isLoginLoading } = useLogin();
|
const { mutate: login, isPending: isLoginLoading } = useLogin();
|
||||||
|
const { mutate: register, isPending: isRegisterLoading } = useRegister();
|
||||||
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
|
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
|
||||||
|
|
||||||
const resetDialog = useCallback(() => {
|
const resetDialog = useCallback(() => {
|
||||||
setOtpSent(false);
|
setAuthStep("phone");
|
||||||
setPhone("+993 ");
|
setPhone("+993 ");
|
||||||
|
setName("");
|
||||||
|
setAddress("");
|
||||||
setOtp("");
|
setOtp("");
|
||||||
|
setIsNewUser(false);
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -50,7 +59,6 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
|
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
|
||||||
|
|
||||||
const limitedDigits = digitsOnly.substring(0, 8);
|
const limitedDigits = digitsOnly.substring(0, 8);
|
||||||
|
|
||||||
let formattedPhone = prefix;
|
let formattedPhone = prefix;
|
||||||
@@ -70,7 +78,7 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
return phoneDigits.length === 8;
|
return phoneDigits.length === 8;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendOtp = useCallback(() => {
|
const handleCheckPhone = useCallback(() => {
|
||||||
if (!isPhoneValid()) {
|
if (!isPhoneValid()) {
|
||||||
toast.error(t("invalid_phone"));
|
toast.error(t("invalid_phone"));
|
||||||
return;
|
return;
|
||||||
@@ -78,21 +86,71 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
|
|
||||||
const phoneNumber = formatPhoneForBackend(phone);
|
const phoneNumber = formatPhoneForBackend(phone);
|
||||||
|
|
||||||
|
// Try to login first to check if user exists
|
||||||
login(
|
login(
|
||||||
{ phone_number: parseInt(phoneNumber, 10) },
|
{ phone_number: parseInt(phoneNumber, 10) },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("code_sent"));
|
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) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.response?.data?.message || t("error_occurred"));
|
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) {
|
if (otp.length < 4) {
|
||||||
toast.error(t("invalid_code"));
|
toast.error(t("invalid_code"));
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +172,7 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.response?.data?.message || t("wrong_code"));
|
toast.error(error?.response?.data?.message || t("wrong_code"));
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [otp, phone, verifyToken, resetDialog, t]);
|
}, [otp, phone, verifyToken, resetDialog, t]);
|
||||||
|
|
||||||
@@ -124,9 +182,35 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
action();
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
@@ -137,14 +221,15 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-2xl text-center">
|
<DialogTitle className="text-2xl text-center">
|
||||||
{t("common.enterPhone")}
|
{getTitle()}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p className="text-center text-sm text-gray-600">
|
<p className="text-center text-sm text-gray-600">
|
||||||
{t("common.weWillSendCode")}
|
{getDescription()}
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
|
{/* Phone Input - Always shown but disabled after first step */}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -152,13 +237,40 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
|
|||||||
value={phone}
|
value={phone}
|
||||||
onChange={handlePhoneChange}
|
onChange={handlePhoneChange}
|
||||||
className="h-12 rounded-xl"
|
className="h-12 rounded-xl"
|
||||||
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
|
onKeyDown={(e) => handleKeyPress(e, handleCheckPhone)}
|
||||||
disabled={otpSent || isLoginLoading}
|
disabled={authStep !== "phone" || isLoginLoading}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
|
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
|
||||||
</div>
|
</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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("common.code")}
|
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))
|
setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))
|
||||||
}
|
}
|
||||||
className="h-12 rounded-xl"
|
className="h-12 rounded-xl"
|
||||||
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
|
onKeyDown={(e) => handleKeyPress(e, handleVerify)}
|
||||||
disabled={isVerifyLoading}
|
disabled={isVerifyLoading}
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
<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]"
|
className="w-full cursor-pointer h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={
|
disabled={
|
||||||
isLoginLoading || isVerifyLoading || (!otpSent && !isPhoneValid())
|
isLoginLoading ||
|
||||||
|
isRegisterLoading ||
|
||||||
|
isVerifyLoading ||
|
||||||
|
(authStep === "phone" && !isPhoneValid())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoginLoading
|
{isLoginLoading
|
||||||
? t("sending")
|
? t("checking") || "Barlanýar..."
|
||||||
: isVerifyLoading
|
: isRegisterLoading
|
||||||
? t("verifying")
|
? t("registering") || "Hasaba alynýar..."
|
||||||
: otpSent
|
: isVerifyLoading
|
||||||
? t("verify")
|
? t("verifying")
|
||||||
: t("common.send")}
|
: authStep === "phone"
|
||||||
|
? t("common.send")
|
||||||
|
: authStep === "register"
|
||||||
|
? t("register_button") || "Hasaba al"
|
||||||
|
: t("verify")}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ export default function AuthWrapper({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
|
// Only fetch guest token once on initial mount if no token exists
|
||||||
if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
|
if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
|
||||||
getGuestToken();
|
getGuestToken();
|
||||||
}
|
}
|
||||||
}, [isLoading, getGuestToken, isGettingGuestToken]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isLoading]); // Only run when isLoading changes (initial mount)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isGettingGuestToken) return;
|
if (isLoading || isGettingGuestToken) return;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
const pendingQuantityRef = useRef<number | null>(null);
|
const pendingQuantityRef = useRef<number | null>(null);
|
||||||
const retryCountRef = useRef(0);
|
const retryCountRef = useRef(0);
|
||||||
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const isInitializedRef = useRef(false); // Track if component has been initialized
|
||||||
|
|
||||||
// Function refs to solve circular dependency
|
// Function refs to solve circular dependency
|
||||||
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
|
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
|
||||||
@@ -62,6 +63,10 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
// Initialize from server state
|
// Initialize from server state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalQuantity(item.quantity);
|
setLocalQuantity(item.quantity);
|
||||||
|
// Mark as initialized after first render
|
||||||
|
if (!isInitializedRef.current) {
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
}
|
||||||
}, [item.quantity]);
|
}, [item.quantity]);
|
||||||
|
|
||||||
// Save to sessionStorage
|
// Save to sessionStorage
|
||||||
@@ -81,13 +86,13 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
|
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
PENDING_CART_UPDATES_KEY,
|
PENDING_CART_UPDATES_KEY,
|
||||||
JSON.stringify(pending)
|
JSON.stringify(pending),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save pending update:", error);
|
console.error("Failed to save pending update:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[item.product_id]
|
[item.product_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove from sessionStorage
|
// Remove from sessionStorage
|
||||||
@@ -103,7 +108,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
} else {
|
} else {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
PENDING_CART_UPDATES_KEY,
|
PENDING_CART_UPDATES_KEY,
|
||||||
JSON.stringify(pending)
|
JSON.stringify(pending),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
|
|
||||||
retrySyncRef.current?.(quantity);
|
retrySyncRef.current?.(quantity);
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,13 +216,16 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
removeItem,
|
removeItem,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
clearPendingUpdate,
|
clearPendingUpdate,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update ref
|
// Update ref
|
||||||
syncToServerRef.current = syncToServer;
|
syncToServerRef.current = syncToServer;
|
||||||
|
|
||||||
// Load pending updates from sessionStorage on mount
|
// Load pending updates from sessionStorage on mount
|
||||||
|
// DISABLED: This was causing automatic sync on mount, sending 0 quantity to server
|
||||||
|
// Users should manually refresh or re-add items if there were pending updates
|
||||||
|
/*
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPendingUpdates = () => {
|
const loadPendingUpdates = () => {
|
||||||
try {
|
try {
|
||||||
@@ -246,9 +254,15 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
|
|
||||||
loadPendingUpdates();
|
loadPendingUpdates();
|
||||||
}, [item.product_id, item.quantity]);
|
}, [item.product_id, item.quantity]);
|
||||||
|
*/
|
||||||
|
|
||||||
// Debounced sync
|
// Debounced sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't sync on initial mount - only sync after user interaction
|
||||||
|
if (!isInitializedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timers
|
// Clear existing timers
|
||||||
if (debounceTimerRef.current) {
|
if (debounceTimerRef.current) {
|
||||||
clearTimeout(debounceTimerRef.current);
|
clearTimeout(debounceTimerRef.current);
|
||||||
@@ -259,6 +273,14 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't sync if quantity is 0 or invalid (unless it's a delete operation)
|
||||||
|
if (localQuantity <= 0 && item.quantity > 0) {
|
||||||
|
// This is a delete operation, allow it
|
||||||
|
} else if (localQuantity <= 0) {
|
||||||
|
// Invalid state, don't sync
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Save to sessionStorage immediately
|
// Save to sessionStorage immediately
|
||||||
savePendingUpdate(localQuantity);
|
savePendingUpdate(localQuantity);
|
||||||
|
|
||||||
@@ -336,14 +358,14 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="font-semibold text-base">{item.product.name}</h3>
|
<h3 className="font-semibold text-base">{item.product.name}</h3>
|
||||||
<p className="text-sm text-gray-600">
|
{/* <p className="text-sm text-gray-600">
|
||||||
{item.seller?.name || "Store"}
|
{item.seller?.name || "Store"}
|
||||||
</p>
|
</p> */}
|
||||||
{availableStock <= 5 && (
|
{/* {availableStock <= 5 && (
|
||||||
<p className="text-xs text-orange-600 font-medium">
|
<p className="text-xs text-orange-600 font-medium">
|
||||||
{t("only_left", { count: availableStock })}
|
{t("only_left", { count: availableStock })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)} */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import DeliveryTypeSelector from "./DeliveryTypeSelector";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api";
|
import type {
|
||||||
|
DeliveryType,
|
||||||
|
PaymentType,
|
||||||
|
Province,
|
||||||
|
OrderDelivery,
|
||||||
|
} from "@/lib/types/api";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface OrderBillingItem {
|
interface OrderBillingItem {
|
||||||
@@ -37,10 +41,11 @@ interface OrderSummaryProps {
|
|||||||
billing: OrderBilling;
|
billing: OrderBilling;
|
||||||
};
|
};
|
||||||
paymentType: PaymentType | null;
|
paymentType: PaymentType | null;
|
||||||
deliveryType: DeliveryType;
|
orderDeliveries: OrderDelivery[];
|
||||||
|
selectedOrderDelivery: OrderDelivery | null;
|
||||||
selectedRegion: string;
|
selectedRegion: string;
|
||||||
selectedProvince: number | null;
|
selectedProvince: number | null;
|
||||||
note: string;
|
notes: string;
|
||||||
regionGroups: Record<string, Province[]>;
|
regionGroups: Record<string, Province[]>;
|
||||||
availableRegions: string[];
|
availableRegions: string[];
|
||||||
paymentTypes: PaymentType[];
|
paymentTypes: PaymentType[];
|
||||||
@@ -51,10 +56,10 @@ interface OrderSummaryProps {
|
|||||||
onNameChange: (name: string) => void;
|
onNameChange: (name: string) => void;
|
||||||
onLastNameChange: (lastName: string) => void;
|
onLastNameChange: (lastName: string) => void;
|
||||||
onPaymentTypeChange: (type: PaymentType) => void;
|
onPaymentTypeChange: (type: PaymentType) => void;
|
||||||
onDeliveryTypeChange: (type: DeliveryType) => void;
|
onOrderDeliveryChange: (delivery: OrderDelivery) => void;
|
||||||
onRegionChange: (regionCode: string) => void;
|
onRegionChange: (regionCode: string) => void;
|
||||||
onProvinceChange: (provinceId: number) => void;
|
onProvinceChange: (provinceId: number) => void;
|
||||||
onNoteChange: (note: string) => void;
|
onNoteChange: (notes: string) => void;
|
||||||
onCompleteOrder: () => void;
|
onCompleteOrder: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -62,10 +67,11 @@ interface OrderSummaryProps {
|
|||||||
export default function OrderSummary({
|
export default function OrderSummary({
|
||||||
order,
|
order,
|
||||||
paymentType,
|
paymentType,
|
||||||
deliveryType,
|
orderDeliveries,
|
||||||
|
selectedOrderDelivery,
|
||||||
selectedRegion,
|
selectedRegion,
|
||||||
selectedProvince,
|
selectedProvince,
|
||||||
note,
|
notes,
|
||||||
regionGroups,
|
regionGroups,
|
||||||
availableRegions,
|
availableRegions,
|
||||||
paymentTypes,
|
paymentTypes,
|
||||||
@@ -76,7 +82,7 @@ export default function OrderSummary({
|
|||||||
onNameChange,
|
onNameChange,
|
||||||
onLastNameChange,
|
onLastNameChange,
|
||||||
onPaymentTypeChange,
|
onPaymentTypeChange,
|
||||||
onDeliveryTypeChange,
|
onOrderDeliveryChange,
|
||||||
onRegionChange,
|
onRegionChange,
|
||||||
onProvinceChange,
|
onProvinceChange,
|
||||||
onNoteChange,
|
onNoteChange,
|
||||||
@@ -90,6 +96,15 @@ export default function OrderSummary({
|
|||||||
? regionGroups[selectedRegion] || []
|
? regionGroups[selectedRegion] || []
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const filteredDeliveries = orderDeliveries.filter((delivery) => {
|
||||||
|
if (!selectedRegion) return true;
|
||||||
|
if (selectedRegion === "ag") {
|
||||||
|
return delivery.name === "standart" || delivery.name === "self_pickup";
|
||||||
|
} else {
|
||||||
|
return delivery.name === "region";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const phoneDigits = phone.replace(/\D/g, "");
|
const phoneDigits = phone.replace(/\D/g, "");
|
||||||
const isPhoneValid = phoneDigits.length === 11;
|
const isPhoneValid = phoneDigits.length === 11;
|
||||||
|
|
||||||
@@ -97,6 +112,7 @@ export default function OrderSummary({
|
|||||||
selectedRegion &&
|
selectedRegion &&
|
||||||
selectedProvince &&
|
selectedProvince &&
|
||||||
paymentType &&
|
paymentType &&
|
||||||
|
selectedOrderDelivery &&
|
||||||
isPhoneValid &&
|
isPhoneValid &&
|
||||||
name.trim() !== "" &&
|
name.trim() !== "" &&
|
||||||
lastName.trim() !== "";
|
lastName.trim() !== "";
|
||||||
@@ -136,7 +152,7 @@ export default function OrderSummary({
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
|
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
|
||||||
{/* Customer Information */}
|
{/* Customer Information */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-semibold mb-3">
|
<h3 className="text-lg font-semibold mb-3">
|
||||||
{t("customer_information")}
|
{t("customer_information")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -198,7 +214,7 @@ export default function OrderSummary({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Type */}
|
{/* Payment Type */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
|
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{paymentTypes.map((type) => (
|
{paymentTypes.map((type) => (
|
||||||
@@ -231,16 +247,13 @@ export default function OrderSummary({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Region Selection */}
|
{/* Region Selection */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<Label className="text-lg font-semibold mb-3 block">
|
<Label className="text-lg font-semibold mb-3 block">
|
||||||
{t("choose_region")}
|
{t("choose_region")}
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={selectedRegion}
|
value={selectedRegion}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => onRegionChange(value)}
|
||||||
onRegionChange(value);
|
|
||||||
onProvinceChange(null as any);
|
|
||||||
}}
|
|
||||||
className="flex flex-wrap gap-4"
|
className="flex flex-wrap gap-4"
|
||||||
>
|
>
|
||||||
{availableRegions.map((regionCode) => (
|
{availableRegions.map((regionCode) => (
|
||||||
@@ -270,7 +283,7 @@ export default function OrderSummary({
|
|||||||
|
|
||||||
{/* Province Selection */}
|
{/* Province Selection */}
|
||||||
{selectedRegion && provincesForSelectedRegion.length > 0 && (
|
{selectedRegion && provincesForSelectedRegion.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<Label className="text-lg font-semibold mb-3 block">
|
<Label className="text-lg font-semibold mb-3 block">
|
||||||
{t("choose_address")}
|
{t("choose_address")}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -299,11 +312,59 @@ export default function OrderSummary({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Shipping Method */}
|
||||||
|
{selectedRegion && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{t("shipping_method")}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{filteredDeliveries.map((delivery) => (
|
||||||
|
<Card
|
||||||
|
key={delivery.name}
|
||||||
|
className={`flex-1 cursor-pointer py-4 transition-all ${
|
||||||
|
selectedOrderDelivery?.name === delivery.name
|
||||||
|
? "border-2 border-[#005bff] bg-blue-50"
|
||||||
|
: showValidation && !selectedOrderDelivery
|
||||||
|
? "border-2 border-red-500"
|
||||||
|
: "border-2 border-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onOrderDeliveryChange(delivery)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-col p-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
selectedOrderDelivery?.name === delivery.name
|
||||||
|
? "text-[#005bff]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(delivery.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${
|
||||||
|
selectedOrderDelivery?.name === delivery.name
|
||||||
|
? "text-[#005bff]"
|
||||||
|
: "text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{delivery.price === 0 ? t("free") : `${delivery.price} TMT`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showValidation && !selectedOrderDelivery && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Note */}
|
{/* Note */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
|
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={note}
|
value={notes}
|
||||||
onChange={(e) => onNoteChange(e.target.value)}
|
onChange={(e) => onNoteChange(e.target.value)}
|
||||||
className="rounded-xl resize-none"
|
className="rounded-xl resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|||||||
@@ -5,15 +5,14 @@ import {
|
|||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import type { CartItem } from "@/lib/types/api";
|
import type {
|
||||||
|
CartItem,
|
||||||
|
CartResponse,
|
||||||
|
CreateOrderPayload,
|
||||||
|
OrderDelivery,
|
||||||
|
} from "@/lib/types/api";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface CartResponse {
|
|
||||||
message: string;
|
|
||||||
data: CartItem[];
|
|
||||||
errorDetails?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUpdates = new Map<number, number>();
|
const pendingUpdates = new Map<number, number>();
|
||||||
let updateLock = false;
|
let updateLock = false;
|
||||||
|
|
||||||
@@ -151,7 +150,7 @@ export function useAddToCart() {
|
|||||||
|
|
||||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||||
const idx = updated.data.findIndex(
|
const idx = updated.data.findIndex(
|
||||||
(item: any) => item.product?.id === pendingId
|
(item: any) => item.product?.id === pendingId,
|
||||||
);
|
);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
updated.data[idx] = {
|
updated.data[idx] = {
|
||||||
@@ -162,7 +161,7 @@ export function useAddToCart() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const existingItem = updated.data.find(
|
const existingItem = updated.data.find(
|
||||||
(item: any) => item.product?.id === productId
|
(item: any) => item.product?.id === productId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
@@ -172,7 +171,7 @@ export function useAddToCart() {
|
|||||||
...item,
|
...item,
|
||||||
product_quantity: item.product_quantity + quantity,
|
product_quantity: item.product_quantity + quantity,
|
||||||
}
|
}
|
||||||
: item
|
: item,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updated.data = [
|
updated.data = [
|
||||||
@@ -185,7 +184,7 @@ export function useAddToCart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalItem = updated.data.find(
|
const finalItem = updated.data.find(
|
||||||
(item: any) => item.product?.id === productId
|
(item: any) => item.product?.id === productId,
|
||||||
);
|
);
|
||||||
if (finalItem) {
|
if (finalItem) {
|
||||||
pendingUpdates.set(productId, finalItem.product_quantity);
|
pendingUpdates.set(productId, finalItem.product_quantity);
|
||||||
@@ -261,7 +260,7 @@ export function useRemoveFromCart() {
|
|||||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||||
if (pendingId !== productId) {
|
if (pendingId !== productId) {
|
||||||
const idx = updated.data.findIndex(
|
const idx = updated.data.findIndex(
|
||||||
(item: any) => item.product?.id === pendingId
|
(item: any) => item.product?.id === pendingId,
|
||||||
);
|
);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
updated.data[idx] = {
|
updated.data[idx] = {
|
||||||
@@ -273,7 +272,7 @@ export function useRemoveFromCart() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updated.data = updated.data.filter(
|
updated.data = updated.data.filter(
|
||||||
(item: any) => item.product?.id !== productId
|
(item: any) => item.product?.id !== productId,
|
||||||
);
|
);
|
||||||
|
|
||||||
pendingUpdates.delete(productId);
|
pendingUpdates.delete(productId);
|
||||||
@@ -413,7 +412,7 @@ export function useUpdateCartItemQuantity() {
|
|||||||
|
|
||||||
pendingUpdates.forEach((pendingQty, pendingId) => {
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
||||||
const idx = updated.data.findIndex(
|
const idx = updated.data.findIndex(
|
||||||
(item: any) => item.product?.id === pendingId
|
(item: any) => item.product?.id === pendingId,
|
||||||
);
|
);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
updated.data[idx] = {
|
updated.data[idx] = {
|
||||||
@@ -426,7 +425,7 @@ export function useUpdateCartItemQuantity() {
|
|||||||
updated.data = updated.data.map((item: any) =>
|
updated.data = updated.data.map((item: any) =>
|
||||||
item.product?.id === productId
|
item.product?.id === productId
|
||||||
? { ...item, product_quantity: quantity }
|
? { ...item, product_quantity: quantity }
|
||||||
: item
|
: item,
|
||||||
);
|
);
|
||||||
|
|
||||||
pendingUpdates.set(productId, quantity);
|
pendingUpdates.set(productId, quantity);
|
||||||
@@ -457,27 +456,32 @@ export function useUpdateCartItemQuantity() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOrderDeliveries() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["order-deliveries"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
message: string;
|
||||||
|
data: OrderDelivery[];
|
||||||
|
}>("/order-deliveries");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateOrder() {
|
export function useCreateOrder() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: {
|
mutationFn: async (payload: CreateOrderPayload) => {
|
||||||
customer_name?: string;
|
|
||||||
customer_phone: number;
|
|
||||||
customer_address: string;
|
|
||||||
shipping_method: string;
|
|
||||||
payment_type_id: number;
|
|
||||||
delivery_time?: string;
|
|
||||||
delivery_at?: string;
|
|
||||||
region: string;
|
|
||||||
note?: string;
|
|
||||||
}) => {
|
|
||||||
const response = await apiClient.post("/orders", payload);
|
const response = await apiClient.post("/orders", payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data && data.payment_url) {
|
// Handle payment URL - check both data.payment_url and data.data.payment_url
|
||||||
window.open(data.payment_url, '_blank')?.focus();
|
const paymentUrl = data?.data?.payment_url || data?.payment_url;
|
||||||
|
if (paymentUrl) {
|
||||||
|
window.open(paymentUrl, "_blank")?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingUpdates.clear();
|
pendingUpdates.clear();
|
||||||
@@ -491,7 +495,7 @@ export function useCreateOrder() {
|
|||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error(
|
console.error(
|
||||||
"Create order error:",
|
"Create order error:",
|
||||||
error.response?.data?.message || error.message
|
error.response?.data?.message || error.message,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -502,7 +506,7 @@ export function useCartCount() {
|
|||||||
return (
|
return (
|
||||||
data?.data?.reduce(
|
data?.data?.reduce(
|
||||||
(sum: number, item: any) => sum + (item.product_quantity || 0),
|
(sum: number, item: any) => sum + (item.product_quantity || 0),
|
||||||
0
|
0,
|
||||||
) || 0
|
) || 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
Banknote,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useOrders, useCancelOrder } from "@/lib/hooks";
|
import { useOrders, useCancelOrder, useOrderDeliveries } from "@/lib/hooks";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Order } from "@/lib/types/api";
|
import type { Order } from "@/lib/types/api";
|
||||||
import EmptyOrders from "./EmptyOrders";
|
import EmptyOrders from "./EmptyOrders";
|
||||||
@@ -42,6 +43,8 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { data: orders, isLoading, isError } = useOrders();
|
const { data: orders, isLoading, isError } = useOrders();
|
||||||
|
const { data: orderDeliveries, isLoading: deliveriesLoading } =
|
||||||
|
useOrderDeliveries();
|
||||||
const { mutate: cancelOrder, isPending: isCancellingOrder } =
|
const { mutate: cancelOrder, isPending: isCancellingOrder } =
|
||||||
useCancelOrder();
|
useCancelOrder();
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
|||||||
if (
|
if (
|
||||||
lowerStatus.includes("ожидается") ||
|
lowerStatus.includes("ожидается") ||
|
||||||
lowerStatus.includes("pending") ||
|
lowerStatus.includes("pending") ||
|
||||||
lowerStatus.includes("garaşlama")
|
lowerStatus.includes("garaşylýar")
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -147,20 +150,89 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
|||||||
|
|
||||||
const activeOrders = useMemo(
|
const activeOrders = useMemo(
|
||||||
() => orders?.filter((o) => isActiveOrder(o.status)) || [],
|
() => orders?.filter((o) => isActiveOrder(o.status)) || [],
|
||||||
[orders, isActiveOrder]
|
[orders, isActiveOrder],
|
||||||
);
|
);
|
||||||
const completedOrders = useMemo(
|
const completedOrders = useMemo(
|
||||||
() => orders?.filter((o) => !isActiveOrder(o.status)) || [],
|
() => orders?.filter((o) => !isActiveOrder(o.status)) || [],
|
||||||
[orders, isActiveOrder]
|
[orders, isActiveOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const calculateTotal = useCallback((order: Order) => {
|
const getShippingPrice = useCallback(
|
||||||
return order.orderItems.reduce((sum, item) => {
|
(order: Order) => {
|
||||||
return sum + parseFloat(item.unit_price_amount) * item.quantity;
|
if (order.shipping_price !== undefined && order.shipping_price !== null) {
|
||||||
}, 0);
|
return Number(order.shipping_price);
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (!orderDeliveries || orderDeliveries.length === 0) return 0;
|
||||||
|
|
||||||
|
const methodFromOrder = order.shipping_method.toLowerCase();
|
||||||
|
|
||||||
|
// Find delivery method by matching internal name, translated name, or common keywords
|
||||||
|
const delivery = orderDeliveries.find((d) => {
|
||||||
|
const internalName = d.name.toLowerCase();
|
||||||
|
const translatedName = t(internalName).toLowerCase(); // d.name should be used for translation
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (
|
||||||
|
internalName === methodFromOrder ||
|
||||||
|
translatedName === methodFromOrder
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword based matching for "region"
|
||||||
|
if (
|
||||||
|
(internalName === "region" || internalName === "çapar") &&
|
||||||
|
(methodFromOrder.includes("welaýat") ||
|
||||||
|
methodFromOrder.includes("region") ||
|
||||||
|
methodFromOrder.includes("регион") ||
|
||||||
|
methodFromOrder.includes("çapar") ||
|
||||||
|
methodFromOrder.includes("welayat"))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword based matching for "self_pickup"
|
||||||
|
if (
|
||||||
|
internalName === "self_pickup" &&
|
||||||
|
(methodFromOrder.includes("özüm") ||
|
||||||
|
methodFromOrder.includes("özüň") ||
|
||||||
|
methodFromOrder.includes("pickup") ||
|
||||||
|
methodFromOrder.includes("самовывоз"))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword based matching for "standart"
|
||||||
|
if (
|
||||||
|
internalName === "standart" &&
|
||||||
|
(methodFromOrder.includes("standart") ||
|
||||||
|
methodFromOrder.includes("standard"))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return delivery ? Number(delivery.price) : 0;
|
||||||
|
},
|
||||||
|
[orderDeliveries, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateTotal = useCallback(
|
||||||
|
(order: Order) => {
|
||||||
|
const itemsTotal = order.orderItems.reduce((sum, item) => {
|
||||||
|
return sum + parseFloat(item.unit_price_amount) * item.quantity;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const shippingPrice = getShippingPrice(order);
|
||||||
|
return itemsTotal + shippingPrice;
|
||||||
|
},
|
||||||
|
[getShippingPrice],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading || deliveriesLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
|
<div className="mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
|
||||||
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">
|
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">
|
||||||
@@ -247,8 +319,10 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
|||||||
isCancelling={isCancellingOrder}
|
isCancelling={isCancellingOrder}
|
||||||
getStatusBadge={getStatusBadge}
|
getStatusBadge={getStatusBadge}
|
||||||
calculateTotal={calculateTotal}
|
calculateTotal={calculateTotal}
|
||||||
|
getShippingPrice={getShippingPrice}
|
||||||
showCancelButton
|
showCancelButton
|
||||||
t={t}
|
t={t}
|
||||||
|
orderDeliveries={orderDeliveries || []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,12 +344,13 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
|||||||
order={order}
|
order={order}
|
||||||
isExpanded={expandedOrders.has(order.id)}
|
isExpanded={expandedOrders.has(order.id)}
|
||||||
onToggle={() => toggleOrderExpand(order.id)}
|
onToggle={() => toggleOrderExpand(order.id)}
|
||||||
onCancel={handleCancelOrder}
|
|
||||||
isCancelling={isCancellingOrder}
|
isCancelling={isCancellingOrder}
|
||||||
getStatusBadge={getStatusBadge}
|
getStatusBadge={getStatusBadge}
|
||||||
calculateTotal={calculateTotal}
|
calculateTotal={calculateTotal}
|
||||||
|
getShippingPrice={getShippingPrice}
|
||||||
showCancelButton={false}
|
showCancelButton={false}
|
||||||
t={t}
|
t={t}
|
||||||
|
orderDeliveries={orderDeliveries || []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,12 +394,14 @@ interface CompactOrderCardProps {
|
|||||||
order: Order;
|
order: Order;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onCancel: (order: Order) => void;
|
onCancel?: (order: Order) => void;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
getStatusBadge: (status: string) => React.ReactNode;
|
getStatusBadge: (status: string) => React.ReactNode;
|
||||||
calculateTotal: (order: Order) => number;
|
calculateTotal: (order: Order) => number;
|
||||||
|
getShippingPrice: (order: Order) => number;
|
||||||
showCancelButton: boolean;
|
showCancelButton: boolean;
|
||||||
t: any;
|
t: any;
|
||||||
|
orderDeliveries: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactOrderCard({
|
function CompactOrderCard({
|
||||||
@@ -335,12 +412,19 @@ function CompactOrderCard({
|
|||||||
isCancelling,
|
isCancelling,
|
||||||
getStatusBadge,
|
getStatusBadge,
|
||||||
calculateTotal,
|
calculateTotal,
|
||||||
|
getShippingPrice,
|
||||||
showCancelButton,
|
showCancelButton,
|
||||||
t,
|
t,
|
||||||
|
orderDeliveries,
|
||||||
}: CompactOrderCardProps) {
|
}: CompactOrderCardProps) {
|
||||||
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
|
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
|
||||||
const itemCount = order.orderItems.length;
|
const itemCount = order.orderItems.length;
|
||||||
|
|
||||||
|
const shippingPrice = useMemo(
|
||||||
|
() => getShippingPrice(order),
|
||||||
|
[order, getShippingPrice],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md">
|
<Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md">
|
||||||
{/* Compact Header - Always Visible */}
|
{/* Compact Header - Always Visible */}
|
||||||
@@ -430,6 +514,20 @@ function CompactOrderCard({
|
|||||||
<p className="text-sm text-gray-900">{order.shipping_method}</p>
|
<p className="text-sm text-gray-900">{order.shipping_method}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 border-t md:border-t-0 pt-2 md:pt-0">
|
||||||
|
<Banknote className="h-5 w-5 text-orange-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
{t("shipping_price")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-green-600">
|
||||||
|
{shippingPrice === 0
|
||||||
|
? t("free")
|
||||||
|
: `${shippingPrice.toFixed(2)} TMT`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Products List */}
|
{/* Products List */}
|
||||||
@@ -476,7 +574,22 @@ function CompactOrderCard({
|
|||||||
|
|
||||||
{/* Footer with Total and Actions */}
|
{/* Footer with Total and Actions */}
|
||||||
<div className="border-t p-4 bg-gray-50">
|
<div className="border-t p-4 bg-gray-50">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>{t("products")}:</span>
|
||||||
|
<span>{(total - shippingPrice).toFixed(2)} TMT</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>{t("shipping_method")}:</span>
|
||||||
|
<span>
|
||||||
|
{shippingPrice === 0
|
||||||
|
? t("free")
|
||||||
|
: `${shippingPrice.toFixed(2)} TMT`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-3 pt-2 border-t">
|
||||||
<span className="text-base font-semibold text-gray-700">
|
<span className="text-base font-semibold text-gray-700">
|
||||||
{t("total_price")}:
|
{t("total_price")}:
|
||||||
</span>
|
</span>
|
||||||
@@ -490,7 +603,7 @@ function CompactOrderCard({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCancel(order);
|
onCancel?.(order);
|
||||||
}}
|
}}
|
||||||
disabled={isCancelling}
|
disabled={isCancelling}
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
RotateCw,
|
||||||
|
RotateCcw,
|
||||||
|
Maximize2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
interface ProductImageGalleryProps {
|
interface ProductImageGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -13,10 +23,22 @@ export function ProductImageGallery({
|
|||||||
noImageText,
|
noImageText,
|
||||||
}: ProductImageGalleryProps) {
|
}: ProductImageGalleryProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
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 autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const modalImageRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (images.length <= 1) return;
|
setSelectedImage(0);
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (images.length <= 1 || isModalOpen) return;
|
||||||
|
|
||||||
const startAutoplay = () => {
|
const startAutoplay = () => {
|
||||||
autoplayTimerRef.current = setInterval(() => {
|
autoplayTimerRef.current = setInterval(() => {
|
||||||
@@ -28,63 +50,417 @@ export function ProductImageGallery({
|
|||||||
return () => {
|
return () => {
|
||||||
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [images.length]);
|
}, [images.length, isModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isModalOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
const handleImageSelect = useCallback(
|
const handleImageSelect = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
setSelectedImage(index);
|
setSelectedImage(index);
|
||||||
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
||||||
if (images.length > 1) {
|
if (images.length > 1 && !isModalOpen) {
|
||||||
autoplayTimerRef.current = setInterval(() => {
|
autoplayTimerRef.current = setInterval(() => {
|
||||||
setSelectedImage((prev) => (prev + 1) % images.length);
|
setSelectedImage((prev) => (prev + 1) % images.length);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[images.length]
|
[images.length, isModalOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
resetTransform();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetTransform();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTransform = () => {
|
||||||
|
setZoom(1);
|
||||||
|
setRotation(0);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setZoom((prev) => Math.min(prev + 0.25, 5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setZoom((prev) => Math.max(prev - 0.25, 0.5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateClockwise = () => {
|
||||||
|
setRotation((prev) => (prev + 90) % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateCounterClockwise = () => {
|
||||||
|
setRotation((prev) => (prev - 90 + 360) % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
setDragStart({
|
||||||
|
x: clientX - position.x,
|
||||||
|
y: clientY - position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
setPosition({
|
||||||
|
x: clientX - dragStart.x,
|
||||||
|
y: clientY - dragStart.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
handleZoomIn();
|
||||||
|
} else {
|
||||||
|
handleZoomOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalImageChange = (direction: "prev" | "next") => {
|
||||||
|
if (direction === "next") {
|
||||||
|
setSelectedImage((prev) => (prev + 1) % images.length);
|
||||||
|
} else {
|
||||||
|
setSelectedImage((prev) => (prev - 1 + images.length) % images.length);
|
||||||
|
}
|
||||||
|
resetTransform();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contents max-w-2xl">
|
<>
|
||||||
<div className="relative">
|
<div className="w-full lg:flex-1 max-w-2xl">
|
||||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white">
|
<div className="relative">
|
||||||
{images.length > 0 ? (
|
<div
|
||||||
<Image
|
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"
|
||||||
src={images[selectedImage]}
|
onClick={openModal}
|
||||||
alt={productName}
|
>
|
||||||
fill
|
{images.length > 0 && images[selectedImage] ? (
|
||||||
className="object-contain"
|
<>
|
||||||
priority
|
<Image
|
||||||
/>
|
src={images[selectedImage]}
|
||||||
) : (
|
alt={productName}
|
||||||
<div className="flex items-center justify-center h-full text-gray-400">
|
fill
|
||||||
{noImageText}
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
className="object-contain transition-transform group-hover:scale-105"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 md:p-3 transform translate-y-4 group-hover:translate-y-0 transition-transform">
|
||||||
|
<Maximize2 className="w-4 h-4 md:w-5 md:h-5 text-gray-800" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm md:text-base">
|
||||||
|
{noImageText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{images.length > 1 && (
|
{/* Modal */}
|
||||||
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
|
{isModalOpen && (
|
||||||
{images.map((image, index) => (
|
<div className="fixed inset-0 z-99 bg-gradient-to-br from-gray-900/95 via-gray-800/95 to-gray-900/95 backdrop-blur-xl flex flex-col">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-20 bg-gradient-to-b from-black/20 to-transparent">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
key={index}
|
onClick={closeModal}
|
||||||
onClick={() => handleImageSelect(index)}
|
className="p-2 md:p-2.5 bg-white/10 hover:bg-white/20 rounded-lg md:rounded-xl transition-all backdrop-blur-sm border border-white/10 shrink-0 ml-2"
|
||||||
className={`relative w-16 h-16 shrink-0 rounded cursor-pointer overflow-hidden border-2 transition-all ${
|
aria-label="Close"
|
||||||
selectedImage === index
|
>
|
||||||
? "border-primary ring-2 ring-primary/20"
|
<X className="w-4 h-4 md:w-5 md:h-5 text-white" />
|
||||||
: "border-gray-200 hover:border-gray-300"
|
</button>
|
||||||
}`}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Image Area */}
|
||||||
|
<div className="flex-1 flex items-center justify-center relative px-2 md:px-16 lg:px-20">
|
||||||
|
{/* Left Arrow - Desktop */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("prev")}
|
||||||
|
className="hidden md:flex absolute left-3 lg:left-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5 md:w-6 md:h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Container */}
|
||||||
|
<div
|
||||||
|
ref={modalImageRef}
|
||||||
|
className="w-full h-full flex items-center justify-center overflow-hidden"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onTouchStart={handleMouseDown}
|
||||||
|
onTouchMove={handleMouseMove}
|
||||||
|
onTouchEnd={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
className="relative w-[90vw] h-[60vh] md:w-[75vw] md:h-[70vh]"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={images[selectedImage]}
|
||||||
alt={`${productName} ${index + 1}`}
|
alt={productName}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-contain pointer-events-none select-none"
|
||||||
|
priority
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Arrow - Desktop */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("next")}
|
||||||
|
className="hidden md:flex absolute right-3 lg:right-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 md:w-6 md:h-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Bottom Control Bar */}
|
||||||
</div>
|
<div className="bg-gradient-to-t from-black/40 via-black/20 to-transparent backdrop-blur-xl border-t border-white/10">
|
||||||
|
<div className="max-w-7xl mx-auto px-3 md:px-6 py-3 md:py-4">
|
||||||
|
{/* Mobile Layout */}
|
||||||
|
<div className="md:hidden flex flex-col gap-2">
|
||||||
|
{/* Row 1: Navigation */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("prev")}
|
||||||
|
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-white mx-auto" />
|
||||||
|
</button>
|
||||||
|
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
|
||||||
|
<span className="text-white text-sm font-medium whitespace-nowrap">
|
||||||
|
{selectedImage + 1} / {images.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("next")}
|
||||||
|
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 text-white mx-auto" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 2: Zoom & Rotate */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4 text-white mx-auto" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={handleRotateCounterClockwise}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Rotate counter-clockwise"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRotateClockwise}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Rotate clockwise"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={resetTransform}
|
||||||
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-xs font-medium border border-white/10"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
{t("reset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Layout */}
|
||||||
|
<div className="hidden md:flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("prev")}
|
||||||
|
disabled={images.length <= 1}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-8 bg-white/20" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={handleRotateCounterClockwise}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Rotate counter-clockwise"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRotateClockwise}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
||||||
|
aria-label="Rotate clockwise"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={resetTransform}
|
||||||
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-sm font-medium border border-white/10"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-8 bg-white/20" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleModalImageChange("next")}
|
||||||
|
disabled={images.length <= 1}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-8 bg-white/20" />
|
||||||
|
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{selectedImage + 1} / {images.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
refetch: refetchProduct,
|
refetch: refetchProduct,
|
||||||
} = useProductsBySlug(slug);
|
} = useProductsBySlug(slug);
|
||||||
const { isFavorite, isLoading: isFavLoading } = useIsFavorite(
|
const { isFavorite, isLoading: isFavLoading } = useIsFavorite(
|
||||||
product?.id || 0
|
product?.id || 0,
|
||||||
);
|
);
|
||||||
const cartOptions = useMemo(
|
const cartOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -80,7 +80,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
}),
|
}),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
const { mutate: toggleFavoriteMutation } = useToggleFavorite();
|
const { mutate: toggleFavoriteMutation } = useToggleFavorite();
|
||||||
const {
|
const {
|
||||||
@@ -100,7 +100,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
|
|
||||||
const cartItem = useMemo(() => {
|
const cartItem = useMemo(() => {
|
||||||
const item = cartData?.data?.find(
|
const item = cartData?.data?.find(
|
||||||
(item: any) => item.product?.id === product?.id
|
(item: any) => item.product?.id === product?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -109,19 +109,26 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
const isInCart = !!cartItem;
|
const isInCart = !!cartItem;
|
||||||
const availableStock = product?.stock || 0;
|
const availableStock = product?.stock || 0;
|
||||||
|
|
||||||
const imageUrls = useMemo(
|
const imageUrls = useMemo(() => {
|
||||||
() =>
|
|
||||||
product?.media?.map(
|
if (!product?.media || product.media.length === 0) {
|
||||||
(m) => m.images_800x800 || m.images_720x720 || m.thumbnail
|
return [];
|
||||||
) || [],
|
}
|
||||||
[product]
|
|
||||||
);
|
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 reviews = useMemo(() => product?.reviews_resources || [], [product]);
|
||||||
const averageRating = useMemo(
|
const averageRating = useMemo(
|
||||||
() =>
|
() =>
|
||||||
product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0,
|
product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0,
|
||||||
[product]
|
[product],
|
||||||
);
|
);
|
||||||
|
|
||||||
const transformedRelatedProducts = useMemo(() => {
|
const transformedRelatedProducts = useMemo(() => {
|
||||||
@@ -173,13 +180,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
};
|
};
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
PENDING_PRODUCT_UPDATES_KEY,
|
PENDING_PRODUCT_UPDATES_KEY,
|
||||||
JSON.stringify(pending)
|
JSON.stringify(pending),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save pending update:", error);
|
console.error("Failed to save pending update:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[product?.id]
|
[product?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearPendingUpdate = useCallback(() => {
|
const clearPendingUpdate = useCallback(() => {
|
||||||
@@ -194,7 +201,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
} else {
|
} else {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
PENDING_PRODUCT_UPDATES_KEY,
|
PENDING_PRODUCT_UPDATES_KEY,
|
||||||
JSON.stringify(pending)
|
JSON.stringify(pending),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +232,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
syncToServerRef.current?.(quantity);
|
syncToServerRef.current?.(quantity);
|
||||||
}, delay);
|
}, delay);
|
||||||
},
|
},
|
||||||
[t]
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
retrySyncRef.current = retrySync;
|
retrySyncRef.current = retrySync;
|
||||||
@@ -288,7 +295,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
cartItem,
|
cartItem,
|
||||||
clearPendingUpdate,
|
clearPendingUpdate,
|
||||||
t,
|
t,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
syncToServerRef.current = syncToServer;
|
syncToServerRef.current = syncToServer;
|
||||||
@@ -401,7 +408,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
toast.success(
|
toast.success(
|
||||||
data?.wasAdded
|
data?.wasAdded
|
||||||
? t("added_to_favorites")
|
? t("added_to_favorites")
|
||||||
: t("removed_from_favorites")
|
: t("removed_from_favorites"),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -409,10 +416,10 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
description: "Try again later",
|
description: "Try again later",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[product?.id, isFavorite, toggleFavoriteMutation, t]
|
[product?.id, isFavorite, toggleFavoriteMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmitReview = useCallback(
|
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(
|
const loadingSkeleton = useMemo(
|
||||||
@@ -464,7 +471,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (productLoading) return loadingSkeleton;
|
if (productLoading) return loadingSkeleton;
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"all_collections_loaded": "Все коллекции загружены"
|
"all_collections_loaded": "Все коллекции загружены"
|
||||||
},
|
},
|
||||||
|
|
||||||
"category": "Категория",
|
"category": "Категория",
|
||||||
"checkout": "Оформить заказ",
|
"checkout": "Оформить заказ",
|
||||||
"price_label": "Цена:",
|
"price_label": "Цена:",
|
||||||
|
"shipping_price": "Цена доставки",
|
||||||
"extra_price": "Доп. цена:",
|
"extra_price": "Доп. цена:",
|
||||||
"discount": "Скидка:",
|
"discount": "Скидка:",
|
||||||
"total_price": "Общая цена:",
|
"total_price": "Общая цена:",
|
||||||
@@ -45,6 +47,10 @@
|
|||||||
"delivery_type": "Тип доставки",
|
"delivery_type": "Тип доставки",
|
||||||
"delivery": "Доставка",
|
"delivery": "Доставка",
|
||||||
"pickup": "Самовывоз",
|
"pickup": "Самовывоз",
|
||||||
|
"standart": "Стандартная",
|
||||||
|
"self_pickup": "Самовывоз",
|
||||||
|
"region": "Çapar ugrat",
|
||||||
|
"free": "Бесплатно",
|
||||||
"payment_type": "Тип оплаты",
|
"payment_type": "Тип оплаты",
|
||||||
"cash": "Наличные",
|
"cash": "Наличные",
|
||||||
"card": "Карта",
|
"card": "Карта",
|
||||||
@@ -81,7 +87,7 @@
|
|||||||
"become_seller": "Стать продавцом",
|
"become_seller": "Стать продавцом",
|
||||||
"choose_region": "Выберите регион",
|
"choose_region": "Выберите регион",
|
||||||
"choose_or_enter_address": "Выберите или введите свой адрес",
|
"choose_or_enter_address": "Выберите или введите свой адрес",
|
||||||
"note": "Заметка",
|
"note": "Укажите подробнее свой адрес",
|
||||||
"seller_application_form": "Форма подачи заявления на открытие магазина",
|
"seller_application_form": "Форма подачи заявления на открытие магазина",
|
||||||
"phone": "Телефон",
|
"phone": "Телефон",
|
||||||
"unit_price": "Цена за 1 шт.:",
|
"unit_price": "Цена за 1 шт.:",
|
||||||
@@ -189,8 +195,19 @@
|
|||||||
"enter_email": "Введите email",
|
"enter_email": "Введите email",
|
||||||
"uploadPatent": "Загрузить патент",
|
"uploadPatent": "Загрузить патент",
|
||||||
"outOfStock": "Нет в наличии",
|
"outOfStock": "Нет в наличии",
|
||||||
"requiredField": "Обязательное поле",
|
"requiredField": "Обязательное поле",
|
||||||
"fileRequired": "Файл загрузить"
|
"fileRequired": "Файл загрузить",
|
||||||
|
"register_title": "Регистрация",
|
||||||
|
"register_description": "Заполните ваши данные",
|
||||||
|
"register_button": "Зарегистрироваться",
|
||||||
|
"name_required": "Введите ваше имя",
|
||||||
|
"address_required": "Введите ваш адрес",
|
||||||
|
"name_placeholder": "Ваше имя",
|
||||||
|
"address_placeholder": "Ваш адрес (например: Теджен)",
|
||||||
|
"checking": "Проверка...",
|
||||||
|
"registering": "Регистрация...",
|
||||||
|
"registration_success": "Регистрация успешна! Введите код",
|
||||||
|
"verify_title": "Введите код",
|
||||||
|
"verify_description": "Введите код, отправленный на ваш телефон",
|
||||||
|
"back": "Назад"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"categories": "Bölümler",
|
"categories": "Bölümler",
|
||||||
"products": "Azyk harytlary",
|
"products": "Harytlar",
|
||||||
"catalog": "Katalog",
|
"catalog": "Katalog",
|
||||||
"search": "Haryt gözleg",
|
"search": "Haryt gözleg",
|
||||||
"orders": "Sargytlar",
|
"orders": "Sargytlar",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"category": "Bölümler",
|
"category": "Bölümler",
|
||||||
"checkout": "Sargyt et",
|
"checkout": "Sargyt et",
|
||||||
"price_label": "Baha:",
|
"price_label": "Baha:",
|
||||||
|
"shipping_price": "Eltip berme bahasy",
|
||||||
"extra_price": "Goşmaça baha:",
|
"extra_price": "Goşmaça baha:",
|
||||||
"discount": "Arzanladyş:",
|
"discount": "Arzanladyş:",
|
||||||
"total_price": "Jemi baha:",
|
"total_price": "Jemi baha:",
|
||||||
@@ -45,10 +46,14 @@
|
|||||||
"delivery_type": "Elip bermek görnüşi",
|
"delivery_type": "Elip bermek görnüşi",
|
||||||
"delivery": "Eltip bermek",
|
"delivery": "Eltip bermek",
|
||||||
"pickup": "Özüň baryp al",
|
"pickup": "Özüň baryp al",
|
||||||
|
"standart": "Standart",
|
||||||
|
"self_pickup": "Özüm baryp aljak",
|
||||||
|
"region": "Çapar ugrat",
|
||||||
|
"free": "Mugt",
|
||||||
"payment_type": "Töleg görnüşi",
|
"payment_type": "Töleg görnüşi",
|
||||||
"cash": "Nagt",
|
"cash": "Nagt",
|
||||||
"card": "Kartdan tölemek",
|
"card": "Kartdan tölemek",
|
||||||
"choose_address": "Adres saýla",
|
"choose_address": "Etrap saýla",
|
||||||
"brands": "Brendler",
|
"brands": "Brendler",
|
||||||
"color": "Reňk",
|
"color": "Reňk",
|
||||||
"price": "Baha",
|
"price": "Baha",
|
||||||
@@ -76,13 +81,13 @@
|
|||||||
"yes": "Hawa",
|
"yes": "Hawa",
|
||||||
"cart_empty": "Siziň söwda sebediňiz boş",
|
"cart_empty": "Siziň söwda sebediňiz boş",
|
||||||
"add_to_cart": "Sebede goş",
|
"add_to_cart": "Sebede goş",
|
||||||
|
|
||||||
"go_to_cart": "Sebede geçmek",
|
"go_to_cart": "Sebede geçmek",
|
||||||
"products": "Azyk harytlary",
|
"products": "Harytlar",
|
||||||
"become_seller": "Satyjy bolmak",
|
"become_seller": "Satyjy bolmak",
|
||||||
"choose_region": "Etrap saýlaň",
|
"choose_region": "Welaýat saýlaň",
|
||||||
"choose_or_enter_address": "Salgyňyzy saýlaň ýa-da ýazyň",
|
"choose_or_enter_address": "Salgyňyzy saýlaň ýa-da ýazyň",
|
||||||
"note": "Bellik",
|
"note": "Adresiňiz barada giňişleýin ýazyň",
|
||||||
"seller_application_form": "Dükan açmak üçin arza görnüşi",
|
"seller_application_form": "Dükan açmak üçin arza görnüşi",
|
||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"unit_price": "1 san bahasy:",
|
"unit_price": "1 san bahasy:",
|
||||||
@@ -191,5 +196,18 @@
|
|||||||
"uploadPatent": "Patent goş",
|
"uploadPatent": "Patent goş",
|
||||||
"outOfStock": "Ammarda ýok",
|
"outOfStock": "Ammarda ýok",
|
||||||
"requiredField": "Zerur maglumat",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
84
lib/api.ts
84
lib/api.ts
@@ -1,6 +1,10 @@
|
|||||||
// lib/api.ts
|
// 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";
|
import TokenStorage from "./tokenStorage";
|
||||||
|
|
||||||
const localeToApiLang = (locale: string): string => {
|
const localeToApiLang = (locale: string): string => {
|
||||||
@@ -42,27 +46,32 @@ class APIClient {
|
|||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add language parameter
|
// Add language parameter (except for POST requests to /orders)
|
||||||
let lang = "tm";
|
const url = config.url || "";
|
||||||
|
const isOrderPost =
|
||||||
if (typeof window !== "undefined") {
|
config.method?.toLowerCase() === "post" && url.includes("/orders");
|
||||||
if ((window as any).i18n?.language) {
|
|
||||||
lang = localeToApiLang((window as any).i18n.language);
|
if (!isOrderPost) {
|
||||||
} else {
|
let lang = "tm";
|
||||||
const pathLocale = window.location.pathname.split("/")[1];
|
|
||||||
if (pathLocale === "tm" || pathLocale === "ru") {
|
if (typeof window !== "undefined") {
|
||||||
lang = localeToApiLang(pathLocale);
|
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("?") ? "&" : "?";
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
config.url = `${url}${separator}lang=${lang}`;
|
||||||
config.url = `${url}${separator}lang=${lang}`;
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response interceptor
|
// Response interceptor
|
||||||
@@ -92,11 +101,12 @@ class APIClient {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
"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) {
|
if (newToken) {
|
||||||
TokenStorage.setGuestToken(newToken);
|
TokenStorage.setGuestToken(newToken);
|
||||||
this.processQueue(null);
|
this.processQueue(null);
|
||||||
@@ -105,11 +115,11 @@ class APIClient {
|
|||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
this.processQueue(refreshError);
|
this.processQueue(refreshError);
|
||||||
TokenStorage.clearTokens();
|
TokenStorage.clearTokens();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
} finally {
|
} finally {
|
||||||
this.isRefreshing = false;
|
this.isRefreshing = false;
|
||||||
@@ -131,7 +141,7 @@ class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,25 +156,43 @@ class APIClient {
|
|||||||
this.failedQueue = [];
|
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);
|
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);
|
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);
|
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);
|
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);
|
return this.client.delete<T>(url, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new APIClient();
|
export const apiClient = new APIClient();
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ interface LoginCredentials {
|
|||||||
|
|
||||||
interface RegisterData {
|
interface RegisterData {
|
||||||
phone_number: string;
|
phone_number: string;
|
||||||
name?: string;
|
name: string;
|
||||||
email?: string;
|
address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerifyTokenData {
|
interface VerifyTokenData {
|
||||||
@@ -52,31 +52,31 @@ function extractToken(data: AuthResponse): string {
|
|||||||
|
|
||||||
function handleAuthError(error: unknown): AuthError {
|
function handleAuthError(error: unknown): AuthError {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
if (error.code === 'ECONNABORTED') {
|
if (error.code === "ECONNABORTED") {
|
||||||
return {
|
return {
|
||||||
message: "Request timeout - server not responding",
|
message: "Request timeout - server not responding",
|
||||||
code: "TIMEOUT",
|
code: "TIMEOUT",
|
||||||
statusCode: 408
|
statusCode: 408,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
return {
|
return {
|
||||||
message: error.response.data?.message || "Authentication failed",
|
message: error.response.data?.message || "Authentication failed",
|
||||||
code: error.response.data?.code || "AUTH_ERROR",
|
code: error.response.data?.code || "AUTH_ERROR",
|
||||||
statusCode: error.response.status
|
statusCode: error.response.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (error.request) {
|
if (error.request) {
|
||||||
return {
|
return {
|
||||||
message: "Network error - cannot reach server",
|
message: "Network error - cannot reach server",
|
||||||
code: "NETWORK_ERROR",
|
code: "NETWORK_ERROR",
|
||||||
statusCode: 0
|
statusCode: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
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,
|
signal: controller.signal,
|
||||||
timeout: 10000
|
timeout: 10000,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
return extractToken(response.data);
|
return extractToken(response.data);
|
||||||
@@ -123,7 +123,7 @@ export function useGetGuestToken() {
|
|||||||
console.error("[Guest Token] Failed:", {
|
console.error("[Guest Token] Failed:", {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
statusCode: error.statusCode
|
statusCode: error.statusCode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
@@ -147,7 +147,7 @@ export function useLogin() {
|
|||||||
const response = await apiClient.post<AuthResponse>(
|
const response = await apiClient.post<AuthResponse>(
|
||||||
"/auth/login",
|
"/auth/login",
|
||||||
credentials,
|
credentials,
|
||||||
{ timeout: 15000 }
|
{ timeout: 15000 },
|
||||||
);
|
);
|
||||||
return extractToken(response.data);
|
return extractToken(response.data);
|
||||||
},
|
},
|
||||||
@@ -172,7 +172,7 @@ export function useRegister() {
|
|||||||
const response = await apiClient.post<AuthResponse>(
|
const response = await apiClient.post<AuthResponse>(
|
||||||
"/auth/register",
|
"/auth/register",
|
||||||
userData,
|
userData,
|
||||||
{ timeout: 15000 }
|
{ timeout: 15000 },
|
||||||
);
|
);
|
||||||
return extractToken(response.data);
|
return extractToken(response.data);
|
||||||
},
|
},
|
||||||
@@ -197,7 +197,7 @@ export function useVerifyToken() {
|
|||||||
const response = await apiClient.post<AuthResponse>(
|
const response = await apiClient.post<AuthResponse>(
|
||||||
"/auth/verify",
|
"/auth/verify",
|
||||||
verifyData,
|
verifyData,
|
||||||
{ timeout: 15000 }
|
{ timeout: 15000 },
|
||||||
);
|
);
|
||||||
return extractToken(response.data);
|
return extractToken(response.data);
|
||||||
},
|
},
|
||||||
@@ -222,7 +222,9 @@ export function useLogout() {
|
|||||||
try {
|
try {
|
||||||
await apiClient.post("/auth/logout", {}, { timeout: 5000 });
|
await apiClient.post("/auth/logout", {}, { timeout: 5000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Logout] Server call failed, clearing local state anyway");
|
console.warn(
|
||||||
|
"[Logout] Server call failed, clearing local state anyway",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -234,4 +236,4 @@ export function useLogout() {
|
|||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ export interface CartItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CartResponse {
|
export interface CartResponse {
|
||||||
message?: string;
|
message: string;
|
||||||
|
errorDetails?: string;
|
||||||
data: CartItem[];
|
data: CartItem[];
|
||||||
count?: number;
|
count?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -200,6 +201,7 @@ export interface Order {
|
|||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
shipping_method: string;
|
shipping_method: string;
|
||||||
|
shipping_price?: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
customer_phone: string;
|
customer_phone: string;
|
||||||
@@ -235,7 +237,7 @@ export interface CreateOrderRequest {
|
|||||||
delivery_time?: string;
|
delivery_time?: string;
|
||||||
delivery_at?: string;
|
delivery_at?: string;
|
||||||
region: string;
|
region: string;
|
||||||
note?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderPayload {
|
export interface CreateOrderPayload {
|
||||||
@@ -243,11 +245,12 @@ export interface CreateOrderPayload {
|
|||||||
customer_phone?: string;
|
customer_phone?: string;
|
||||||
customer_address: string;
|
customer_address: string;
|
||||||
shipping_method: string;
|
shipping_method: string;
|
||||||
|
shipping_price: number;
|
||||||
payment_type_id: number;
|
payment_type_id: number;
|
||||||
delivery_time?: string;
|
delivery_time?: string;
|
||||||
delivery_at?: string;
|
delivery_at?: string;
|
||||||
region: string;
|
region: string;
|
||||||
note?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination Types
|
// Pagination Types
|
||||||
@@ -396,6 +399,11 @@ export interface ShippingMethod {
|
|||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderDelivery {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Generic API Error Response
|
// Generic API Error Response
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const nextConfig: NextConfig = {
|
|||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "https",
|
||||||
hostname: "shop.post.tm",
|
hostname: "hyzmat.app",
|
||||||
// port: "8080",
|
// port: "8080",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
732
package-lock.json
generated
732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user