Compare commits

..

8 Commits

Author SHA1 Message Date
@jcarymuhammedow
2ab9eab656 Merge branch 'main' of https://git.webulgam.com/nurmuhammet/postshop-frontend 2026-03-02 17:46:45 +05:00
@jcarymuhammedow
c13a4655bf added shipping method 2026-03-02 17:46:18 +05:00
Mekan1206
db7889fb7a update again 2026-02-14 22:54:03 +05:00
Mekan1206
1b378ccf79 fix critical package 2026-02-14 22:43:48 +05:00
@jcarymuhammedow
bf5980e3b3 fixed order, image carousel 2026-02-05 19:01:57 +05:00
@jcarymuhammedow
b546deeac0 added zoom function, fixed cart quantitu bug 2026-02-05 16:38:39 +05:00
a1b766fb3b change preview to start 2026-02-04 19:37:11 +05:00
a51a84409f add .env.example 2026-02-04 19:36:24 +05:00
19 changed files with 1452 additions and 627 deletions

6
.env.example Normal file
View 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
View File

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

View File

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

View File

@@ -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,17 +84,17 @@ 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 => {
@@ -95,7 +102,7 @@ export default function CartPage() {
}; };
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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "Назад"
} }

View File

@@ -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",
@@ -78,11 +83,11 @@
"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"
} }

View File

@@ -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 =
config.method?.toLowerCase() === "post" && url.includes("/orders");
if (typeof window !== "undefined") { if (!isOrderPost) {
if ((window as any).i18n?.language) { let lang = "tm";
lang = localeToApiLang((window as any).i18n.language);
} else { if (typeof window !== "undefined") {
const pathLocale = window.location.pathname.split("/")[1]; if ((window as any).i18n?.language) {
if (pathLocale === "tm" || pathLocale === "ru") { lang = localeToApiLang((window as any).i18n.language);
lang = localeToApiLang(pathLocale); } 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,10 +101,11 @@ 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);
@@ -131,7 +141,7 @@ class APIClient {
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
} }
@@ -146,23 +156,41 @@ 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);
} }
} }

View File

@@ -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: () => {

View File

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

View File

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

File diff suppressed because it is too large Load Diff