first commit

This commit is contained in:
Jelaletdin12
2026-02-01 20:55:57 +05:00
commit b8c871750a
128 changed files with 23114 additions and 0 deletions

View File

@@ -0,0 +1,458 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { Minus, Plus, Trash2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks";
import { useTranslations } from "next-intl";
import type { CartItem } from "@/lib/types/api";
interface CartItemCardProps {
item: CartItem;
onUpdate?: () => void;
}
// Session Storage Key
const PENDING_CART_UPDATES_KEY = "pendingCartUpdates";
interface PendingUpdate {
quantity: number;
timestamp: number;
retryCount: number;
}
export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
const t = useTranslations();
// Local UI State (Instant feedback)
const [localQuantity, setLocalQuantity] = useState(item.quantity);
// Sync State
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
// Stock limit modal
const [showStockModal, setShowStockModal] = useState(false);
// Refs
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Function refs to solve circular dependency
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const { mutate: updateQuantity } = useUpdateCartItemQuantity();
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart();
// Get available stock
const availableStock = item.product.stock || 0;
// Initialize from server state
useEffect(() => {
setLocalQuantity(item.quantity);
}, [item.quantity]);
// Save to sessionStorage
const savePendingUpdate = useCallback(
(quantity: number) => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
? JSON.parse(stored)
: {};
pending[item.product_id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current,
};
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
);
} catch (error) {
console.error("Failed to save pending update:", error);
}
},
[item.product_id]
);
// Remove from sessionStorage
const clearPendingUpdate = useCallback(() => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[item.product_id];
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_CART_UPDATES_KEY);
} else {
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
);
}
}
} catch (error) {
console.error("Failed to clear pending update:", error);
}
}, [item.product_id]);
// Exponential backoff retry
const retrySync = useCallback((quantity: number) => {
const maxRetries = 4;
const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
return;
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000); // Max 16s
retryCountRef.current++;
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity);
}, delay);
}, []);
// Update ref
retrySyncRef.current = retrySync;
// Sync to server
const syncToServer = useCallback(
(quantity: number) => {
// If already syncing, queue this update
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
// Mark as syncing
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
if (quantity <= 0) {
removeItem(item.product_id, {
onSuccess: () => {
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error("Remove failed:", error);
isRequestInFlightRef.current = false;
retrySyncRef.current?.(quantity);
},
});
} else {
updateQuantity(
{ productId: item.product_id, quantity },
{
onSuccess: () => {
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error("Update failed:", error);
isRequestInFlightRef.current = false;
// Rollback on error after retries exhausted
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity);
},
}
);
}
},
[
item.product_id,
item.quantity,
updateQuantity,
removeItem,
onUpdate,
clearPendingUpdate,
]
);
// Update ref
syncToServerRef.current = syncToServer;
// Load pending updates from sessionStorage on mount
useEffect(() => {
const loadPendingUpdates = () => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
const productPending = pending[item.product_id];
if (productPending && productPending.quantity !== item.quantity) {
// Apply pending update
setLocalQuantity(productPending.quantity);
pendingQuantityRef.current = productPending.quantity;
retryCountRef.current = productPending.retryCount;
// Trigger sync after a short delay
setTimeout(
() => syncToServerRef.current?.(productPending.quantity),
500
);
}
}
} catch (error) {
console.error("Failed to load pending updates:", error);
}
};
loadPendingUpdates();
}, [item.product_id, item.quantity]);
// Debounced sync
useEffect(() => {
// Clear existing timers
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// If local quantity matches server, no sync needed
if (localQuantity === item.quantity) {
return;
}
// Save to sessionStorage immediately
savePendingUpdate(localQuantity);
// Debounce the API call
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [localQuantity, item.quantity, savePendingUpdate]);
// Cleanup
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Check stock limit
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
}
// Optimistic update (instant UI feedback)
setLocalQuantity((prev) => prev + 1);
};
const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (localQuantity <= 1) {
handleDelete();
return;
}
// Optimistic update (instant UI feedback)
setLocalQuantity((prev) => prev - 1);
};
const handleDelete = () => {
setLocalQuantity(0);
clearPendingUpdate();
};
const getImageSrc = () => {
if (item.product.image) return item.product.image;
if (item.product.images && item.product.images.length > 0)
return item.product.images[0];
return "/placeholder.svg";
};
return (
<>
<Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden shrink-0">
<Image
src={getImageSrc()}
alt={item.product.name}
fill
className="object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3>
<p className="text-sm text-gray-600">
{item.seller?.name || "Store"}
</p>
{availableStock <= 5 && (
<p className="text-xs text-orange-600 font-medium">
{t("only_left", { count: availableStock })}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isRemoving}
className="w-fit cursor-pointer p-0 h-auto hover:bg-transparent hover:text-red-500"
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">
{t("unit_price")}{" "}
<span className="text-primary">{item.price_formatted}</span>
</p>
{item.discount_formatted &&
item.discount_formatted !== "0 TMT" && (
<p className="text-sm font-semibold">
{t("discount")} {item.discount_formatted}
</p>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">
{t("total_price")}
</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-lg font-semibold text-base">
{(
parseFloat(item.product.price_amount || "0") * localQuantity
).toFixed(2)}{" "}
TMT
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
className={` cursor-pointer rounded-lg bg-blue-50 ${
isSyncing ? "opacity-70" : ""
}`}
>
<Minus className="h-4 w-4" />
</Button>
<div className="w-12 text-center font-semibold relative">
{localQuantity}
{syncError && (
<span
className="absolute -top-1 -right-3 h-2 w-2 bg-red-500 rounded-full"
title="Sync error"
/>
)}
</div>
<Button
variant="outline"
size="icon"
onClick={handleQuantityIncrease}
// disabled={localQuantity >= availableStock}
className={`rounded-lg cursor-pointer bg-blue-50 ${
isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Plus className="h-4 w-4 text-[#007AFF]" />
</Button>
</div>
</div>
</div>
</Card>
{/* Stock Limit Modal */}
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: item.product.name,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={() => setShowStockModal(false)}
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function CartItemSkeleton() {
return (
<Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
<Skeleton className="w-[88px] h-[117px] rounded-xl" />
<div className="flex flex-col gap-2 flex-1">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-5 w-5 rounded" />
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-40 rounded-lg" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-10 rounded-xl" />
<Skeleton className="h-6 w-12" />
<Skeleton className="h-10 w-10 rounded-xl" />
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import { Truck, Warehouse } from "lucide-react"
import { Card } from "@/components/ui/card"
import { useTranslations } from "next-intl"
import type { DeliveryType } from "@/lib/types/api"
interface DeliveryTypeSelectorProps {
selectedType: DeliveryType
onSelect: (type: DeliveryType) => void
}
export default function DeliveryTypeSelector({
selectedType,
onSelect,
}: DeliveryTypeSelectorProps) {
const t = useTranslations()
const deliveryOptions: {
type: DeliveryType
label: string
icon: typeof Truck
}[] = [
{ type: "SELECTED_DELIVERY", label: t("delivery"), icon: Truck },
{ type: "PICK_UP", label: t("pickup"), icon: Warehouse },
]
return (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t("delivery_type")}</h3>
<div className="flex gap-2">
{deliveryOptions.map(({ type, label, icon: Icon }) => (
<Card
key={type}
className={`flex-1 cursor-pointer transition-all hover:shadow-md ${
selectedType === type
? "border-2 border-[#005bff] bg-blue-50"
: "border-2 border-gray-200"
}`}
onClick={() => onSelect(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<Icon
className={`h-8 w-8 ${
selectedType === type ? "text-[#005bff]" : "text-gray-600"
}`}
/>
<span className={`text-xs font-medium ${
selectedType === type ? "text-[#005bff]" : "text-gray-700"
}`}>
{label}
</span>
</div>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button";
import { ShoppingCart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function EmptyCart() {
const t=useTranslations();
const router=useRouter();
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<ShoppingCart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("cart_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("cart_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full cursor-pointer rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import DeliveryTypeSelector from "./DeliveryTypeSelector";
import { useTranslations } from "next-intl";
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api";
import { useState } from "react";
interface OrderBillingItem {
title: string;
value: string;
}
interface OrderBilling {
body: OrderBillingItem[];
footer: {
title: string;
value: string;
};
}
interface OrderSummaryProps {
order: {
id: number;
billing: OrderBilling;
};
paymentType: PaymentType | null;
deliveryType: DeliveryType;
selectedRegion: string;
selectedProvince: number | null;
note: string;
regionGroups: Record<string, Province[]>;
availableRegions: string[];
paymentTypes: PaymentType[];
phone: string;
name: string;
lastName: string;
onPhoneChange: (phone: string) => void;
onNameChange: (name: string) => void;
onLastNameChange: (lastName: string) => void;
onPaymentTypeChange: (type: PaymentType) => void;
onDeliveryTypeChange: (type: DeliveryType) => void;
onRegionChange: (regionCode: string) => void;
onProvinceChange: (provinceId: number) => void;
onNoteChange: (note: string) => void;
onCompleteOrder: () => void;
isLoading: boolean;
}
export default function OrderSummary({
order,
paymentType,
deliveryType,
selectedRegion,
selectedProvince,
note,
regionGroups,
availableRegions,
paymentTypes,
phone,
name,
lastName,
onPhoneChange,
onNameChange,
onLastNameChange,
onPaymentTypeChange,
onDeliveryTypeChange,
onRegionChange,
onProvinceChange,
onNoteChange,
onCompleteOrder,
isLoading,
}: OrderSummaryProps) {
const t = useTranslations();
const [showValidation, setShowValidation] = useState(false);
const provincesForSelectedRegion = selectedRegion
? regionGroups[selectedRegion] || []
: [];
const phoneDigits = phone.replace(/\D/g, "");
const isPhoneValid = phoneDigits.length === 11;
const isFormValid =
selectedRegion &&
selectedProvince &&
paymentType &&
isPhoneValid &&
name.trim() !== "" &&
lastName.trim() !== "";
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const prefix = "+993 ";
if (input.length < prefix.length) {
onPhoneChange(prefix);
return;
}
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
const limitedDigits = digitsOnly.substring(0, 8);
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
onPhoneChange(formattedPhone);
};
const handleCompleteOrderClick = () => {
setShowValidation(true);
if (isFormValid) {
onCompleteOrder();
}
};
return (
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
{/* Customer Information */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">
{t("customer_information")}
</h3>
<div className="space-y-5">
<div>
<Label className="text-sm font-medium mb-2 block">
{t("name")}
</Label>
<Input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder={t("name")}
className={`rounded-lg ${
showValidation && name.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && name.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
{t("last_name")}
</Label>
<Input
type="text"
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
placeholder={t("last_name")}
className={`rounded-lg ${
showValidation && lastName.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && lastName.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
{t("phone")}
</Label>
<Input
type="tel"
value={phone}
onChange={handlePhoneChange}
placeholder="+993 61 097651"
className={`rounded-lg ${
showValidation && !isPhoneValid ? "border-red-500" : ""
}`}
/>
{showValidation && !isPhoneValid && (
<p className="text-xs text-red-500 mt-1">
{t("requiredField")}
</p>
)}
</div>
</div>
</div>
{/* Payment Type */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
<div className="flex gap-2">
{paymentTypes.map((type) => (
<Card
key={type.id}
className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50"
: showValidation && !paymentType
? "border-2 border-red-500"
: "border-2 border-gray-200"
}`}
onClick={() => onPaymentTypeChange(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<span
className={`text-xs font-medium ${
paymentType?.id === type.id ? "text-[#005bff]" : ""
}`}
>
{type.name}
</span>
</div>
</Card>
))}
</div>
{showValidation && !paymentType && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Region Selection */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_region")}
</Label>
<RadioGroup
value={selectedRegion}
onValueChange={(value) => {
onRegionChange(value);
onProvinceChange(null as any);
}}
className="flex flex-wrap gap-4"
>
{availableRegions.map((regionCode) => (
<div key={regionCode} className="flex items-center space-x-2">
<RadioGroupItem
value={regionCode}
id={`region-${regionCode}`}
className={`border-2 ${
showValidation && !selectedRegion
? "border-red-500"
: "border-gray-400"
} data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white`}
/>
<Label
htmlFor={`region-${regionCode}`}
className="cursor-pointer uppercase"
>
{regionCode}
</Label>
</div>
))}
</RadioGroup>
{showValidation && !selectedRegion && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_address")}
</Label>
<Select
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger
className={`rounded-lg w-full ${
showValidation && !selectedProvince ? "border-red-500" : ""
}`}
>
<SelectValue placeholder={t("choose_address")} />
</SelectTrigger>
<SelectContent>
{provincesForSelectedRegion.map((province) => (
<SelectItem key={province.id} value={province.id.toString()}>
{province.name}
</SelectItem>
))}
</SelectContent>
</Select>
{showValidation && !selectedProvince && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
)}
{/* Note */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
<Textarea
value={note}
onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none"
rows={3}
placeholder={t("note")}
/>
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{order.billing.body.map((item, index) => (
<div
key={index}
className="flex justify-between text-base font-medium"
>
<span>{item.title}:</span>
<span>{item.value}</span>
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<span className="text-lg font-semibold">
{order.billing.footer.title}:
</span>
<span className="text-lg font-bold text-green-600">
{order.billing.footer.value}
</span>
</div>
<Button
onClick={handleCompleteOrderClick}
disabled={isLoading}
className="w-full rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg"
>
{isLoading ? `${t("order")}...` : t("order")}
</Button>
</Card>
);
}

View File

@@ -0,0 +1,81 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
export default function OrderSummarySkeleton() {
return (
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
{/* Customer Information */}
<div className="mb-6">
<Skeleton className="h-6 w-48 mb-3" />
<div className="space-y-4">
<div>
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
<div>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
</div>
</div>
{/* Payment Type */}
<div className="mb-6">
<Skeleton className="h-6 w-32 mb-3" />
<div className="flex gap-2">
<Skeleton className="flex-1 h-20 rounded-lg" />
<Skeleton className="flex-1 h-20 rounded-lg" />
</div>
</div>
{/* Region Selection */}
<div className="mb-6">
<Skeleton className="h-6 w-36 mb-3" />
<div className="flex flex-wrap gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
{/* Province Selection */}
<div className="mb-6">
<Skeleton className="h-6 w-40 mb-3" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Note */}
<div className="mb-6">
<Skeleton className="h-6 w-24 mb-3" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-6 w-28" />
</div>
<Skeleton className="h-12 w-full rounded-lg" />
</Card>
);
}

View File

@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
interface Province {
id: number
region: string
name: string
}
interface ProvincesResponse {
message: string
data: Province[]
}
export function useRegions() {
return useQuery({
queryKey: ["regions"],
queryFn: async () => {
const response = await apiClient.get<ProvincesResponse>("/provinces")
return response.data.data
},
staleTime: 1000 * 60 * 60, // 1 hour
})
}

View File

@@ -0,0 +1,508 @@
import {
useQuery,
useMutation,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { CartItem } from "@/lib/types/api";
import { useEffect } from "react";
interface CartResponse {
message: string;
data: CartItem[];
errorDetails?: string;
}
const pendingUpdates = new Map<number, number>();
let updateLock = false;
class CartEventEmitter {
private listeners: Set<() => void> = new Set();
subscribe(callback: () => void) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
emit() {
this.listeners.forEach((cb) => cb());
}
}
export const cartEvents = new CartEventEmitter();
function transformCartResponse(response: any): CartResponse {
if (
typeof response === "string" &&
(response.trim().startsWith("<!DOCTYPE") ||
response.trim().startsWith("<html"))
) {
return {
message: "error",
data: [],
errorDetails: "Server returned HTML instead of JSON.",
};
}
if (typeof response === "object") {
if (response.data) return response;
return { message: "success", data: [] };
}
if (typeof response === "string") {
try {
return JSON.parse(response);
} catch {
return { message: "error", data: [] };
}
}
return { message: "unknown", data: [] };
}
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["cart"],
queryFn: async () => {
const response = await apiClient.get("/carts");
const transformed = transformCartResponse(response.data);
return transformed;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: Infinity,
gcTime: 1000 * 60 * 5,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
...options,
});
useEffect(() => {
const unsubscribe = cartEvents.subscribe(() => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
});
return () => {
unsubscribe();
};
}, [queryClient]);
return query;
}
export function useAddToCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
quantity = 1,
}: {
productId: number;
quantity?: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data;
}
if (typeof response.data === "string") {
try {
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Added to cart" };
}
}
return { message: "success", data: "Added to cart" };
},
onMutate: async ({ productId, quantity }) => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
const existingItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (existingItem) {
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? {
...item,
product_quantity: item.product_quantity + quantity,
}
: item
);
} else {
updated.data = [
...updated.data,
{
product: { id: productId },
product_quantity: quantity,
} as any,
];
}
const finalItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (finalItem) {
pendingUpdates.set(productId, finalItem.product_quantity);
}
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
},
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
});
}
export function useRemoveFromCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number) => {
const params = new URLSearchParams({ product_id: String(productId) });
const response = await apiClient.patch("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
return [];
}
}
return [];
},
onMutate: async (productId) => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
if (pendingId !== productId) {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
}
});
updated.data = updated.data.filter(
(item: any) => item.product?.id !== productId
);
pendingUpdates.delete(productId);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
});
}
export function useCleanCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const response = await apiClient.delete("/carts", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
return [];
}
}
return [];
},
onMutate: async () => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
pendingUpdates.clear();
return { ...old, data: [] };
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
export function useUpdateCartItemQuantity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
quantity,
}: {
productId: number;
quantity: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 15000,
});
if (typeof response.data === "object" && response.data.data) {
return response.data;
}
if (typeof response.data === "string") {
try {
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Updated cart" };
}
}
return { message: "success", data: "Updated cart" };
},
onMutate: async ({ productId, quantity }) => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? { ...item, product_quantity: quantity }
: item
);
pendingUpdates.set(productId, quantity);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
throw error;
},
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
},
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: {
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);
return response.data;
},
onSuccess: (data) => {
if (data && data.payment_url) {
window.open(data.payment_url, '_blank')?.focus();
}
pendingUpdates.clear();
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return { ...old, data: [] };
});
cartEvents.emit();
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
onError: (error: any) => {
console.error(
"Create order error:",
error.response?.data?.message || error.message
);
},
});
}
export function useCartCount() {
const { data } = useCart();
return (
data?.data?.reduce(
(sum: number, item: any) => sum + (item.product_quantity || 0),
0
) || 0
);
}

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
interface PaymentType {
id: number
name: string
}
interface PaymentTypesResponse {
message: string
data: PaymentType[]
}
export function usePaymentTypes() {
return useQuery({
queryKey: ["paymentTypes"],
queryFn: async () => {
const response = await apiClient.get<PaymentTypesResponse>("/order-payments")
return response.data.data
},
staleTime: 1000 * 60 * 60, // 1 hour
})
}