connected api with profile, order
This commit is contained in:
@@ -1,53 +1,79 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react";
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator";
|
||||||
import CartItemCard from "./ui/CartItemCard"
|
import CartItemCard from "../../../features/cart/components/CartItemCard";
|
||||||
import OrderSummary from "./ui/OrderSummary"
|
import OrderSummary from "../../../features/cart/components/OrderSummary";
|
||||||
import { useCart, useCreateOrder, useRegions, useAddresses, usePaymentTypes } from "@/lib/hooks"
|
import {
|
||||||
import { useTranslations } from "next-intl"
|
useCart,
|
||||||
import { useRouter } from "next/navigation"
|
useCreateOrder,
|
||||||
import type { DeliveryType, PaymentTypeOption } from "./ui/types"
|
useRegions,
|
||||||
|
usePaymentTypes,
|
||||||
|
} from "@/lib/hooks";
|
||||||
|
import { userStore } from "@/features/profile/userStore";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { DeliveryType, PaymentType } from "../../../features/cart/types";
|
||||||
|
|
||||||
export default function CartPage() {
|
export default function CartPage() {
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [paymentType, setPaymentType] = useState<PaymentTypeOption | null>(null)
|
const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
|
||||||
const [deliveryType, setDeliveryType] = useState<DeliveryType>("SELECTED_DELIVERY")
|
const [deliveryType, setDeliveryType] = useState<DeliveryType>("SELECTED_DELIVERY");
|
||||||
const [selectedRegion, setSelectedRegion] = useState<string | null>(null)
|
const [selectedRegion, setSelectedRegion] = useState<string>("");
|
||||||
const [selectedAddress, setSelectedAddress] = useState<string>("")
|
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
|
||||||
const [note, setNote] = useState<string>("")
|
const [note, setNote] = useState<string>("");
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
const t = useTranslations()
|
const t = useTranslations();
|
||||||
|
|
||||||
// useCart dönen data yapısı: { message: "success", data: [...] }
|
const { data: cartResponse, isLoading, isError } = useCart();
|
||||||
const { data: cartResponse, isLoading, isError } = useCart()
|
const { data: provinces = [] } = useRegions();
|
||||||
const { data: regions = [] } = useRegions()
|
const { data: paymentTypes = [] } = usePaymentTypes();
|
||||||
const { data: addresses = [] } = useAddresses()
|
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
|
||||||
const { data: paymentTypes = [] } = usePaymentTypes()
|
|
||||||
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder()
|
|
||||||
|
|
||||||
// Cart items'ı doğru şekilde al
|
const cartItems = cartResponse?.data || [];
|
||||||
const cartItems = cartResponse?.data || []
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true)
|
setIsClient(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
|
const regionGroups = provinces.reduce((acc, province) => {
|
||||||
|
if (!acc[province.region]) {
|
||||||
|
acc[province.region] = [];
|
||||||
|
}
|
||||||
|
acc[province.region].push(province);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof provinces>);
|
||||||
|
|
||||||
|
const availableRegions = Object.keys(regionGroups);
|
||||||
|
|
||||||
const handleDeliveryTypeChange = (type: DeliveryType) => {
|
const handleDeliveryTypeChange = (type: DeliveryType) => {
|
||||||
setDeliveryType(type)
|
setDeliveryType(type);
|
||||||
setSelectedAddress("")
|
setSelectedProvince(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCompleteOrder = () => {
|
const handleCompleteOrder = () => {
|
||||||
if (!selectedRegion || !selectedAddress || !paymentType) {
|
if (!selectedRegion || !selectedProvince || !paymentType) {
|
||||||
console.warn("Missing required fields for order")
|
console.warn("Missing required fields for order");
|
||||||
return
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
|
||||||
|
if (!selectedProvinceData) return;
|
||||||
|
|
||||||
|
// Kullanıcı bilgilerini store'dan al
|
||||||
|
const orderData = userStore.getOrderData();
|
||||||
|
if (!orderData) {
|
||||||
|
console.error("User data not found");
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrder(
|
createOrder(
|
||||||
{
|
{
|
||||||
customer_address: selectedAddress,
|
customer_name: orderData.customer_name,
|
||||||
|
customer_phone: orderData.customer_phone,
|
||||||
|
customer_address: selectedProvinceData.name,
|
||||||
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart",
|
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart",
|
||||||
payment_type_id: paymentType.id,
|
payment_type_id: paymentType.id,
|
||||||
region: selectedRegion,
|
region: selectedRegion,
|
||||||
@@ -55,30 +81,30 @@ export default function CartPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push(`/orders`)
|
router.push(`/orders`);
|
||||||
},
|
},
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isClient) return null
|
if (!isClient) return null;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
|
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
|
||||||
<p>{t("loading")}</p>
|
<p>{t("loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || cartItems.length === 0) {
|
if (isError || cartItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
|
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl text-gray-400 font-semibold">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl text-gray-400 font-semibold">
|
||||||
{t("emptyCart") || "Your cart is empty"}
|
{t("emptyCart")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
@@ -100,49 +126,43 @@ export default function CartPage() {
|
|||||||
placeOrder: t("order"),
|
placeOrder: t("order"),
|
||||||
emptyCart: t("cart_empty"),
|
emptyCart: t("cart_empty"),
|
||||||
map: t("address"),
|
map: t("address"),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Group items by seller (from channel)
|
const itemsBySeller = cartItems.reduce((acc, item) => {
|
||||||
const itemsBySeller = cartItems.reduce(
|
const sellerId = item.product.channel?.[0]?.id || 0;
|
||||||
(acc, item) => {
|
const sellerName = item.product.channel?.[0]?.name || "Unknown Seller";
|
||||||
const sellerId = item.product.channel?.[0]?.id || 0
|
|
||||||
const sellerName = item.product.channel?.[0]?.name || "Unknown Seller"
|
|
||||||
|
|
||||||
if (!acc[sellerId]) {
|
if (!acc[sellerId]) {
|
||||||
acc[sellerId] = {
|
acc[sellerId] = {
|
||||||
seller: { id: sellerId, name: sellerName },
|
seller: { id: sellerId, name: sellerName },
|
||||||
items: []
|
items: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
acc[sellerId].items.push(item);
|
||||||
acc[sellerId].items.push(item)
|
return acc;
|
||||||
return acc
|
}, {} as Record<number, { seller: any; items: typeof cartItems }>);
|
||||||
},
|
|
||||||
{} as Record<number, { seller: any; items: typeof cartItems }>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Calculate total
|
|
||||||
const totalAmount = cartItems.reduce((sum, item) => {
|
const totalAmount = 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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 min-h-screen">
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
<h1 className="text-3xl font-bold mb-6">{translations.cart}</h1>
|
<h1 className="text-3xl font-bold mb-6">{translations.cart}</h1>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
{/* Cart Items Section */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Card className="p-6 rounded-xl">
|
<Card className="p-6 rounded-xl">
|
||||||
{/* Sellers */}
|
{Object.entries(itemsBySeller).map(
|
||||||
{Object.entries(itemsBySeller).map(([sellerId, { seller, items }]) => (
|
([sellerId, { seller, items }]) => (
|
||||||
<div key={sellerId} className="mb-6">
|
<div key={sellerId} className="mb-6">
|
||||||
<p className="text-base font-semibold mb-3">{seller.name}</p>
|
<p className="text-base font-semibold mb-3">{seller.name}</p>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const price = parseFloat(item.product.price_amount || "0")
|
const price = parseFloat(item.product.price_amount || "0");
|
||||||
const quantity = item.product_quantity
|
const quantity = item.product_quantity;
|
||||||
const total = price * quantity
|
const total = price * quantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CartItemCard
|
<CartItemCard
|
||||||
@@ -159,46 +179,55 @@ export default function CartPage() {
|
|||||||
discount_formatted: "0 TMT",
|
discount_formatted: "0 TMT",
|
||||||
product: {
|
product: {
|
||||||
...item.product,
|
...item.product,
|
||||||
image: item.product.media?.[0]?.images_800x800 || item.product.media?.[0]?.thumbnail,
|
image:
|
||||||
images: item.product.media?.map(m => m.images_800x800 || m.thumbnail) || []
|
item.product.media?.[0]?.images_800x800 ||
|
||||||
}
|
item.product.media?.[0]?.thumbnail,
|
||||||
|
images:
|
||||||
|
item.product.media?.map(
|
||||||
|
(m) => m.images_800x800 || m.thumbnail
|
||||||
|
) || [],
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
translations={translations}
|
translations={translations}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(itemsBySeller).length > 1 && <Separator className="mt-4" />}
|
{Object.entries(itemsBySeller).length > 1 && (
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Summary Sidebar */}
|
|
||||||
<OrderSummary
|
<OrderSummary
|
||||||
order={{
|
order={{
|
||||||
id: 1,
|
id: 1,
|
||||||
seller: { id: 1, name: "Store" },
|
seller: { id: 1, name: "Store" },
|
||||||
items: cartItems.map(item => ({
|
items: cartItems.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
quantity: item.product_quantity,
|
quantity: item.product_quantity,
|
||||||
price: parseFloat(item.product.price_amount || "0"),
|
price: parseFloat(item.product.price_amount || "0"),
|
||||||
total: parseFloat(item.product.price_amount || "0") * item.product_quantity,
|
total:
|
||||||
|
parseFloat(item.product.price_amount || "0") *
|
||||||
|
item.product_quantity,
|
||||||
seller: {
|
seller: {
|
||||||
id: item.product.channel?.[0]?.id || 0,
|
id: item.product.channel?.[0]?.id || 0,
|
||||||
name: item.product.channel?.[0]?.name || "Unknown"
|
name: item.product.channel?.[0]?.name || "Unknown",
|
||||||
}
|
},
|
||||||
})),
|
})),
|
||||||
billing: {
|
billing: {
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
title: t("goods"),
|
title: t("goods"),
|
||||||
value: `${totalAmount.toFixed(2)} TMT`
|
value: `${totalAmount.toFixed(2)} TMT`,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
footer: {
|
footer: {
|
||||||
title: t("total"),
|
title: t("total"),
|
||||||
value: `${totalAmount.toFixed(2)} TMT`
|
value: `${totalAmount.toFixed(2)} TMT`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -206,21 +235,20 @@ export default function CartPage() {
|
|||||||
paymentType={paymentType}
|
paymentType={paymentType}
|
||||||
deliveryType={deliveryType}
|
deliveryType={deliveryType}
|
||||||
selectedRegion={selectedRegion}
|
selectedRegion={selectedRegion}
|
||||||
selectedAddress={selectedAddress}
|
selectedProvince={selectedProvince}
|
||||||
note={note}
|
note={note}
|
||||||
regions={regions}
|
regionGroups={regionGroups}
|
||||||
addresses={addresses}
|
availableRegions={availableRegions}
|
||||||
paymentTypes={paymentTypes}
|
paymentTypes={paymentTypes}
|
||||||
onPaymentTypeChange={setPaymentType}
|
onPaymentTypeChange={setPaymentType}
|
||||||
onDeliveryTypeChange={handleDeliveryTypeChange}
|
onDeliveryTypeChange={handleDeliveryTypeChange}
|
||||||
onRegionChange={setSelectedRegion}
|
onRegionChange={setSelectedRegion}
|
||||||
onAddressChange={setSelectedAddress}
|
onProvinceChange={setSelectedProvince}
|
||||||
onNoteChange={setNote}
|
onNoteChange={setNote}
|
||||||
onMapOpen={() => {}}
|
|
||||||
onCompleteOrder={handleCompleteOrder}
|
onCompleteOrder={handleCompleteOrder}
|
||||||
isLoading={isCreatingOrder}
|
isLoading={isCreatingOrder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { MapPin } from "lucide-react"
|
|
||||||
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 DeliveryTypeSelector from "./DeliveryTypeSelector"
|
|
||||||
import type {
|
|
||||||
Order,
|
|
||||||
Region,
|
|
||||||
Address,
|
|
||||||
DeliveryType,
|
|
||||||
CartTranslations,
|
|
||||||
PaymentTypeOption
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
interface OrderSummaryProps {
|
|
||||||
order: Order
|
|
||||||
translations: CartTranslations
|
|
||||||
paymentType: PaymentTypeOption | null
|
|
||||||
deliveryType: DeliveryType
|
|
||||||
selectedRegion: string | null
|
|
||||||
selectedAddress: string
|
|
||||||
note: string
|
|
||||||
regions: Region[]
|
|
||||||
addresses: Address[]
|
|
||||||
paymentTypes: PaymentTypeOption[]
|
|
||||||
onPaymentTypeChange: (type: PaymentTypeOption) => void
|
|
||||||
onDeliveryTypeChange: (type: DeliveryType) => void
|
|
||||||
onRegionChange: (regionCode: string) => void
|
|
||||||
onAddressChange: (address: string) => void
|
|
||||||
onNoteChange: (note: string) => void
|
|
||||||
onMapOpen: () => void
|
|
||||||
onCompleteOrder: () => void
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrderSummary({
|
|
||||||
order,
|
|
||||||
translations: t,
|
|
||||||
paymentType,
|
|
||||||
deliveryType,
|
|
||||||
selectedRegion,
|
|
||||||
selectedAddress,
|
|
||||||
note,
|
|
||||||
regions,
|
|
||||||
addresses,
|
|
||||||
paymentTypes,
|
|
||||||
onPaymentTypeChange,
|
|
||||||
onDeliveryTypeChange,
|
|
||||||
onRegionChange,
|
|
||||||
onAddressChange,
|
|
||||||
onNoteChange,
|
|
||||||
onMapOpen,
|
|
||||||
onCompleteOrder,
|
|
||||||
isLoading,
|
|
||||||
}: OrderSummaryProps) {
|
|
||||||
// Filter addresses based on selected region
|
|
||||||
const filteredAddresses = selectedRegion
|
|
||||||
? addresses.filter((addr) => {
|
|
||||||
const region = regions.find((r) => r.code === selectedRegion)
|
|
||||||
return region && addr.region_id === region.id
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
// Validate form completion
|
|
||||||
const isFormValid = selectedRegion && selectedAddress && paymentType
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
|
|
||||||
{/* Payment Type Selection */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</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]"
|
|
||||||
: "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">{type.name}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery Type Selection */}
|
|
||||||
<DeliveryTypeSelector
|
|
||||||
selectedType={deliveryType}
|
|
||||||
onSelect={onDeliveryTypeChange}
|
|
||||||
translations={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Region Selection */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Label className="text-lg font-semibold mb-3 block">
|
|
||||||
{t.selectRegion}
|
|
||||||
</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={selectedRegion || ""}
|
|
||||||
onValueChange={onRegionChange}
|
|
||||||
className="flex flex-wrap gap-4"
|
|
||||||
>
|
|
||||||
{regions.map((region) => (
|
|
||||||
<div key={region.id} className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
value={region.code}
|
|
||||||
id={`region-${region.id}`}
|
|
||||||
className="border-2 border-gray-400 data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white data-[state=checked]:[&_svg]:fill-[#005bff] data-[state=checked]:[&_svg]:stroke-[#005bff]"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`region-${region.id}`}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{region.name}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Selection (only show when region is selected) */}
|
|
||||||
{selectedRegion && filteredAddresses.length > 0 && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<Label className="text-lg font-semibold mb-3 block">
|
|
||||||
{t.selectAddress}
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={selectedAddress} onValueChange={onAddressChange}>
|
|
||||||
<SelectTrigger className="rounded-xl">
|
|
||||||
<SelectValue placeholder={t.selectAddress} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredAddresses.map((addr) => (
|
|
||||||
<SelectItem key={addr.id} value={addr.address}>
|
|
||||||
{addr.title} - {addr.address}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={onMapOpen}
|
|
||||||
className="rounded-xl flex-shrink-0 bg-transparent"
|
|
||||||
>
|
|
||||||
<MapPin className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Note Input */}
|
|
||||||
<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 Summary */}
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
{/* Total Amount */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Complete Order Button */}
|
|
||||||
<Button
|
|
||||||
onClick={onCompleteOrder}
|
|
||||||
disabled={!isFormValid || isLoading}
|
|
||||||
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] cursor-pointer h-12 text-lg font-bold disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{isLoading ? `${t.placeOrder}...` : t.placeOrder}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import type { StaticImageData } from "next/image"
|
|
||||||
|
|
||||||
export interface CartItem {
|
|
||||||
id: number
|
|
||||||
product_id: number
|
|
||||||
product: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
images: (StaticImageData | string)[]
|
|
||||||
image?: StaticImageData | string
|
|
||||||
stock?: number
|
|
||||||
price_amount?: string
|
|
||||||
}
|
|
||||||
seller: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
quantity: number
|
|
||||||
product_quantity?: number // For compatibility with old API
|
|
||||||
price: number
|
|
||||||
total: number
|
|
||||||
price_formatted?: string
|
|
||||||
sub_total_formatted?: string
|
|
||||||
discount_formatted?: string
|
|
||||||
total_formatted?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Cart {
|
|
||||||
message: string
|
|
||||||
data: CartItem[]
|
|
||||||
errorDetails?: string
|
|
||||||
total?: number
|
|
||||||
total_formatted?: string
|
|
||||||
items?: CartItem[] // Alternative structure
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Order {
|
|
||||||
id: number
|
|
||||||
seller: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
items: CartItem[]
|
|
||||||
billing: {
|
|
||||||
body: Array<{ title: string; value: string }>
|
|
||||||
footer: { title: string; value: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Region {
|
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Address {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
region_id: number
|
|
||||||
address: string
|
|
||||||
phone?: string
|
|
||||||
is_default?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PickUpPoint {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
address: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentTypeOption {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CartTranslations {
|
|
||||||
cart: string
|
|
||||||
ordersIn: string
|
|
||||||
pricePerUnit: string
|
|
||||||
additionalPrice: string
|
|
||||||
discount: string
|
|
||||||
totalPrice: string
|
|
||||||
paymentType: string
|
|
||||||
cash: string
|
|
||||||
card: string
|
|
||||||
deliveryType: string
|
|
||||||
delivery: string
|
|
||||||
pickup: string
|
|
||||||
selectRegion: string
|
|
||||||
selectAddress: string
|
|
||||||
note: string
|
|
||||||
placeOrder: string
|
|
||||||
emptyCart: string
|
|
||||||
map: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaymentType = "CASH" | "CARD"
|
|
||||||
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP"
|
|
||||||
|
|
||||||
// API Response types
|
|
||||||
export interface ApiResponse<T> {
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
errorDetails?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateOrderPayload {
|
|
||||||
customer_name?: string
|
|
||||||
customer_phone?: string
|
|
||||||
customer_address: string
|
|
||||||
shipping_method: string
|
|
||||||
payment_type_id: number
|
|
||||||
delivery_time?: string
|
|
||||||
delivery_at?: string
|
|
||||||
region: string
|
|
||||||
note?: string
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,6 @@ export default async function CategoryPage(props: Props) {
|
|||||||
const params = await props.params
|
const params = await props.params
|
||||||
const { slug } = params
|
const { slug } = params
|
||||||
|
|
||||||
const CategoryPageClient = (await import("./ui/CategoryClient")).default
|
const CategoryPageClient = (await import("../../../../features/category/components/CategoryClient")).default
|
||||||
return <CategoryPageClient params={params} />
|
return <CategoryPageClient params={params} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ function ProductCard({
|
|||||||
onMouseEnter={() => onHover(productId)}
|
onMouseEnter={() => onHover(productId)}
|
||||||
onMouseLeave={() => onHover(null)}
|
onMouseLeave={() => onHover(null)}
|
||||||
>
|
>
|
||||||
<Link href={`/product/${product.slug || productId}`} className="block">
|
<Link href={`/product/${productId|| product.slug}`} className="block">
|
||||||
<div className="relative aspect-square bg-gray-50">
|
<div className="relative aspect-square bg-gray-50">
|
||||||
{/* Favorite Button */}
|
{/* Favorite Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function RootLayout({ children, params }: Props) {
|
|||||||
|
|
||||||
let messages
|
let messages
|
||||||
try {
|
try {
|
||||||
messages = (await import(`../../messages/${locale}.json`)).default
|
messages = (await import(`../../i18n/messages/${locale}.json`)).default
|
||||||
} catch {
|
} catch {
|
||||||
messages = {}
|
messages = {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import ClientProfilePage from "./client-page"
|
import ClientProfilePage from "../../../features/profile/components/client-page"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "My Profile | E-Commerce",
|
title: "My Profile | E-Commerce",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import OrdersPageClient from "./orders-page-client"
|
import OrdersPageClient from "../../../features/orders/components/orders-page-client"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "My Orders | E-Commerce",
|
title: "My Orders | E-Commerce",
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next";
|
||||||
import HomePage from "@/components/home/HomePage"
|
import HomePage from "@/features/home/components/HomePage";
|
||||||
|
|
||||||
export const revalidate = 300
|
export const revalidate = 300;
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params
|
const { locale } = await params;
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
ru: {
|
ru: {
|
||||||
title: "Интернет магазин - Лучшие товары по низким ценам",
|
title: "Интернет магазин - Лучшие товары по низким ценам",
|
||||||
description: "Качественные товары с быстрой доставкой по всей стране"
|
description: "Качественные товары с быстрой доставкой по всей стране",
|
||||||
},
|
},
|
||||||
tm: {
|
tm: {
|
||||||
title: "Satym dükanı - Iň gowy harytlar aşak bahada",
|
title: "Satym dükanı - Iň gowy harytlar aşak bahada",
|
||||||
description: "Suw harytly towarnama. Elektrika, eşik, ev we bag"
|
description: "Suw harytly towarnama. Elektrika, eşik, ev we bag",
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const { title, description } = meta[locale as keyof typeof meta] || meta.ru
|
const { title, description } = meta[locale as keyof typeof meta] || meta.ru;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
openGraph: { type: "website", locale, title, description }
|
openGraph: { type: "website", locale, title, description },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <HomePage />
|
return <HomePage />;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import ProductPageContent from "./ProductPageContent"
|
import ProductPageContent from "../../../../features/products/components/ProductPageContent"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ locale: string; slug: string }>
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
|
||||||
import { useEffect, useState, useCallback } from "react"
|
|
||||||
import InfiniteScroll from "react-infinite-scroll-component"
|
|
||||||
import HeroCarousel from "./Carousel"
|
|
||||||
import CategoryGrid from "./CategoryGrid"
|
|
||||||
import CollectionSection from "./ProductGrid"
|
|
||||||
import { useCategories, useCarousels, useCollections } from "@/lib/hooks"
|
|
||||||
import type { Collection } from "@/lib/types/api"
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const locale = useLocale()
|
|
||||||
const t = useTranslations("common")
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
const [visibleCollections, setVisibleCollections] = useState<Collection[]>([])
|
|
||||||
const [hasMore, setHasMore] = useState(true)
|
|
||||||
const itemsPerPage = 10
|
|
||||||
|
|
||||||
const { data: categories, isLoading: categoriesLoading, isError: categoriesError } = useCategories()
|
|
||||||
const { data: carousels, isLoading: carouselsLoading } = useCarousels()
|
|
||||||
const { data: collections, isLoading: collectionsLoading, isError: collectionsError } = useCollections()
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), [])
|
|
||||||
|
|
||||||
// Initialize visible collections when data first loads
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("=== Collections Data Change ===")
|
|
||||||
console.log("Collections:", collections)
|
|
||||||
console.log("Collections length:", collections?.length)
|
|
||||||
console.log("Visible collections length:", visibleCollections.length)
|
|
||||||
|
|
||||||
if (collections && collections.length > 0 && visibleCollections.length === 0) {
|
|
||||||
console.log("🟢 Initializing first batch of collections")
|
|
||||||
const initial = collections.slice(0, itemsPerPage)
|
|
||||||
console.log("Initial collections to show:", initial.length)
|
|
||||||
setVisibleCollections(initial)
|
|
||||||
setHasMore(collections.length > itemsPerPage)
|
|
||||||
console.log("Has more after init:", collections.length > itemsPerPage)
|
|
||||||
}
|
|
||||||
}, [collections, visibleCollections.length])
|
|
||||||
|
|
||||||
const loadMoreCollections = useCallback(() => {
|
|
||||||
console.log("=== loadMoreCollections Called ===")
|
|
||||||
console.log("Collections available:", collections?.length)
|
|
||||||
console.log("Visible collections:", visibleCollections.length)
|
|
||||||
console.log("Has more:", hasMore)
|
|
||||||
|
|
||||||
if (!collections) {
|
|
||||||
console.log("❌ No collections data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentLength = visibleCollections.length
|
|
||||||
const nextCollections = collections.slice(
|
|
||||||
currentLength,
|
|
||||||
currentLength + itemsPerPage
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log("Current length:", currentLength)
|
|
||||||
console.log("Next batch size:", nextCollections.length)
|
|
||||||
console.log("Next batch:", nextCollections.map(c => c.id))
|
|
||||||
|
|
||||||
if (nextCollections.length > 0) {
|
|
||||||
console.log("🟢 Adding", nextCollections.length, "more collections")
|
|
||||||
setVisibleCollections((prev) => {
|
|
||||||
const updated = [...prev, ...nextCollections]
|
|
||||||
console.log("Updated visible collections count:", updated.length)
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.log("⚠️ No more collections to load")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've loaded all collections
|
|
||||||
const newTotal = currentLength + nextCollections.length
|
|
||||||
const shouldHaveMore = newTotal < collections.length
|
|
||||||
console.log("New total:", newTotal, "/ Total available:", collections.length)
|
|
||||||
console.log("Should have more:", shouldHaveMore)
|
|
||||||
|
|
||||||
if (!shouldHaveMore) {
|
|
||||||
console.log("🔴 Setting hasMore to false")
|
|
||||||
setHasMore(false)
|
|
||||||
}
|
|
||||||
}, [collections, visibleCollections.length, itemsPerPage, hasMore])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("=== State Update ===")
|
|
||||||
console.log("Visible collections count:", visibleCollections.length)
|
|
||||||
console.log("Has more:", hasMore)
|
|
||||||
}, [visibleCollections.length, hasMore])
|
|
||||||
|
|
||||||
if (!mounted) return <div className="p-8">Loading...</div>
|
|
||||||
|
|
||||||
// Transform carousel data to match component props
|
|
||||||
const carouselItems = carousels?.map(carousel => ({
|
|
||||||
title: carousel.title || "",
|
|
||||||
image: carousel.image || carousel.thumbnail,
|
|
||||||
url: carousel.link || null
|
|
||||||
})) || []
|
|
||||||
|
|
||||||
console.log("=== Render ===")
|
|
||||||
console.log("Collections loading:", collectionsLoading)
|
|
||||||
console.log("Visible collections for render:", visibleCollections.length)
|
|
||||||
console.log("Has more for InfiniteScroll:", hasMore)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
|
|
||||||
{/* Hero Carousel with API data */}
|
|
||||||
{!carouselsLoading && carouselItems.length > 0 && (
|
|
||||||
<HeroCarousel items={carouselItems} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Categories Grid */}
|
|
||||||
<CategoryGrid
|
|
||||||
categories={categories}
|
|
||||||
isLoading={categoriesLoading}
|
|
||||||
isError={categoriesError}
|
|
||||||
locale={locale}
|
|
||||||
title={t("categories")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Collections Sections with Infinite Scroll */}
|
|
||||||
{collectionsError ? (
|
|
||||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
|
||||||
<p className="text-red-600">Failed to load collections. Please try again.</p>
|
|
||||||
</section>
|
|
||||||
) : collectionsLoading ? (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="bg-white rounded-2xl shadow-sm p-6">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, j) => (
|
|
||||||
<div key={j} className="h-64 bg-gray-200 rounded-lg animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-yellow-100 border border-yellow-400 rounded p-4 text-sm">
|
|
||||||
<strong>Debug Info:</strong><br/>
|
|
||||||
Total Collections: {collections?.length || 0}<br/>
|
|
||||||
Visible: {visibleCollections.length}<br/>
|
|
||||||
Has More: {hasMore ? "Yes" : "No"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InfiniteScroll
|
|
||||||
dataLength={visibleCollections.length}
|
|
||||||
next={loadMoreCollections}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loader={
|
|
||||||
<div className="text-center py-8 bg-blue-50 border-2 border-blue-200 rounded">
|
|
||||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
|
||||||
<p className="text-gray-500 mt-2 font-bold">Loading more collections...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
endMessage={
|
|
||||||
<div className="text-center py-8 bg-green-50 border-2 border-green-200 rounded">
|
|
||||||
<p className="text-gray-600 font-bold">✓ You've reached the end</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
scrollThreshold={0.8}
|
|
||||||
>
|
|
||||||
<div className="space-y-8">
|
|
||||||
{visibleCollections.map((collection, index) => (
|
|
||||||
<div key={collection.id}>
|
|
||||||
<div className="text-xs text-gray-400 mb-2">Collection #{index + 1}</div>
|
|
||||||
<CollectionSection
|
|
||||||
collection={collection}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import temp1 from "@/public/temp1.jpg"
|
|
||||||
import temp2 from "@/public/temp2.jpg"
|
|
||||||
import temp3 from "@/public/temp3.jpg"
|
|
||||||
import jbl from "@/public/jbl.png"
|
|
||||||
import jbll from "@/public/jbll.png"
|
|
||||||
import jbl3 from "@/public/jbl3.webp"
|
|
||||||
import jb from "@/public/jb.webp"
|
|
||||||
|
|
||||||
export const carouselItems = [
|
|
||||||
{ title: "Banner 1", image: temp1, url: "#" },
|
|
||||||
{ title: "Banner 2", image: temp2, url: "#" },
|
|
||||||
{ title: "Banner 3", image: temp3, url: "#" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const categories = [
|
|
||||||
{ id: 1, slug: "sneakers", name: "Sneakers", image: jbl },
|
|
||||||
{ id: 2, slug: "boots", name: "Boots", image: jbl3 },
|
|
||||||
{ id: 3, slug: "sandals", name: "Sandals", image: jbll },
|
|
||||||
{ id: 4, slug: "heels", name: "Heels", image: jb },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const products = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Nike Air Max 270",
|
|
||||||
struct_price_text: "$120",
|
|
||||||
price: 120,
|
|
||||||
images: [jb, jbll, jbl, jbl3],
|
|
||||||
is_favorite: false,
|
|
||||||
labels: [{ text: "New", bg_color: "#10B981" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Adidas Ultraboost",
|
|
||||||
struct_price_text: "$150",
|
|
||||||
price: 150,
|
|
||||||
images: [jbll, jb, jbl, jbl3],
|
|
||||||
is_favorite: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Puma RS-X",
|
|
||||||
struct_price_text: "$110",
|
|
||||||
price: 110,
|
|
||||||
images: [jbl3, jbll, jbl, jb],
|
|
||||||
is_favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "New Balance 327",
|
|
||||||
struct_price_text: "$130",
|
|
||||||
price: 130,
|
|
||||||
images: [jbl, jbll, jb, jbl3],
|
|
||||||
is_favorite: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -3,18 +3,24 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { X, Menu, Search, Store } from "lucide-react"
|
import { X, Menu, Search, Store, LogOut, User as UserIcon } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import Logo from "@/public/logo.png"
|
import Logo from "@/public/logo.png"
|
||||||
import CategoryMenu from "./ui/CategoryMenu"
|
import CategoryMenu from "./ui/CategoryMenu"
|
||||||
import SearchBar from "./ui/SearchBar"
|
import SearchBar from "./ui/SearchBar"
|
||||||
import AuthDialog from "./ui/AuthDialog"
|
import AuthDialog from "./ui/AuthDialog"
|
||||||
import ActionButtons from "./ui/ActionButtons"
|
import ActionButtons from "./ui/ActionButtons"
|
||||||
import LanguageSelector from "./ui/LanguageSelector"
|
import LanguageSelector from "./ui/LanguageSelector"
|
||||||
|
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth"
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
locale?: string
|
locale?: string
|
||||||
isAuthenticated?: boolean
|
|
||||||
translations?: {
|
translations?: {
|
||||||
catalog: string
|
catalog: string
|
||||||
search: string
|
search: string
|
||||||
@@ -27,8 +33,17 @@ interface HeaderProps {
|
|||||||
phone: string
|
phone: string
|
||||||
code: string
|
code: string
|
||||||
send: string
|
send: string
|
||||||
|
verify: string
|
||||||
|
sending: string
|
||||||
|
verifying: string
|
||||||
enterPhone: string
|
enterPhone: string
|
||||||
weWillSendCode: string
|
weWillSendCode: string
|
||||||
|
invalidPhone: string
|
||||||
|
invalidCode: string
|
||||||
|
loginSuccess: string
|
||||||
|
codeSent: string
|
||||||
|
logout: string
|
||||||
|
loggingOut: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +59,29 @@ const DEFAULT_TRANSLATIONS = {
|
|||||||
phone: "Номер телефона",
|
phone: "Номер телефона",
|
||||||
code: "Код",
|
code: "Код",
|
||||||
send: "Отправить",
|
send: "Отправить",
|
||||||
|
verify: "Подтвердить",
|
||||||
|
sending: "Отправка...",
|
||||||
|
verifying: "Проверка...",
|
||||||
enterPhone: "Введите свой номер телефона",
|
enterPhone: "Введите свой номер телефона",
|
||||||
weWillSendCode: "Мы вышлем вам код",
|
weWillSendCode: "Мы вышлем вам код",
|
||||||
|
invalidPhone: "Неверный номер телефона",
|
||||||
|
invalidCode: "Неверный код",
|
||||||
|
loginSuccess: "Вход выполнен успешно",
|
||||||
|
codeSent: "Код отправлен на ваш номер",
|
||||||
|
logout: "Выйти",
|
||||||
|
loggingOut: "Выход...",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ locale = "ru", isAuthenticated = false, translations }: HeaderProps) {
|
export default function Header({ locale = "ru", translations }: HeaderProps) {
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
|
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
|
||||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false)
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false)
|
||||||
const [isLoginOpen, setIsLoginOpen] = useState(false)
|
const [isLoginOpen, setIsLoginOpen] = useState(false)
|
||||||
|
|
||||||
const t = translations || DEFAULT_TRANSLATIONS
|
const t = { ...DEFAULT_TRANSLATIONS, ...translations }
|
||||||
|
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStatus()
|
||||||
|
const { mutate: logout, isPending: isLoggingOut } = useLogout()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true)
|
setIsClient(true)
|
||||||
@@ -68,6 +95,10 @@ export default function Header({ locale = "ru", isAuthenticated = false, transla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen)
|
const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen)
|
||||||
const closeCategoryMenu = () => setIsCategoryOpen(false)
|
const closeCategoryMenu = () => setIsCategoryOpen(false)
|
||||||
|
|
||||||
@@ -80,7 +111,7 @@ export default function Header({ locale = "ru", isAuthenticated = false, transla
|
|||||||
<div className="flex h-16 items-center justify-between gap-4">
|
<div className="flex h-16 items-center justify-between gap-4">
|
||||||
<Link href="/" className="shrink-0">
|
<Link href="/" className="shrink-0">
|
||||||
<div className="relative h-8 w-[180px]">
|
<div className="relative h-8 w-[180px]">
|
||||||
<Image src={Logo || "/placeholder.svg"} alt="Logo" fill className="object-contain" priority />
|
<Image src={Logo} alt="Logo" fill className="object-contain" priority />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -106,6 +137,36 @@ export default function Header({ locale = "ru", isAuthenticated = false, transla
|
|||||||
|
|
||||||
<SearchBar isMobile={false} searchPlaceholder={t.search} className="hidden flex-1 md:flex" />
|
<SearchBar isMobile={false} searchPlaceholder={t.search} className="hidden flex-1 md:flex" />
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />
|
||||||
|
) : isAuthenticated ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2">
|
||||||
|
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-xs text-gray-700">{t.profile}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => (window.location.href = `/${locale}/me`)}>
|
||||||
|
<UserIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t.profile}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{isLoggingOut ? t.loggingOut : t.logout}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={handleAuthClick}>
|
||||||
|
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-xs text-gray-700">{t.login}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
onAuthClick={handleAuthClick}
|
onAuthClick={handleAuthClick}
|
||||||
@@ -146,6 +207,13 @@ export default function Header({ locale = "ru", isAuthenticated = false, transla
|
|||||||
phone: t.phone,
|
phone: t.phone,
|
||||||
code: t.code,
|
code: t.code,
|
||||||
send: t.send,
|
send: t.send,
|
||||||
|
verify: t.verify,
|
||||||
|
sending: t.sending,
|
||||||
|
verifying: t.verifying,
|
||||||
|
invalidPhone: t.invalidPhone,
|
||||||
|
invalidCode: t.invalidCode,
|
||||||
|
loginSuccess: t.loginSuccess,
|
||||||
|
codeSent: t.codeSent,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,59 +1,112 @@
|
|||||||
import React, { useState } from "react";
|
"use client"
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import React, { useState } from "react"
|
||||||
import { Input } from "@/components/ui/input";
|
import Image from "next/image"
|
||||||
import {
|
import { Button } from "@/components/ui/button"
|
||||||
Dialog,
|
import { Input } from "@/components/ui/input"
|
||||||
DialogContent,
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
DialogHeader,
|
import { toast } from "sonner"
|
||||||
DialogTitle,
|
import Logo from "@/public/logo.png"
|
||||||
} from "@/components/ui/dialog";
|
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth"
|
||||||
import Logo from "@/public/logo.png";
|
|
||||||
|
|
||||||
interface AuthDialogProps {
|
interface AuthDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
translations: {
|
translations: {
|
||||||
enterPhone: string;
|
enterPhone: string
|
||||||
weWillSendCode: string;
|
weWillSendCode: string
|
||||||
phone: string;
|
phone: string
|
||||||
code: string;
|
code: string
|
||||||
send: string;
|
send: string
|
||||||
};
|
verify: string
|
||||||
|
sending: string
|
||||||
|
verifying: string
|
||||||
|
invalidPhone: string
|
||||||
|
invalidCode: string
|
||||||
|
loginSuccess: string
|
||||||
|
codeSent: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthDialog({
|
export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDialogProps) {
|
||||||
isOpen,
|
const [phone, setPhone] = useState("993")
|
||||||
onClose,
|
const [otp, setOtp] = useState("")
|
||||||
translations: t,
|
const [otpSent, setOtpSent] = useState(false)
|
||||||
}: AuthDialogProps) {
|
const [rawPhone, setRawPhone] = useState("")
|
||||||
const [phone, setPhone] = useState("993");
|
|
||||||
const [otp, setOtp] = useState("");
|
|
||||||
const [otpSent, setOtpSent] = useState(false);
|
|
||||||
|
|
||||||
const handleSendOtp = () => {
|
const { mutate: login, isPending: isLoginLoading } = useLogin()
|
||||||
if (phone.length > 3) {
|
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken()
|
||||||
setOtpSent(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
// Here you can add authentication logic
|
|
||||||
resetDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetDialog = () => {
|
const resetDialog = () => {
|
||||||
onClose();
|
setOtpSent(false)
|
||||||
setOtpSent(false);
|
setPhone("993")
|
||||||
setPhone("993");
|
setOtp("")
|
||||||
setOtp("");
|
setRawPhone("")
|
||||||
};
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendOtp = () => {
|
||||||
|
const cleanPhone = phone.replace(/\D/g, "")
|
||||||
|
|
||||||
|
if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) {
|
||||||
|
toast.error(t.invalidPhone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = cleanPhone.substring(3)
|
||||||
|
setRawPhone(phoneNumber)
|
||||||
|
|
||||||
|
login(
|
||||||
|
{ phone_number: phoneNumber },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t.codeSent)
|
||||||
|
setOtpSent(true)
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.response?.data?.message || "Hata oluştu")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
if (otp.length < 4) {
|
||||||
|
toast.error(t.invalidCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyToken(
|
||||||
|
{
|
||||||
|
phone_number: rawPhone,
|
||||||
|
code: otp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t.loginSuccess)
|
||||||
|
resetDialog()
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.response?.data?.message || "Kod yanlış")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent, action: () => void) => {
|
const handleKeyPress = (e: React.KeyboardEvent, action: () => void) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
action();
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhoneInput = (value: string) => {
|
||||||
|
const cleaned = value.replace(/\D/g, "")
|
||||||
|
if (!cleaned.startsWith("993")) {
|
||||||
|
return "993"
|
||||||
|
}
|
||||||
|
return cleaned.substring(0, 11)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
||||||
@@ -64,34 +117,36 @@ export default function AuthDialog({
|
|||||||
<Image src={Logo} alt="Logo" fill className="object-contain" />
|
<Image src={Logo} alt="Logo" fill className="object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-2xl text-center">
|
<DialogTitle className="text-2xl text-center">{t.enterPhone}</DialogTitle>
|
||||||
{t.enterPhone}
|
<p className="text-center text-sm text-gray-600">{t.weWillSendCode}</p>
|
||||||
</DialogTitle>
|
|
||||||
<p className="text-center text-sm text-gray-600">
|
|
||||||
{t.weWillSendCode}
|
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder={t.phone}
|
placeholder={t.phone}
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(formatPhoneInput(e.target.value))}
|
||||||
className="h-12 rounded-xl"
|
className="h-12 rounded-xl"
|
||||||
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
|
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
|
||||||
disabled={otpSent}
|
disabled={otpSent || isLoginLoading}
|
||||||
|
maxLength={11}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Format: 99365123456</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{otpSent && (
|
{otpSent && (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t.code}
|
placeholder={t.code}
|
||||||
value={otp}
|
value={otp}
|
||||||
onChange={(e) => setOtp(e.target.value)}
|
onChange={(e) => 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, handleLogin)}
|
||||||
|
disabled={isVerifyLoading}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -99,11 +154,18 @@ export default function AuthDialog({
|
|||||||
onClick={otpSent ? handleLogin : handleSendOtp}
|
onClick={otpSent ? handleLogin : handleSendOtp}
|
||||||
className="w-full h-12 rounded-xl font-bold text-base"
|
className="w-full h-12 rounded-xl font-bold text-base"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
disabled={isLoginLoading || isVerifyLoading}
|
||||||
>
|
>
|
||||||
{t.send}
|
{isLoginLoading
|
||||||
|
? t.sending
|
||||||
|
: isVerifyLoading
|
||||||
|
? t.verifying
|
||||||
|
: otpSent
|
||||||
|
? t.verify
|
||||||
|
: t.send}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -1,72 +1,71 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from "react"
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation"
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth"
|
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
|
||||||
|
import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
|
||||||
|
|
||||||
interface AuthWrapperProps {
|
interface AuthWrapperProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean;
|
||||||
redirectTo?: string
|
redirectTo?: string;
|
||||||
locale: string
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AuthWrapper - Protects routes and manages authentication state
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* - Wrap protected pages: <AuthWrapper requireAuth>{content}</AuthWrapper>
|
|
||||||
* - Wrap public pages: <AuthWrapper>{content}</AuthWrapper>
|
|
||||||
*/
|
|
||||||
export default function AuthWrapper({
|
export default function AuthWrapper({
|
||||||
children,
|
children,
|
||||||
requireAuth = false,
|
requireAuth = false,
|
||||||
redirectTo,
|
redirectTo,
|
||||||
locale,
|
locale,
|
||||||
}: AuthWrapperProps) {
|
}: AuthWrapperProps) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStatus()
|
const { isAuthenticated, isLoading } = useAuthStatus();
|
||||||
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken()
|
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken();
|
||||||
|
|
||||||
|
// Login olmuş kullanıcı için profil bilgisini otomatik çek
|
||||||
|
useUserProfile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for auth check to complete
|
if (isLoading) return;
|
||||||
if (isLoading || isGettingGuestToken) return
|
|
||||||
|
|
||||||
// If auth required and user not authenticated
|
const authToken = document.cookie
|
||||||
if (requireAuth && !isAuthenticated) {
|
|
||||||
const redirect = redirectTo || `/${locale}/login`
|
|
||||||
const returnUrl = pathname !== redirect ? `?returnUrl=${encodeURIComponent(pathname)}` : ""
|
|
||||||
router.push(`${redirect}${returnUrl}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If already authenticated and trying to access login/register
|
|
||||||
if (isAuthenticated && (pathname.includes("/login") || pathname.includes("/register"))) {
|
|
||||||
router.push(`/${locale}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure guest token exists for non-authenticated users
|
|
||||||
if (!isAuthenticated && !requireAuth) {
|
|
||||||
const hasGuestToken = document.cookie
|
|
||||||
.split("; ")
|
.split("; ")
|
||||||
.some((row) => row.startsWith("guestToken="))
|
.find(row => row.startsWith("authToken="));
|
||||||
|
const guestToken = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find(row => row.startsWith("guestToken="));
|
||||||
|
|
||||||
if (!hasGuestToken) {
|
if (!authToken && !guestToken && !isGettingGuestToken) {
|
||||||
getGuestToken()
|
getGuestToken();
|
||||||
}
|
}
|
||||||
}
|
}, [isLoading, getGuestToken, isGettingGuestToken]);
|
||||||
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, getGuestToken, isGettingGuestToken])
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || isGettingGuestToken) return;
|
||||||
|
|
||||||
|
if (requireAuth && !isAuthenticated) {
|
||||||
|
const redirect = redirectTo || `/${locale}/login`;
|
||||||
|
const returnUrl = pathname !== redirect ? `?returnUrl=${encodeURIComponent(pathname)}` : "";
|
||||||
|
router.push(`${redirect}${returnUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && (pathname.includes("/login") || pathname.includes("/register"))) {
|
||||||
|
router.push(`/${locale}`);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, isGettingGuestToken]);
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (isLoading || (requireAuth && !isAuthenticated)) {
|
if (isLoading || (requireAuth && !isAuthenticated)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900" />
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
<p className="text-sm text-gray-600">Yükleniyor...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,7 @@ import Image from "next/image"
|
|||||||
import { Minus, Plus, Trash2 } from "lucide-react"
|
import { Minus, Plus, Trash2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import {
|
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks"
|
||||||
useUpdateCartItemQuantity,
|
|
||||||
useRemoveFromCart
|
|
||||||
} from "@/lib/hooks"
|
|
||||||
import type { CartItem, CartTranslations } from "./types"
|
import type { CartItem, CartTranslations } from "./types"
|
||||||
|
|
||||||
interface CartItemCardProps {
|
interface CartItemCardProps {
|
||||||
@@ -16,11 +13,7 @@ interface CartItemCardProps {
|
|||||||
onUpdate?: () => void
|
onUpdate?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CartItemCard({
|
export default function CartItemCard({ item, translations: t, onUpdate }: CartItemCardProps) {
|
||||||
item,
|
|
||||||
translations: t,
|
|
||||||
onUpdate
|
|
||||||
}: CartItemCardProps) {
|
|
||||||
const [localQuantity, setLocalQuantity] = useState(item.quantity)
|
const [localQuantity, setLocalQuantity] = useState(item.quantity)
|
||||||
const [pendingQuantity, setPendingQuantity] = useState(item.quantity)
|
const [pendingQuantity, setPendingQuantity] = useState(item.quantity)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -29,58 +22,40 @@ export default function CartItemCard({
|
|||||||
const { mutate: updateQuantity } = useUpdateCartItemQuantity()
|
const { mutate: updateQuantity } = useUpdateCartItemQuantity()
|
||||||
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
|
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
|
||||||
|
|
||||||
// Sync local quantity with server quantity
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalQuantity(item.quantity)
|
setLocalQuantity(item.quantity)
|
||||||
setPendingQuantity(item.quantity)
|
setPendingQuantity(item.quantity)
|
||||||
}, [item.quantity])
|
}, [item.quantity])
|
||||||
|
|
||||||
// Debounced update effect
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingQuantity === item.quantity) {
|
if (pendingQuantity === item.quantity) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous timeout
|
|
||||||
if (updateTimeoutRef.current) {
|
if (updateTimeoutRef.current) {
|
||||||
clearTimeout(updateTimeoutRef.current)
|
clearTimeout(updateTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set new timeout for update
|
|
||||||
updateTimeoutRef.current = setTimeout(() => {
|
updateTimeoutRef.current = setTimeout(() => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
if (pendingQuantity <= 0) {
|
if (pendingQuantity <= 0) {
|
||||||
removeItem(item.id, {
|
removeItem(item.product_id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => onUpdate?.(),
|
||||||
onUpdate?.()
|
onError: () => {
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to remove item:", error)
|
|
||||||
// Revert on error
|
|
||||||
setLocalQuantity(item.quantity)
|
setLocalQuantity(item.quantity)
|
||||||
setPendingQuantity(item.quantity)
|
setPendingQuantity(item.quantity)
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => setIsLoading(false),
|
||||||
setIsLoading(false)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
updateQuantity(
|
updateQuantity(
|
||||||
{ itemId: item.id, quantity: pendingQuantity },
|
{ productId: item.product_id, quantity: pendingQuantity },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => onUpdate?.(),
|
||||||
onUpdate?.()
|
onError: () => {
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to update quantity:", error)
|
|
||||||
// Revert on error
|
|
||||||
setLocalQuantity(item.quantity)
|
setLocalQuantity(item.quantity)
|
||||||
setPendingQuantity(item.quantity)
|
setPendingQuantity(item.quantity)
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => setIsLoading(false),
|
||||||
setIsLoading(false)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,12 +66,11 @@ export default function CartItemCard({
|
|||||||
clearTimeout(updateTimeoutRef.current)
|
clearTimeout(updateTimeoutRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pendingQuantity, item.quantity, item.id, updateQuantity, removeItem, onUpdate])
|
}, [pendingQuantity, item.quantity, item.product_id, updateQuantity, removeItem, onUpdate])
|
||||||
|
|
||||||
const handleQuantityIncrease = (e: React.MouseEvent) => {
|
const handleQuantityIncrease = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
|
|
||||||
const newQuantity = localQuantity + 1
|
const newQuantity = localQuantity + 1
|
||||||
@@ -107,11 +81,9 @@ export default function CartItemCard({
|
|||||||
const handleQuantityDecrease = (e: React.MouseEvent) => {
|
const handleQuantityDecrease = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
|
|
||||||
const newQuantity = localQuantity - 1
|
const newQuantity = localQuantity - 1
|
||||||
|
|
||||||
if (newQuantity < 1) {
|
if (newQuantity < 1) {
|
||||||
handleDelete()
|
handleDelete()
|
||||||
return
|
return
|
||||||
@@ -123,43 +95,28 @@ export default function CartItemCard({
|
|||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
removeItem(item.id, {
|
removeItem(item.product_id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => onUpdate?.(),
|
||||||
onUpdate?.()
|
onSettled: () => setIsLoading(false),
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to remove item:", error)
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setIsLoading(false)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getImageSrc = () => {
|
const getImageSrc = () => {
|
||||||
if (item.product.image) return item.product.image
|
if (item.product.image) return item.product.image
|
||||||
if (item.product.images && item.product.images.length > 0) {
|
if (item.product.images?.length > 0) return item.product.images[0]
|
||||||
return item.product.images[0]
|
|
||||||
}
|
|
||||||
return "/placeholder.svg"
|
return "/placeholder.svg"
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 shadow-none border">
|
<Card className="p-4 shadow-none border">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
{/* Product Image & Info */}
|
|
||||||
<div className="flex gap-4 flex-1">
|
<div className="flex gap-4 flex-1">
|
||||||
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
|
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image src={getImageSrc()} alt={item.product.name} fill className="object-contain" />
|
||||||
src={getImageSrc()}
|
|
||||||
alt={item.product.name}
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</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">{item.seller.name}</p>
|
<p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -172,33 +129,25 @@ export default function CartItemCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price & Quantity */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{t.pricePerUnit}{" "}
|
{t.pricePerUnit} <span className="text-primary">{item.price_formatted}</span>
|
||||||
<span className="text-primary">
|
|
||||||
{item.price_formatted || `${item.price} TMT`}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{t.additionalPrice}{" "}
|
{t.additionalPrice} {item.sub_total_formatted}
|
||||||
{item.sub_total_formatted || `${item.total} TMT`}
|
|
||||||
</p>
|
</p>
|
||||||
{item.discount_formatted && item.discount_formatted !== "0 TMT" && (
|
{item.discount_formatted && item.discount_formatted !== "0 TMT" && (
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">{t.discount} {item.discount_formatted}</p>
|
||||||
{t.discount} {item.discount_formatted}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold">{t.totalPrice}</span>
|
<span className="text-sm font-semibold">{t.totalPrice}</span>
|
||||||
<span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
|
<span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
|
||||||
{item.total_formatted || `${item.total} TMT`}
|
{item.total_formatted}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity Controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -209,9 +158,7 @@ export default function CartItemCard({
|
|||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-12 text-center font-semibold">
|
<div className="w-12 text-center font-semibold">{localQuantity}</div>
|
||||||
{localQuantity}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Truck, Warehouse } from "lucide-react"
|
import { Truck, Warehouse } from "lucide-react"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { DeliveryType, CartTranslations } from "./types"
|
import { DeliveryType, CartTranslations } from "../types"
|
||||||
|
|
||||||
interface DeliveryTypeSelectorProps {
|
interface DeliveryTypeSelectorProps {
|
||||||
selectedType: DeliveryType
|
selectedType: DeliveryType
|
||||||
176
features/cart/components/OrderSummary.tsx
Normal file
176
features/cart/components/OrderSummary.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"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 DeliveryTypeSelector from "./DeliveryTypeSelector"
|
||||||
|
import type { Order, Province, DeliveryType, CartTranslations, PaymentType } from "../types"
|
||||||
|
|
||||||
|
interface OrderSummaryProps {
|
||||||
|
order: Order
|
||||||
|
translations: CartTranslations
|
||||||
|
paymentType: PaymentType | null
|
||||||
|
deliveryType: DeliveryType
|
||||||
|
selectedRegion: string
|
||||||
|
selectedProvince: number | null
|
||||||
|
note: string
|
||||||
|
regionGroups: Record<string, Province[]>
|
||||||
|
availableRegions: string[]
|
||||||
|
paymentTypes: PaymentType[]
|
||||||
|
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,
|
||||||
|
translations: t,
|
||||||
|
paymentType,
|
||||||
|
deliveryType,
|
||||||
|
selectedRegion,
|
||||||
|
selectedProvince,
|
||||||
|
note,
|
||||||
|
regionGroups,
|
||||||
|
availableRegions,
|
||||||
|
paymentTypes,
|
||||||
|
onPaymentTypeChange,
|
||||||
|
onDeliveryTypeChange,
|
||||||
|
onRegionChange,
|
||||||
|
onProvinceChange,
|
||||||
|
onNoteChange,
|
||||||
|
onCompleteOrder,
|
||||||
|
isLoading,
|
||||||
|
}: OrderSummaryProps) {
|
||||||
|
const provincesForSelectedRegion = selectedRegion ? regionGroups[selectedRegion] || [] : []
|
||||||
|
const isFormValid = selectedRegion && selectedProvince && paymentType
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
|
||||||
|
{/* Payment Type */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</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"
|
||||||
|
: "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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery Type */}
|
||||||
|
<DeliveryTypeSelector
|
||||||
|
selectedType={deliveryType}
|
||||||
|
onSelect={onDeliveryTypeChange}
|
||||||
|
translations={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Region Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Label className="text-lg font-semibold mb-3 block">{t.selectRegion}</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 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Province Selection */}
|
||||||
|
{selectedRegion && provincesForSelectedRegion.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Label className="text-lg font-semibold mb-3 block">{t.selectAddress}</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedProvince?.toString() || ""}
|
||||||
|
onValueChange={(value) => onProvinceChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-xl">
|
||||||
|
<SelectValue placeholder={t.selectAddress} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{provincesForSelectedRegion.map((province) => (
|
||||||
|
<SelectItem key={province.id} value={province.id.toString()}>
|
||||||
|
{province.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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={onCompleteOrder}
|
||||||
|
disabled={!isFormValid || isLoading}
|
||||||
|
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading ? `${t.placeOrder}...` : t.placeOrder}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
features/cart/hooks/useAddresses.ts
Normal file
25
features/cart/hooks/useAddresses.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/lib/api"
|
import { apiClient } from "@/lib/api"
|
||||||
import type { Cart, CartItem } from "@/lib/types/api"
|
import type { CartItem } from "@/lib/types/api"
|
||||||
|
|
||||||
interface CartResponse {
|
interface CartResponse {
|
||||||
message: string
|
message: string
|
||||||
@@ -227,7 +227,7 @@ export function useCreateOrder() {
|
|||||||
region: string
|
region: string
|
||||||
note?: string
|
note?: string
|
||||||
}) => {
|
}) => {
|
||||||
const response = await apiClient.post("/api/v1/orders", payload)
|
const response = await apiClient.post("/orders", payload)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -241,30 +241,8 @@ export function useCreateOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
import type { Region } from "@/lib/types/api"
|
|
||||||
|
|
||||||
export function useRegions() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["regions"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<Region[]>("/api/v1/provinces")
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 60, // 1 hour
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import type { Address } from "@/lib/types/api"
|
|
||||||
|
|
||||||
export function useAddresses() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["addresses"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<Address[]>("/api/v1/addresses")
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/lib/api"
|
import { apiClient } from "@/lib/api"
|
||||||
import type { PaymentTypeOption } from "@/lib/types/api"
|
|
||||||
|
interface PaymentType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentTypesResponse {
|
||||||
|
message: string
|
||||||
|
data: PaymentType[]
|
||||||
|
}
|
||||||
|
|
||||||
export function usePaymentTypes() {
|
export function usePaymentTypes() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["paymentTypes"],
|
queryKey: ["paymentTypes"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get<PaymentTypeOption[]>("/api/v1/order-payments")
|
const response = await apiClient.get<PaymentTypesResponse>("/order-payments")
|
||||||
return response.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60, // 1 hour
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
})
|
})
|
||||||
171
features/cart/types.ts
Normal file
171
features/cart/types.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type { StaticImageData } from "next/image";
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
message: string;
|
||||||
|
data: CartItem[];
|
||||||
|
errorDetails?: string;
|
||||||
|
total?: number;
|
||||||
|
total_formatted?: string;
|
||||||
|
items?: CartItem[]; // Alternative structure
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: number;
|
||||||
|
seller: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
items: CartItem[];
|
||||||
|
billing: {
|
||||||
|
body: Array<{ title: string; value: string }>;
|
||||||
|
footer: { title: string; value: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Region {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
region_id: number;
|
||||||
|
address: string;
|
||||||
|
phone?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickUpPoint {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTypeOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartTranslations {
|
||||||
|
cart: string;
|
||||||
|
ordersIn: string;
|
||||||
|
pricePerUnit: string;
|
||||||
|
additionalPrice: string;
|
||||||
|
discount: string;
|
||||||
|
totalPrice: string;
|
||||||
|
paymentType: string;
|
||||||
|
cash: string;
|
||||||
|
card: string;
|
||||||
|
deliveryType: string;
|
||||||
|
delivery: string;
|
||||||
|
pickup: string;
|
||||||
|
selectRegion: string;
|
||||||
|
selectAddress: string;
|
||||||
|
note: string;
|
||||||
|
placeOrder: string;
|
||||||
|
emptyCart: string;
|
||||||
|
map: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
errorDetails?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderPayload {
|
||||||
|
customer_name?: string;
|
||||||
|
customer_phone?: string;
|
||||||
|
customer_address: string;
|
||||||
|
shipping_method: string;
|
||||||
|
payment_type_id: number;
|
||||||
|
delivery_time?: string;
|
||||||
|
delivery_at?: string;
|
||||||
|
region: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
product: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
media?: Array<{ images_800x800?: string; thumbnail?: string }>;
|
||||||
|
channel?: Array<{ id: number; name: string }>;
|
||||||
|
price_amount?: string;
|
||||||
|
stock?: number;
|
||||||
|
};
|
||||||
|
product_quantity: number;
|
||||||
|
quantity?: number; // For compatibility
|
||||||
|
seller?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
price?: number;
|
||||||
|
total?: number;
|
||||||
|
price_formatted?: string;
|
||||||
|
sub_total_formatted?: string;
|
||||||
|
discount_formatted?: string;
|
||||||
|
total_formatted?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Province {
|
||||||
|
id: number;
|
||||||
|
region: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: number;
|
||||||
|
seller: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
items: CartItem[];
|
||||||
|
billing: {
|
||||||
|
body: Array<{ title: string; value: string }>;
|
||||||
|
footer: { title: string; value: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartTranslations {
|
||||||
|
cart: string;
|
||||||
|
ordersIn: string;
|
||||||
|
pricePerUnit: string;
|
||||||
|
additionalPrice: string;
|
||||||
|
discount: string;
|
||||||
|
totalPrice: string;
|
||||||
|
paymentType: string;
|
||||||
|
cash: string;
|
||||||
|
card: string;
|
||||||
|
deliveryType: string;
|
||||||
|
delivery: string;
|
||||||
|
pickup: string;
|
||||||
|
selectRegion: string;
|
||||||
|
selectAddress: string;
|
||||||
|
note: string;
|
||||||
|
placeOrder: string;
|
||||||
|
emptyCart: string;
|
||||||
|
map: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
|
||||||
|
|
||||||
|
export interface CreateOrderPayload {
|
||||||
|
customer_address: string;
|
||||||
|
shipping_method: string;
|
||||||
|
payment_type_id: number;
|
||||||
|
region: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ import {
|
|||||||
useAllCategoryProducts,
|
useAllCategoryProducts,
|
||||||
useAllCategoryProductsPaginated,
|
useAllCategoryProductsPaginated,
|
||||||
useCategoryProducts,
|
useCategoryProducts,
|
||||||
} from "@/lib/hooks/useCategories";
|
} from "@/features/category/hooks/useCategories";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import type { Category, Product } from "@/lib/types/api";
|
import type { Category, Product } from "@/lib/types/api";
|
||||||
|
|
||||||
interface CategoryPageClientProps {
|
interface CategoryPageClientProps {
|
||||||
@@ -39,10 +40,10 @@ export default function CategoryPageClient({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
// Fetch all categories first
|
// Fetch all categories first
|
||||||
const { data: categoriesData, isLoading: categoriesLoading } =
|
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
|
||||||
useCategories();
|
|
||||||
|
|
||||||
// Find category from slug
|
// Find category from slug
|
||||||
const selectedCategory = useMemo(() => {
|
const selectedCategory = useMemo(() => {
|
||||||
@@ -64,9 +65,7 @@ export default function CategoryPageClient({
|
|||||||
|
|
||||||
// Track subcategories
|
// Track subcategories
|
||||||
const [hasSubcategories, setHasSubcategories] = useState(false);
|
const [hasSubcategories, setHasSubcategories] = useState(false);
|
||||||
const [subcategoriesToShow, setSubcategoriesToShow] = useState<Category[]>(
|
const [subcategoriesToShow, setSubcategoriesToShow] = useState<Category[]>([]);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -74,17 +73,13 @@ export default function CategoryPageClient({
|
|||||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
// Price sorting state
|
// Price sorting state
|
||||||
const [priceSort, setPriceSort] = useState<
|
const [priceSort, setPriceSort] = useState<"none" | "lowToHigh" | "highToLow">("none");
|
||||||
"none" | "lowToHigh" | "highToLow"
|
|
||||||
>("none");
|
|
||||||
|
|
||||||
// Price filter state
|
// Price filter state
|
||||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
||||||
|
|
||||||
// Selected filters state
|
// Selected filters state
|
||||||
const [selectedFilters, setSelectedFilters] = useState<
|
const [selectedFilters, setSelectedFilters] = useState<Record<string, Set<number>>>({
|
||||||
Record<string, Set<number>>
|
|
||||||
>({
|
|
||||||
brand: new Set(),
|
brand: new Set(),
|
||||||
color: new Set(),
|
color: new Set(),
|
||||||
tag: new Set(),
|
tag: new Set(),
|
||||||
@@ -94,10 +89,7 @@ export default function CategoryPageClient({
|
|||||||
const isSubCategory = useMemo(() => {
|
const isSubCategory = useMemo(() => {
|
||||||
if (!categoriesData || !selectedCategory) return false;
|
if (!categoriesData || !selectedCategory) return false;
|
||||||
|
|
||||||
const checkIsSubCategory = (
|
const checkIsSubCategory = (categories: Category[], targetId: number): boolean => {
|
||||||
categories: Category[],
|
|
||||||
targetId: number
|
|
||||||
): boolean => {
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.children) {
|
if (category.children) {
|
||||||
for (const subCategory of category.children) {
|
for (const subCategory of category.children) {
|
||||||
@@ -142,21 +134,7 @@ export default function CategoryPageClient({
|
|||||||
limit: 6,
|
limit: 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
const t = {
|
|
||||||
filter: "Filtreler",
|
|
||||||
from: "Min",
|
|
||||||
to: "Max",
|
|
||||||
reset: "Sıfırla",
|
|
||||||
total: "Toplam",
|
|
||||||
items: "ürün",
|
|
||||||
subCategories: "Alt Kategoriler",
|
|
||||||
composition: "Sıralama",
|
|
||||||
neverMind: "Varsayılan",
|
|
||||||
From_cheap_to_expensive: "Ucuzdan Pahalıya",
|
|
||||||
From_expensive_to_cheap: "Pahalıdan Ucuza",
|
|
||||||
brands: "Markalar",
|
|
||||||
noResults: "Ürün bulunamadı",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -217,7 +195,7 @@ export default function CategoryPageClient({
|
|||||||
}
|
}
|
||||||
}, [selectedCategory, subcategoryProducts, currentPage, isSubCategory]);
|
}, [selectedCategory, subcategoryProducts, currentPage, isSubCategory]);
|
||||||
|
|
||||||
// Handle paginated category products (non-subcategories)
|
// Handle paginated category products (non-subcategories) - FIXED
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paginatedCategoryData && selectedCategory && !isSubCategory) {
|
if (paginatedCategoryData && selectedCategory && !isSubCategory) {
|
||||||
console.log("Paginated category data:", paginatedCategoryData);
|
console.log("Paginated category data:", paginatedCategoryData);
|
||||||
@@ -235,6 +213,7 @@ export default function CategoryPageClient({
|
|||||||
return [...prevProducts, ...newProducts];
|
return [...prevProducts, ...newProducts];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXED: Check next_page_url instead of pagination object existence
|
||||||
setHasMore(!!paginatedCategoryData.pagination?.next_page_url);
|
setHasMore(!!paginatedCategoryData.pagination?.next_page_url);
|
||||||
} else if (currentPage === 1) {
|
} else if (currentPage === 1) {
|
||||||
setAllProducts([]);
|
setAllProducts([]);
|
||||||
@@ -273,16 +252,13 @@ export default function CategoryPageClient({
|
|||||||
}, [paginatedSubcategoryData, currentPage, selectedCategory, isSubCategory]);
|
}, [paginatedSubcategoryData, currentPage, selectedCategory, isSubCategory]);
|
||||||
|
|
||||||
const loadMoreData = useCallback(() => {
|
const loadMoreData = useCallback(() => {
|
||||||
if (!hasMore || categoryPaginatedFetching || subcategoryPaginatedLoading)
|
if (!hasMore || categoryPaginatedFetching || subcategoryPaginatedLoading) {
|
||||||
|
console.log("Cannot load more:", { hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading });
|
||||||
return;
|
return;
|
||||||
console.log("Loading more, page:", currentPage + 1);
|
}
|
||||||
|
console.log("Loading more, current page:", currentPage, "next page:", currentPage + 1);
|
||||||
setCurrentPage((prevPage) => prevPage + 1);
|
setCurrentPage((prevPage) => prevPage + 1);
|
||||||
}, [
|
}, [hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading, currentPage]);
|
||||||
hasMore,
|
|
||||||
categoryPaginatedFetching,
|
|
||||||
subcategoryPaginatedLoading,
|
|
||||||
currentPage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
categoriesLoading ||
|
categoriesLoading ||
|
||||||
@@ -318,9 +294,7 @@ export default function CategoryPageClient({
|
|||||||
return products.length || 0;
|
return products.length || 0;
|
||||||
}, [paginatedCategoryData, products, isSubCategory, selectedCategory]);
|
}, [paginatedCategoryData, products, isSubCategory, selectedCategory]);
|
||||||
|
|
||||||
const handlePriceSortChange = (
|
const handlePriceSortChange = (sortType: "none" | "lowToHigh" | "highToLow") => {
|
||||||
sortType: "none" | "lowToHigh" | "highToLow"
|
|
||||||
) => {
|
|
||||||
setPriceSort(sortType);
|
setPriceSort(sortType);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,7 +350,7 @@ export default function CategoryPageClient({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageTitle = selectedCategory?.name || "Kategori";
|
const pageTitle = selectedCategory?.name || t("category");
|
||||||
|
|
||||||
const handleFilterChange = (key: string, value: number) => {
|
const handleFilterChange = (key: string, value: number) => {
|
||||||
setSelectedFilters((prev) => {
|
setSelectedFilters((prev) => {
|
||||||
@@ -422,7 +396,7 @@ export default function CategoryPageClient({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{hasSubcategories && subcategoriesToShow.length > 0 && (
|
{hasSubcategories && subcategoriesToShow.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">{t.subCategories}</h3>
|
<h3 className="text-lg font-semibold mb-3">{t("subcategories")}</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{subcategoriesToShow.map((subCategory) => (
|
{subcategoriesToShow.map((subCategory) => (
|
||||||
<button
|
<button
|
||||||
@@ -442,7 +416,7 @@ export default function CategoryPageClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">{t.composition}</h3>
|
<h3 className="text-lg font-semibold mb-3">{t("composition")}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -452,7 +426,7 @@ export default function CategoryPageClient({
|
|||||||
onChange={() => handlePriceSortChange("none")}
|
onChange={() => handlePriceSortChange("none")}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>{t.neverMind}</span>
|
<span>{t("neverMind")}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -462,7 +436,7 @@ export default function CategoryPageClient({
|
|||||||
onChange={() => handlePriceSortChange("lowToHigh")}
|
onChange={() => handlePriceSortChange("lowToHigh")}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>{t.From_cheap_to_expensive}</span>
|
<span>{t("fromCheapToExpensive")}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -472,17 +446,17 @@ export default function CategoryPageClient({
|
|||||||
onChange={() => handlePriceSortChange("highToLow")}
|
onChange={() => handlePriceSortChange("highToLow")}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>{t.From_expensive_to_cheap}</span>
|
<span>{t("fromExpensiveToHigh")}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PriceFilter
|
<PriceFilter
|
||||||
title="Fiyat"
|
title={t("price")}
|
||||||
priceRange={priceRange}
|
priceRange={priceRange}
|
||||||
onPriceChange={handlePriceChange}
|
onPriceChange={handlePriceChange}
|
||||||
onInputChange={handlePriceInputChange}
|
onInputChange={handlePriceInputChange}
|
||||||
translations={{ from: t.from, to: t.to }}
|
translations={{ from: t("from"), to: t("to") }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -490,24 +464,26 @@ export default function CategoryPageClient({
|
|||||||
className="w-full rounded-xl bg-transparent"
|
className="w-full rounded-xl bg-transparent"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
>
|
>
|
||||||
{t.reset}
|
{t("reset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>;
|
if (isLoading) return <div>{t("loading") || "Ýüklenýär..."}</div>;
|
||||||
|
|
||||||
if (!selectedCategory && !categoriesLoading) {
|
if (!selectedCategory && !categoriesLoading) {
|
||||||
return <div className="text-center py-8">Kategori bulunamadı</div>;
|
return <div className="text-center py-8">Bölüm tapylmady</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"Current products:",
|
"Current state - products:",
|
||||||
products.length,
|
products.length,
|
||||||
"Has more:",
|
"hasMore:",
|
||||||
hasMore,
|
hasMore,
|
||||||
"Page:",
|
"page:",
|
||||||
currentPage
|
currentPage,
|
||||||
|
"isFetching:",
|
||||||
|
categoryPaginatedFetching
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -515,7 +491,7 @@ export default function CategoryPageClient({
|
|||||||
{selectedCategory && renderBreadcrumbs()}
|
{selectedCategory && renderBreadcrumbs()}
|
||||||
<h2 className="text-3xl font-bold">{pageTitle}</h2>
|
<h2 className="text-3xl font-bold">{pageTitle}</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{t.total}: {totalItems} {t.items}
|
{t("total")}: {totalItems} {t("items")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@@ -537,7 +513,7 @@ export default function CategoryPageClient({
|
|||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
loader={
|
loader={
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<div>Loading...</div>
|
<div>Ýüklenýär...</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -560,7 +536,7 @@ export default function CategoryPageClient({
|
|||||||
</div>
|
</div>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-gray-500">{t.noResults}</div>
|
<div className="text-center py-8 text-gray-500">{t("nResults")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -572,19 +548,19 @@ export default function CategoryPageClient({
|
|||||||
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
|
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{t.filter}
|
{t("filter")}
|
||||||
<SlidersHorizontal className="h-5 w-5" />
|
<SlidersHorizontal className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-[290px] p-0">
|
<SheetContent side="left" className="w-[290px] p-0">
|
||||||
<SheetHeader className="p-4 border-b">
|
<SheetHeader className="p-4 border-b">
|
||||||
<SheetTitle>{t.filter}</SheetTitle>
|
<SheetTitle>{t("filter")}</SheetTitle>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
|
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Kapat</span>
|
<span className="sr-only">Ýap</span>
|
||||||
</button>
|
</button>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
||||||
@@ -92,8 +92,6 @@ export function useAllCategoryProducts(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated)
|
// Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated)
|
||||||
export function useAllCategoryProductsPaginated(
|
export function useAllCategoryProductsPaginated(
|
||||||
category: Category | undefined,
|
category: Category | undefined,
|
||||||
@@ -158,4 +156,3 @@ export function useAllCategoryProductsPaginated(
|
|||||||
enabled: options?.enabled !== false && !!category,
|
enabled: options?.enabled !== false && !!category,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import Image from "next/image"
|
import Image from "next/image";
|
||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import type { Category } from "@/lib/types/api"
|
import type { Category } from "@/lib/types/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
categories: Category[] | undefined
|
categories: Category[] | undefined;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isError: boolean
|
isError: boolean;
|
||||||
locale: string
|
locale: string;
|
||||||
title: string
|
title: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function CategoryGrid({ categories, isLoading, isError, locale, title }: Props) {
|
export default function CategoryGrid({
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
locale,
|
||||||
|
title,
|
||||||
|
}: Props) {
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
||||||
<p className="text-red-600">Failed to load categories. Please try again.</p>
|
<p className="text-red-600">
|
||||||
|
Failed to load categories. Please try again.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -36,7 +44,7 @@ export default function CategoryGrid({ categories, isLoading, isError, locale, t
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +52,10 @@ export default function CategoryGrid({ categories, isLoading, isError, locale, t
|
|||||||
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{categories?.map((cat) => (
|
{categories?.map((cat) => (
|
||||||
<Link key={cat.id} href={`/${locale}/category/${cat.slug}?category_id=${cat.id}`}>
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
href={`/${locale}/category/${cat.slug}?category_id=${cat.id}`}
|
||||||
|
>
|
||||||
<Card className="hover:shadow-md border-none shadow-none p-0 gap-2 transition-all cursor-pointer">
|
<Card className="hover:shadow-md border-none shadow-none p-0 gap-2 transition-all cursor-pointer">
|
||||||
<div className="relative w-full h-36 overflow-hidden rounded-lg">
|
<div className="relative w-full h-36 overflow-hidden rounded-lg">
|
||||||
<Image
|
<Image
|
||||||
@@ -53,15 +64,16 @@ export default function CategoryGrid({ categories, isLoading, isError, locale, t
|
|||||||
fill
|
fill
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="py-2">
|
<CardContent className="py-2">
|
||||||
<p className="text-sm font-medium text-gray-800 truncate text-center">{cat.name}</p>
|
<p className="text-sm font-medium text-gray-800 truncate text-center">
|
||||||
|
{cat.name}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
118
features/home/components/HomePage.tsx
Normal file
118
features/home/components/HomePage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
|
import HeroCarousel from "./Carousel";
|
||||||
|
import CategoryGrid from "./CategoryGrid";
|
||||||
|
import CollectionSection from "./ProductGrid";
|
||||||
|
import { useCategories, useCarousels, useCollections } from "@/lib/hooks";
|
||||||
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("common");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(10);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: categories,
|
||||||
|
isLoading: categoriesLoading,
|
||||||
|
isError: categoriesError,
|
||||||
|
} = useCategories();
|
||||||
|
|
||||||
|
const { data: carousels, isLoading: carouselsLoading } = useCarousels();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: collections,
|
||||||
|
isLoading: collectionsLoading,
|
||||||
|
isError: collectionsError,
|
||||||
|
} = useCollections();
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (collections && visibleCount < collections.length) {
|
||||||
|
setVisibleCount((prev) => Math.min(prev + 10, collections.length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return <div className="p-8">Loading...</div>;
|
||||||
|
|
||||||
|
const carouselItems =
|
||||||
|
carousels?.map((carousel) => ({
|
||||||
|
title: carousel.title || "",
|
||||||
|
image: carousel.image || carousel.thumbnail,
|
||||||
|
url: carousel.link || null,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const visibleCollections = collections?.slice(0, visibleCount) || [];
|
||||||
|
const hasMore = collections ? visibleCount < collections.length : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
|
||||||
|
{!carouselsLoading && carouselItems.length > 0 && (
|
||||||
|
<HeroCarousel items={carouselItems} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CategoryGrid
|
||||||
|
categories={categories}
|
||||||
|
isLoading={categoriesLoading}
|
||||||
|
isError={categoriesError}
|
||||||
|
locale={locale}
|
||||||
|
title={t("categories")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{collectionsError ? (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<p className="text-red-600">
|
||||||
|
Failed to load collections. Please try again.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : collectionsLoading ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, j) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className="h-64 bg-gray-200 rounded-lg animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={visibleCollections.length}
|
||||||
|
next={loadMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loader={
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
||||||
|
<p className="text-gray-500 mt-2">Loading more collections...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
endMessage={
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-600">✓ All collections loaded</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
scrollThreshold={0.8}
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{visibleCollections.map((collection) => (
|
||||||
|
<CollectionSection
|
||||||
|
key={collection.id}
|
||||||
|
collection={collection}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation";
|
||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react";
|
||||||
import ProductCard from "@/components/ProductCard"
|
import ProductCard from "@/components/ProductCard";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useCollectionProducts } from "@/lib/hooks"
|
import { useCollectionProducts } from "@/lib/hooks";
|
||||||
import type { Collection } from "@/lib/types/api"
|
import type { Collection } from "@/lib/types/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection: Collection
|
collection: Collection;
|
||||||
locale: string
|
locale: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function CollectionSection({ collection, locale }: Props) {
|
export default function CollectionSection({ collection, locale }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [shouldRender, setShouldRender] = useState(true)
|
const [shouldRender, setShouldRender] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: productsData,
|
data: productsData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError
|
isError,
|
||||||
} = useCollectionProducts(collection.id, { enabled: shouldRender })
|
} = useCollectionProducts(collection.id, { enabled: shouldRender });
|
||||||
|
|
||||||
// Determine if section should render based on products
|
// Determine if section should render based on products
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && productsData) {
|
if (!isLoading && productsData) {
|
||||||
const hasProducts = productsData.data && productsData.data.length > 0
|
const hasProducts = productsData.data && productsData.data.length > 0;
|
||||||
setShouldRender(hasProducts)
|
setShouldRender(hasProducts);
|
||||||
}
|
}
|
||||||
}, [isLoading, productsData])
|
}, [isLoading, productsData]);
|
||||||
|
|
||||||
// Don't render if no products after loading
|
// Don't render if no products after loading
|
||||||
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
|
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTitleClick = () => {
|
const handleTitleClick = () => {
|
||||||
router.push(`/${locale}/collections/${collection.id}`)
|
router.push(`/${locale}/collections/${collection.id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Show skeleton while loading
|
// Show skeleton while loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -53,16 +53,16 @@ export default function CollectionSection({ collection, locale }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error state
|
// Show error state
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return null // Silently skip errored collections
|
return null; // Silently skip errored collections
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slice to show only first 4 products
|
// Slice to show only first 4 products
|
||||||
const displayProducts = productsData?.data.slice(0, 4) || []
|
const displayProducts = productsData?.data.slice(0, 4) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
@@ -79,22 +79,25 @@ export default function CollectionSection({ collection, locale }: Props) {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
{displayProducts.map((product) => {
|
{displayProducts.map((product) => {
|
||||||
// Extract first media image or use placeholder
|
// Extract first media image or use placeholder
|
||||||
const firstImage = product.media?.[0]?.images_800x800 ||
|
const firstImage =
|
||||||
|
product.media?.[0]?.images_800x800 ||
|
||||||
product.media?.[0]?.images_720x720 ||
|
product.media?.[0]?.images_720x720 ||
|
||||||
product.media?.[0]?.thumbnail ||
|
product.media?.[0]?.thumbnail ||
|
||||||
"/placeholder-product.jpg"
|
"/placeholder-product.jpg";
|
||||||
|
|
||||||
// Format price
|
// Format price
|
||||||
const formattedPrice = product.price_amount
|
const formattedPrice = product.price_amount
|
||||||
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
|
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
|
||||||
: "Price not available"
|
: "Price not available";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
id={product.id}
|
id={product.id}
|
||||||
name={product.name}
|
name={product.name}
|
||||||
price={product.price_amount ? parseFloat(product.price_amount) : null}
|
price={
|
||||||
|
product.price_amount ? parseFloat(product.price_amount) : null
|
||||||
|
}
|
||||||
struct_price_text={formattedPrice}
|
struct_price_text={formattedPrice}
|
||||||
images={[firstImage]}
|
images={[firstImage]}
|
||||||
is_favorite={false}
|
is_favorite={false}
|
||||||
@@ -104,9 +107,9 @@ export default function CollectionSection({ collection, locale }: Props) {
|
|||||||
width={250}
|
width={250}
|
||||||
button={false}
|
button={false}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
151
features/home/hooks/useCollections.ts
Normal file
151
features/home/hooks/useCollections.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import type { Collection, Product, PaginatedResponse } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// Get ALL collections (fetch all pages)
|
||||||
|
export function useCollections(options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["collections"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const allCollections: Collection[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let hasMorePages = true;
|
||||||
|
|
||||||
|
while (hasMorePages) {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Collection>>(
|
||||||
|
"/collections",
|
||||||
|
{ params: { page: currentPage, perPage: 50 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = response.data.data || [];
|
||||||
|
allCollections.push(...collections);
|
||||||
|
|
||||||
|
// Check if there are more pages
|
||||||
|
const pagination = response.data.pagination;
|
||||||
|
if (pagination && pagination.next_page_url) {
|
||||||
|
currentPage++;
|
||||||
|
} else {
|
||||||
|
hasMorePages = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCollections;
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false,
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get single collection by ID
|
||||||
|
export function useCollection(
|
||||||
|
id: number | string,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["collection", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Collection>(`/collections/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!id,
|
||||||
|
staleTime: 1000 * 60 * 15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ALL products for a collection (fetch all pages)
|
||||||
|
export function useCollectionProducts(
|
||||||
|
collectionId: number | string,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["collection", collectionId, "products"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const allProducts: Product[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let hasMorePages = true;
|
||||||
|
|
||||||
|
while (hasMorePages) {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/collections/${collectionId}/products`,
|
||||||
|
{ params: { page: currentPage, perPage: 50 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const products = response.data.data || [];
|
||||||
|
allProducts.push(...products);
|
||||||
|
|
||||||
|
// Check if there are more pages
|
||||||
|
const pagination = response.data.pagination;
|
||||||
|
if (pagination && pagination.next_page_url) {
|
||||||
|
currentPage++;
|
||||||
|
} else {
|
||||||
|
hasMorePages = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: allProducts,
|
||||||
|
isEmpty: allProducts.length === 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!collectionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if collection has products (limit=1 for efficiency)
|
||||||
|
export function useCollectionHasProducts(
|
||||||
|
collectionId: number | string,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["collection", collectionId, "has-products"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/collections/${collectionId}/products`,
|
||||||
|
{ params: { perPage: 1 } }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hasProducts: response.data.data && response.data.data.length > 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!collectionId,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get collection products with infinite scroll (recommended for UI)
|
||||||
|
export function useCollectionProductsInfinite(
|
||||||
|
collectionId: number | string,
|
||||||
|
options?: { enabled?: boolean; perPage?: number }
|
||||||
|
) {
|
||||||
|
const perPage = options?.perPage || 6;
|
||||||
|
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["collection", collectionId, "products-infinite", perPage],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/collections/${collectionId}/products`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page: pageParam,
|
||||||
|
perPage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
pagination: response.data.pagination,
|
||||||
|
isEmpty: !response.data.data || response.data.data.length === 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.pagination?.next_page_url) {
|
||||||
|
// Extract page number from URL or increment
|
||||||
|
const currentPage = lastPage.pagination.page || 1;
|
||||||
|
return currentPage + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!collectionId,
|
||||||
|
initialPageParam: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
0
features/home/types.ts
Normal file
0
features/home/types.ts
Normal file
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import Image from "next/image"
|
import Image from "next/image";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -14,34 +14,26 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useOrders, useCancelOrder } from "@/lib/hooks"
|
import { useOrders, useCancelOrder } from "@/lib/hooks";
|
||||||
import type { Order } from "@/lib/types/api"
|
import type { Order } from "../types";
|
||||||
|
|
||||||
interface OrdersPageProps {
|
export default function OrdersPageClient() {
|
||||||
locale?: string
|
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||||
}
|
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
const { data: orders, isLoading, isError, error } = useOrders();
|
||||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder();
|
||||||
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null)
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const { data: orders, isLoading, isError, error } = useOrders()
|
|
||||||
const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder()
|
|
||||||
|
|
||||||
const t = {
|
const t = {
|
||||||
orders: "Заказы",
|
|
||||||
myOrders: "Мои заказы",
|
myOrders: "Мои заказы",
|
||||||
active: "Активные",
|
|
||||||
completed: "Завершенные",
|
|
||||||
activeOrders: "Активные заказы",
|
activeOrders: "Активные заказы",
|
||||||
completedOrders: "Завершенные заказы",
|
completedOrders: "Завершенные заказы",
|
||||||
cancelOrder: "Отменить заказ",
|
cancelOrder: "Отменить заказ",
|
||||||
keepOrder: "Оставить заказ",
|
keepOrder: "Оставить заказ",
|
||||||
areYouSure: "Вы уверены?",
|
cancelConfirmation: "Вы уверены, что хотите отменить этот заказ?",
|
||||||
cancelConfirmation: "Вы уверены, что хотите отменить этот заказ? Это действие нельзя отменить.",
|
|
||||||
cancelling: "Отмена...",
|
cancelling: "Отмена...",
|
||||||
orderNumber: "Заказ №",
|
orderNumber: "Заказ №",
|
||||||
ordered: "Заказано",
|
ordered: "Заказано",
|
||||||
@@ -52,70 +44,80 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
noOrders: "У вас пока нет заказов",
|
noOrders: "У вас пока нет заказов",
|
||||||
noActiveOrders: "У вас нет активных заказов",
|
noActiveOrders: "У вас нет активных заказов",
|
||||||
noCompletedOrders: "У вас нет завершенных заказов",
|
noCompletedOrders: "У вас нет завершенных заказов",
|
||||||
loadError: "Не удалось загрузить заказы. Пожалуйста, попробуйте позже.",
|
loadError: "Не удалось загрузить заказы",
|
||||||
orderCancelled: "Заказ отменен",
|
orderCancelled: "Заказ отменен",
|
||||||
orderCancelledDescription: "Ваш заказ был успешно отменен",
|
orderCancelledDescription: "Ваш заказ был успешно отменен",
|
||||||
error: "Ошибка",
|
error: "Ошибка",
|
||||||
cannotCancelShipped: "Нельзя отменить заказ, который уже отправлен или доставлен",
|
status: "Статус",
|
||||||
}
|
deliveryTime: "Время доставки",
|
||||||
|
deliveryDate: "Дата доставки",
|
||||||
|
address: "Адрес",
|
||||||
|
paymentMethod: "Способ оплаты",
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelOrder = (order: Order) => {
|
const handleCancelOrder = (order: Order) => {
|
||||||
// Check if order can be cancelled
|
setOrderToCancel(order);
|
||||||
if (order.status === "shipped" || order.status === "delivered") {
|
setIsCancelDialogOpen(true);
|
||||||
toast({
|
};
|
||||||
title: t.error,
|
|
||||||
description: t.cannotCancelShipped,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setOrderToCancel(order)
|
|
||||||
setIsCancelDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmCancelOrder = () => {
|
const confirmCancelOrder = () => {
|
||||||
if (!orderToCancel) return
|
if (!orderToCancel) return;
|
||||||
|
|
||||||
cancelOrder(orderToCancel.id, {
|
cancelOrder(orderToCancel.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: t.orderCancelled,
|
title: t.orderCancelled,
|
||||||
description: t.orderCancelledDescription,
|
description: t.orderCancelledDescription,
|
||||||
})
|
});
|
||||||
setIsCancelDialogOpen(false)
|
setIsCancelDialogOpen(false);
|
||||||
setOrderToCancel(null)
|
setOrderToCancel(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.error,
|
title: t.error,
|
||||||
description: error.message || "Не удалось отменить заказ",
|
description: error.message || "Не удалось отменить заказ",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const lowerStatus = status.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerStatus.includes("ожидается") || lowerStatus.includes("pending")) {
|
||||||
|
return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
if (lowerStatus.includes("обработка") || lowerStatus.includes("processing")) {
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
if (lowerStatus.includes("отправлен") || lowerStatus.includes("shipped")) {
|
||||||
|
return <Badge>{status}</Badge>;
|
||||||
|
}
|
||||||
|
if (lowerStatus.includes("доставлен") || lowerStatus.includes("delivered")) {
|
||||||
|
return <Badge className="bg-green-600">{status}</Badge>;
|
||||||
|
}
|
||||||
|
if (lowerStatus.includes("отменен") || lowerStatus.includes("cancelled")) {
|
||||||
|
return <Badge variant="destructive">{status}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (status: Order["status"]) => {
|
return <Badge>{status}</Badge>;
|
||||||
const statusMap: Record<string, { label: string; variant: string; className?: string }> = {
|
};
|
||||||
pending: { label: "Ожидание", variant: "outline" },
|
|
||||||
processing: { label: "Обработка", variant: "secondary" },
|
|
||||||
shipped: { label: "Отправлен", variant: "default" },
|
|
||||||
delivered: { label: "Доставлен", variant: "default", className: "bg-green-600" },
|
|
||||||
cancelled: { label: "Отменен", variant: "destructive" },
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusInfo = statusMap[status] || { label: status, variant: "default" }
|
const isActiveOrder = (status: string) => {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
return lower.includes("ожидается") || lower.includes("обработка") || lower.includes("отправлен") ||
|
||||||
|
lower.includes("pending") || lower.includes("processing") || lower.includes("shipped");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const activeOrders = orders?.filter((o) => isActiveOrder(o.status)) || [];
|
||||||
<Badge variant={statusInfo.variant as any} className={statusInfo.className}>
|
const completedOrders = orders?.filter((o) => !isActiveOrder(o.status)) || [];
|
||||||
{statusInfo.label}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeOrders = orders?.filter((o) => ["pending", "processing", "shipped"].includes(o.status)) || []
|
const calculateTotal = (order: Order) => {
|
||||||
const completedOrders = orders?.filter((o) => ["delivered", "cancelled"].includes(o.status)) || []
|
return order.orderItems.reduce((sum, item) => {
|
||||||
|
return sum + (parseFloat(item.unit_price_amount) * item.quantity);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -130,7 +132,7 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -139,10 +141,9 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
|
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
|
||||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||||
<p className="text-red-600">{t.loadError}</p>
|
<p className="text-red-600">{t.loadError}</p>
|
||||||
{error && <p className="text-sm text-red-500 mt-2">{error.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orders || orders.length === 0) {
|
if (!orders || orders.length === 0) {
|
||||||
@@ -153,7 +154,7 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
<p className="text-2xl text-gray-400">{t.noOrders}</p>
|
<p className="text-2xl text-gray-400">{t.noOrders}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,6 +185,7 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
onCancel={handleCancelOrder}
|
onCancel={handleCancelOrder}
|
||||||
isCancelling={isCancellingOrder}
|
isCancelling={isCancellingOrder}
|
||||||
getStatusBadge={getStatusBadge}
|
getStatusBadge={getStatusBadge}
|
||||||
|
calculateTotal={calculateTotal}
|
||||||
translations={t}
|
translations={t}
|
||||||
showCancelButton
|
showCancelButton
|
||||||
/>
|
/>
|
||||||
@@ -206,6 +208,7 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
onCancel={handleCancelOrder}
|
onCancel={handleCancelOrder}
|
||||||
isCancelling={isCancellingOrder}
|
isCancelling={isCancellingOrder}
|
||||||
getStatusBadge={getStatusBadge}
|
getStatusBadge={getStatusBadge}
|
||||||
|
calculateTotal={calculateTotal}
|
||||||
translations={t}
|
translations={t}
|
||||||
showCancelButton={false}
|
showCancelButton={false}
|
||||||
/>
|
/>
|
||||||
@@ -238,16 +241,17 @@ export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderCardProps {
|
interface OrderCardProps {
|
||||||
order: Order
|
order: Order;
|
||||||
onCancel: (order: Order) => void
|
onCancel: (order: Order) => void;
|
||||||
isCancelling: boolean
|
isCancelling: boolean;
|
||||||
getStatusBadge: (status: Order["status"]) => React.ReactNode
|
getStatusBadge: (status: string) => React.ReactNode;
|
||||||
translations: any
|
calculateTotal: (order: Order) => number;
|
||||||
showCancelButton: boolean
|
translations: any;
|
||||||
|
showCancelButton: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrderCard({
|
function OrderCard({
|
||||||
@@ -255,11 +259,11 @@ function OrderCard({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isCancelling,
|
isCancelling,
|
||||||
getStatusBadge,
|
getStatusBadge,
|
||||||
|
calculateTotal,
|
||||||
translations: t,
|
translations: t,
|
||||||
showCancelButton,
|
showCancelButton,
|
||||||
}: OrderCardProps) {
|
}: OrderCardProps) {
|
||||||
const canCancel =
|
const total = calculateTotal(order);
|
||||||
showCancelButton && order.status !== "shipped" && order.status !== "delivered" && order.status !== "cancelled"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 flex flex-col justify-between">
|
<Card className="p-4 flex flex-col justify-between">
|
||||||
@@ -271,38 +275,36 @@ function OrderCard({
|
|||||||
{getStatusBadge(order.status)}
|
{getStatusBadge(order.status)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3 space-y-1">
|
<div className="mb-3 space-y-1 text-sm">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-gray-600">
|
||||||
{t.ordered}: {new Date(order.created_at).toLocaleDateString()}
|
<span className="font-medium">{t.deliveryTime}:</span> {order.delivery_time}
|
||||||
</p>
|
</p>
|
||||||
{order.estimated_delivery && (
|
<p className="text-gray-600">
|
||||||
<p className="text-sm text-gray-600">
|
<span className="font-medium">{t.deliveryDate}:</span>{" "}
|
||||||
{t.estimatedDelivery}: {order.estimated_delivery}
|
{new Date(order.delivery_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="text-gray-600">
|
||||||
{!showCancelButton && order.updated_at && (
|
<span className="font-medium">{t.address}:</span> {order.customer_address}
|
||||||
<p className="text-sm text-gray-600">
|
</p>
|
||||||
{t.completed}: {new Date(order.updated_at).toLocaleDateString()}
|
<p className="text-gray-600">
|
||||||
|
<span className="font-medium">{t.paymentMethod}:</span> {order.payment_type}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
||||||
{order.items?.map((item) => (
|
{order.orderItems.map((item, index) => (
|
||||||
<div key={item.id} className="flex items-start gap-3">
|
<div key={index} className="flex items-start gap-3">
|
||||||
{item.product?.image && (
|
|
||||||
<Image
|
<Image
|
||||||
src={item.product.image || "/placeholder.svg"}
|
src={item.product.images_400x400 || item.product.thumbnail}
|
||||||
alt={item.product.name}
|
alt={item.product.name}
|
||||||
width={50}
|
width={50}
|
||||||
height={50}
|
height={50}
|
||||||
className="rounded object-cover"
|
className="rounded object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium line-clamp-2">{item.product?.name}</p>
|
<p className="text-sm font-medium line-clamp-2">{item.product.name}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{t.quantity}: {item.quantity}
|
{t.quantity}: {item.quantity} × {item.unit_price_amount} TMT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,12 +314,12 @@ function OrderCard({
|
|||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="flex justify-between font-semibold">
|
<div className="flex justify-between font-semibold">
|
||||||
<span>{t.total}</span>
|
<span>{t.total}</span>
|
||||||
<span>{order.total_formatted || `$${order.total}`}</span>
|
<span>{total.toFixed(2)} TMT</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCancel && (
|
{showCancelButton && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -330,5 +332,5 @@ function OrderCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
78
features/orders/hooks/useOrders.ts
Normal file
78
features/orders/hooks/useOrders.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import type { Order, OrdersResponse, CreateOrderRequest } from "../types";
|
||||||
|
|
||||||
|
export function useOrders(options?: { page?: number; perPage?: number }) {
|
||||||
|
return useQuery<Order[]>({
|
||||||
|
queryKey: ["orders", options?.page],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<OrdersResponse>("/orders", {
|
||||||
|
params: {
|
||||||
|
page: options?.page || 1,
|
||||||
|
per_page: options?.perPage || 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// API response'dan data array'ini döndür
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrder(id: number | string) {
|
||||||
|
return useQuery<Order | null>({
|
||||||
|
queryKey: ["order", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get(`/orders/${id}`);
|
||||||
|
return response.data.data || null;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOrder() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orderData: CreateOrderRequest) => {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(orderData).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post("/orders", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelOrder() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orderId: number) => {
|
||||||
|
const response = await apiClient.delete(`/orders/${orderId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, orderId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
59
features/orders/types.ts
Normal file
59
features/orders/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface OrderProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
thumbnail: string;
|
||||||
|
images_400x400: string;
|
||||||
|
images_800x800: string;
|
||||||
|
images_1200x1200: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItem {
|
||||||
|
product: OrderProduct;
|
||||||
|
order: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
quantity: number;
|
||||||
|
unit_price_amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
shipping_method: string;
|
||||||
|
notes: string | null;
|
||||||
|
customer_name: string;
|
||||||
|
customer_phone: string;
|
||||||
|
customer_address: string;
|
||||||
|
delivery_time: string;
|
||||||
|
delivery_at: string;
|
||||||
|
region: string;
|
||||||
|
user_id: number;
|
||||||
|
province_id: number | null;
|
||||||
|
payment_type: string;
|
||||||
|
orderItems: OrderItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrdersResponse {
|
||||||
|
message: string;
|
||||||
|
data: Order[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
count: number;
|
||||||
|
first_page_url: string;
|
||||||
|
next_page_url: string | null;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
customer_name: string;
|
||||||
|
customer_phone: string;
|
||||||
|
customer_address: string;
|
||||||
|
shipping_method: string;
|
||||||
|
payment_type_id: number;
|
||||||
|
delivery_time?: string;
|
||||||
|
delivery_at?: string;
|
||||||
|
region: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import { Card } from "@/components/ui/card"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useProductsBySlug } from "@/lib/hooks/useProducts"
|
import { useProductsBySlug } from "@/features/products/hooks/useProducts"
|
||||||
import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/lib/hooks/useCart"
|
import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/features/cart/hooks/useCart"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
0
features/products/types.ts
Normal file
0
features/products/types.ts
Normal file
@@ -1,33 +1,39 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { LogOut } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { LogOut } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Label } from "@/components/ui/label";
|
||||||
import { useUserProfile } from "@/lib/hooks"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useUserProfile } from "@/lib/hooks";
|
||||||
|
import { clearAuthToken } from "@/lib/api";
|
||||||
|
|
||||||
interface ProfilePageProps {
|
interface ProfilePageProps {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientProfilePage(props: ProfilePageProps) {
|
export default function ClientProfilePage(props: ProfilePageProps) {
|
||||||
const { data: user, isLoading, error } = useUserProfile()
|
const { data: user, isLoading, error } = useUserProfile();
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
|
personalInfo: "Личная информация",
|
||||||
|
profileDescription: "Ваши данные профиля",
|
||||||
firstName: "Имя",
|
firstName: "Имя",
|
||||||
lastName: "Фамилия",
|
lastName: "Фамилия",
|
||||||
phone: "Номер телефона",
|
phone: "Номер телефона",
|
||||||
email: "Email",
|
address: "Адрес",
|
||||||
logout: "Выйти",
|
logout: "Выйти",
|
||||||
loading: "Загрузка...",
|
loading: "Загрузка...",
|
||||||
}
|
errorLoading: "Не удалось загрузить профиль",
|
||||||
|
tryAgain: "Попробовать снова",
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
// Clear auth token
|
clearAuthToken();
|
||||||
window.location.href = "/"
|
window.location.href = "/";
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +56,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -58,12 +64,12 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
|||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<p className="text-red-600 mb-4">Failed to load profile</p>
|
<p className="text-red-600 mb-4">{translations.errorLoading}</p>
|
||||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
<Button onClick={() => window.location.reload()}>{translations.tryAgain}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,41 +79,36 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
|||||||
|
|
||||||
<Card className="shadow-lg mb-4">
|
<Card className="shadow-lg mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Личная информация</CardTitle>
|
<CardTitle>{translations.personalInfo}</CardTitle>
|
||||||
<CardDescription>Ваши данные профиля</CardDescription>
|
<CardDescription>{translations.profileDescription}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
{/* First Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">{translations.firstName}</Label>
|
<Label htmlFor="firstName">{translations.firstName}</Label>
|
||||||
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
|
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="lastName">{translations.lastName}</Label>
|
<Label htmlFor="lastName">{translations.lastName}</Label>
|
||||||
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
|
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">{translations.phone}</Label>
|
<Label htmlFor="phone">{translations.phone}</Label>
|
||||||
<Input id="phone" value={user.phone || ""} disabled className="bg-gray-50" />
|
<Input id="phone" value={user.phone_number || ""} disabled className="bg-gray-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">{translations.email}</Label>
|
<Label htmlFor="address">{translations.address}</Label>
|
||||||
<Input id="email" value={user.email || ""} disabled className="bg-gray-50" />
|
<Input id="address" value={user.address || ""} disabled className="bg-gray-50" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Logout Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -119,5 +120,5 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
37
features/profile/hooks/useUserProfile.ts
Normal file
37
features/profile/hooks/useUserProfile.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { userStore } from "../userStore";
|
||||||
|
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "../types";
|
||||||
|
|
||||||
|
export const useUserProfile = () => {
|
||||||
|
return useQuery<ProfileResponse["data"]>({
|
||||||
|
queryKey: ["user-profile"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<ProfileResponse>("/profile");
|
||||||
|
const userData = response.data.data;
|
||||||
|
|
||||||
|
// Store'a kaydet
|
||||||
|
userStore.setUser(userData);
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateProfile = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<UpdateProfileResponse["data"], Error, UpdateProfileRequest>({
|
||||||
|
mutationFn: async (profileData) => {
|
||||||
|
const response = await apiClient.post<UpdateProfileResponse>("/profile", profileData);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
userStore.setUser(data);
|
||||||
|
queryClient.setQueryData(["user-profile"], data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
23
features/profile/types.ts
Normal file
23
features/profile/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface UserProfile {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone_number: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileResponse {
|
||||||
|
message: string;
|
||||||
|
data: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileResponse {
|
||||||
|
message: string;
|
||||||
|
data: UserProfile;
|
||||||
|
}
|
||||||
29
features/profile/userStore.ts
Normal file
29
features/profile/userStore.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { UserProfile } from "./types";
|
||||||
|
|
||||||
|
// In-memory store (session-based, no persistence)
|
||||||
|
class UserStore {
|
||||||
|
private user: UserProfile | null = null;
|
||||||
|
|
||||||
|
setUser(user: UserProfile | null) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): UserProfile | null {
|
||||||
|
return this.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUser() {
|
||||||
|
this.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrderData(): { customer_name: string; customer_phone: string } | null {
|
||||||
|
if (!this.user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer_name: `${this.user.first_name} ${this.user.last_name}`.trim(),
|
||||||
|
customer_phone: this.user.phone_number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userStore = new UserStore();
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Endpoints Configuration
|
|
||||||
* Centralized mapping of all API endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
|
||||||
// Products
|
|
||||||
products: "/api/v1/products",
|
|
||||||
productDetail: (id: string | number) => `/api/v1/products/${id}`,
|
|
||||||
productsByCategory: (categoryId: string | number) => `/api/v1/categories/${categoryId}/products`,
|
|
||||||
productsByBrand: (brandId: string | number) => `/api/v1/brands/${brandId}/products`,
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
categories: "/api/v1/categories",
|
|
||||||
categoryDetail: (id: string | number) => `/api/v1/categories/${id}`,
|
|
||||||
|
|
||||||
// Search & Filters
|
|
||||||
search: "/api/v1/search",
|
|
||||||
filters: "/api/v1/filters",
|
|
||||||
|
|
||||||
// Cart
|
|
||||||
cart: "/api/v1/carts",
|
|
||||||
cartItems: "/api/v1/carts",
|
|
||||||
cartItem: (itemId: string | number) => `/api/v1/carts/${itemId}`,
|
|
||||||
|
|
||||||
// Favorites
|
|
||||||
favorites: "/api/v1/favorites",
|
|
||||||
favoriteDetail: (productId: string | number) => `/api/v1/favorites/${productId}`,
|
|
||||||
|
|
||||||
// Orders
|
|
||||||
orders: "/api/v1/orders",
|
|
||||||
orderDetail: (id: string | number) => `/api/v1/orders/${id}`,
|
|
||||||
cancelOrder: (id: string | number) => `/api/v1/orders/${id}/cancel`,
|
|
||||||
|
|
||||||
// Regions & Addresses
|
|
||||||
regions: "/api/v1/regions",
|
|
||||||
addresses: "/api/v1/addresses",
|
|
||||||
|
|
||||||
// Payment & Shipping
|
|
||||||
paymentTypes: "/api/v1/order-payments",
|
|
||||||
shippingMethods: "/api/v1/shipping-methods",
|
|
||||||
|
|
||||||
// Profile
|
|
||||||
profile: "/api/v1/profile",
|
|
||||||
profileMe: "/api/v1/me",
|
|
||||||
|
|
||||||
// Auth
|
|
||||||
guestToken: "/api/v1/auth/guest-token",
|
|
||||||
verifyCode: "/api/v1/auth/verify-code",
|
|
||||||
|
|
||||||
// Media
|
|
||||||
banners: "/api/v1/media/banners",
|
|
||||||
|
|
||||||
// Forms
|
|
||||||
newsletter: "/api/v1/forms/newsletter-subscription",
|
|
||||||
contactUs: "/api/v1/forms/contact-us",
|
|
||||||
openStore: "/api/v1/forms/open-store",
|
|
||||||
} as const
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
export * from "./useProducts"
|
export * from "../../features/products/hooks/useProducts"
|
||||||
export * from "./useCategories"
|
export * from "../../features/category/hooks/useCategories"
|
||||||
export * from "./useCart"
|
export * from "../../features/cart/hooks/useCart"
|
||||||
export * from "./useFavorites"
|
export * from "../../features/favorites/hooks/useFavorites"
|
||||||
export * from "./useOrders"
|
export * from "../../features/orders/hooks/useOrders"
|
||||||
export * from "./useSearch"
|
export * from "./useSearch"
|
||||||
export * from "./useUserProfile"
|
export * from "../../features/profile/hooks/useUserProfile"
|
||||||
export * from "./useOpenStore"
|
export * from "./useOpenStore"
|
||||||
export * from "./useRegions"
|
|
||||||
export * from "./useAddresses"
|
export * from "../../features/cart/hooks/useAddresses"
|
||||||
export * from "./usePaymentTypes"
|
export * from "../../features/cart/hooks/usePaymentTypes"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ export * from "./usePaymentTypes"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export * from "./useMedia"
|
export * from "../../features/home/hooks/useMedia"
|
||||||
export * from "./useCollections"
|
export * from "../../features/home/hooks/useCollections"
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { Product, Category, Cart, CartItem, Order, Favorite, Banner } from "@/lib/types/api"
|
export type { Product, Category, Cart, CartItem, Order, Favorite, Banner } from "@/lib/types/api"
|
||||||
|
|||||||
@@ -1,192 +1,160 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiClient, setAuthToken, clearAuthToken, setGuestToken } from "@/lib/api"
|
import { useState, useEffect } from "react";
|
||||||
import { queryClient } from "@/lib/queryClient"
|
import { apiClient, setAuthToken, clearAuthToken, setGuestToken } from "@/lib/api";
|
||||||
|
|
||||||
|
// ==================== TYPES ====================
|
||||||
interface LoginCredentials {
|
interface LoginCredentials {
|
||||||
phone_number: string
|
phone_number: string;
|
||||||
password?: string
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegisterData {
|
interface RegisterData {
|
||||||
phone_number: string
|
phone_number: string;
|
||||||
name?: string
|
name?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerifyTokenData {
|
interface VerifyTokenData {
|
||||||
phone_number: string
|
phone_number: string;
|
||||||
code: string
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
token?: string
|
token?: string;
|
||||||
data?: string
|
data?: string;
|
||||||
user?: any
|
user?: {
|
||||||
|
id: string;
|
||||||
|
phone_number: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== AUTH STATUS ====================
|
||||||
* Guest Token alma (RTK mantığı)
|
const getTokenFromCookie = (name: string): string | null => {
|
||||||
*/
|
if (typeof document === "undefined") return null;
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(";").shift() || null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthStatus() {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authToken = getTokenFromCookie("authToken");
|
||||||
|
setIsAuthenticated(!!authToken);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GUEST TOKEN ====================
|
||||||
export function useGetGuestToken() {
|
export function useGetGuestToken() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (): Promise<AuthResponse> => {
|
mutationFn: async (): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {}, {
|
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {});
|
||||||
headers: {
|
return response.data;
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const token = data?.token || data?.data
|
const token = data?.token || data?.data;
|
||||||
if (token) {
|
if (token) {
|
||||||
setGuestToken(token)
|
setGuestToken(token);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error fetching guest token:", error)
|
console.error("Guest token hatası:", error);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== LOGIN ====================
|
||||||
* Login mutation (RTK mantığı)
|
|
||||||
*/
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>("/auth/login", credentials)
|
const response = await apiClient.post<AuthResponse>("/auth/login", credentials);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
const token = data?.token || data?.data
|
|
||||||
if (token) {
|
|
||||||
setAuthToken(token)
|
|
||||||
apiClient.setAuthToken(token)
|
|
||||||
|
|
||||||
// Tüm cache'i temizle ve yeniden fetch et
|
|
||||||
queryClient.invalidateQueries()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Login error:", error)
|
console.error("Login hatası:", error);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== REGISTER ====================
|
||||||
* Register mutation (RTK mantığı)
|
|
||||||
*/
|
|
||||||
export function useRegister() {
|
export function useRegister() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>("/auth/register", userData)
|
const response = await apiClient.post<AuthResponse>("/auth/register", userData);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const token = data?.token || data?.data
|
const token = data?.token || data?.data;
|
||||||
if (token) {
|
if (token) {
|
||||||
setAuthToken(token)
|
setAuthToken(token);
|
||||||
apiClient.setAuthToken(token)
|
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
|
||||||
|
|
||||||
// Tüm cache'i temizle
|
|
||||||
queryClient.invalidateQueries()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Register error:", error)
|
console.error("Register hatası:", error);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== VERIFY TOKEN ====================
|
||||||
* Token doğrulama (RTK mantığı)
|
|
||||||
*/
|
|
||||||
export function useVerifyToken() {
|
export function useVerifyToken() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => {
|
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>(
|
const response = await apiClient.post<AuthResponse>("/auth/verify", verifyData);
|
||||||
"/auth/verify",
|
return response.data;
|
||||||
verifyData,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const token = data?.data || data?.token
|
const token = data?.data || data?.token;
|
||||||
if (token) {
|
if (token) {
|
||||||
setAuthToken(token)
|
setAuthToken(token);
|
||||||
apiClient.setAuthToken(token)
|
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
|
||||||
|
|
||||||
// Tüm cache'i temizle
|
|
||||||
queryClient.invalidateQueries()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error verifying token:", error)
|
console.error("Verify hatası:", error);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== LOGOUT ====================
|
||||||
* Logout işlemi
|
|
||||||
*/
|
|
||||||
export function useLogout() {
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (): Promise<void> => {
|
||||||
// Backend'e logout isteği gönder (eğer endpoint varsa)
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/auth/logout")
|
await apiClient.post("/auth/logout");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Logout endpoint yoksa da devam et
|
console.warn("Logout endpoint çalışmadı:", error);
|
||||||
console.warn("Logout endpoint not available")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
clearAuthToken()
|
clearAuthToken();
|
||||||
apiClient.clearAuthToken()
|
queryClient.clear();
|
||||||
|
|
||||||
// Tüm cache'i temizle
|
|
||||||
queryClient.clear()
|
|
||||||
|
|
||||||
// Login sayfasına yönlendir
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/login"
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
onError: (error) => {
|
||||||
}
|
console.error("Logout hatası:", error);
|
||||||
|
clearAuthToken();
|
||||||
/**
|
queryClient.clear();
|
||||||
* Kullanıcı bilgilerini getir
|
|
||||||
*/
|
|
||||||
export function useUser(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user", "me"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get("/auth/me")
|
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
enabled: options?.enabled !== false,
|
});
|
||||||
staleTime: 1000 * 60 * 5, // 5 dakika
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication durumunu kontrol et
|
|
||||||
*/
|
|
||||||
export function useAuthStatus() {
|
|
||||||
const { data: user, isLoading, error } = useUser({ enabled: true })
|
|
||||||
|
|
||||||
return {
|
|
||||||
isAuthenticated: !!user && !error,
|
|
||||||
isLoading,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { apiClient } from "@/lib/api"
|
|
||||||
import type { Collection, Product, PaginatedResponse } from "@/lib/types/api"
|
|
||||||
|
|
||||||
// Get all collections
|
|
||||||
export function useCollections(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collections"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Collection>>("/collections")
|
|
||||||
return response.data.data || response.data
|
|
||||||
},
|
|
||||||
enabled: options?.enabled !== false,
|
|
||||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get single collection by ID
|
|
||||||
export function useCollection(id: number | string, options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<Collection>(`/collections/${id}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
enabled: options?.enabled !== false && !!id,
|
|
||||||
staleTime: 1000 * 60 * 15,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get collection products (non-paginated)
|
|
||||||
export function useCollectionProducts(
|
|
||||||
collectionId: number | string,
|
|
||||||
options?: { enabled?: boolean }
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection", collectionId, "products"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
|
||||||
`/collections/${collectionId}/products`
|
|
||||||
)
|
|
||||||
const data = response.data.data || []
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
isEmpty: data.length === 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: options?.enabled !== false && !!collectionId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if collection has products (limit=1 for efficiency)
|
|
||||||
export function useCollectionHasProducts(
|
|
||||||
collectionId: number | string,
|
|
||||||
options?: { enabled?: boolean }
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection", collectionId, "has-products"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
|
||||||
`/collections/${collectionId}/products`,
|
|
||||||
{
|
|
||||||
params: { limit: 1 },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
hasProducts: response.data.data && response.data.data.length > 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: options?.enabled !== false && !!collectionId,
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get collection products with pagination
|
|
||||||
export function useCollectionProductsPaginated(
|
|
||||||
collectionId: number | string,
|
|
||||||
options?: {
|
|
||||||
enabled?: boolean
|
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const page = options?.page || 1
|
|
||||||
const limit = options?.limit || 6
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["collection", collectionId, "products-paginated", page, limit],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
|
||||||
`/collections/${collectionId}/products`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const data = response.data.data || []
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
pagination: response.data.pagination || {},
|
|
||||||
isEmpty: data.length === 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: options?.enabled !== false && !!collectionId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
"use client"
|
// "use client"
|
||||||
|
|
||||||
import { useMutation } from "@tanstack/react-query"
|
// import { useMutation } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/lib/api"
|
// import { apiClient } from "@/lib/api"
|
||||||
import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
// import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
||||||
|
|
||||||
interface OpenStoreData {
|
// interface OpenStoreData {
|
||||||
firstName: string
|
// firstName: string
|
||||||
lastName: string
|
// lastName: string
|
||||||
email: string
|
// email: string
|
||||||
phone: string
|
// phone: string
|
||||||
patentFile: File
|
// patentFile: File
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface OpenStoreResponse {
|
// interface OpenStoreResponse {
|
||||||
success: boolean
|
// success: boolean
|
||||||
message: string
|
// message: string
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useOpenStore() {
|
// export function useOpenStore() {
|
||||||
return useMutation({
|
// return useMutation({
|
||||||
mutationFn: async (data: OpenStoreData) => {
|
// mutationFn: async (data: OpenStoreData) => {
|
||||||
const formData = new FormData()
|
// const formData = new FormData()
|
||||||
formData.append("first_name", data.firstName)
|
// formData.append("first_name", data.firstName)
|
||||||
formData.append("last_name", data.lastName)
|
// formData.append("last_name", data.lastName)
|
||||||
formData.append("email", data.email)
|
// formData.append("email", data.email)
|
||||||
formData.append("phone", data.phone)
|
// formData.append("phone", data.phone)
|
||||||
formData.append("patent_file", data.patentFile)
|
// formData.append("patent_file", data.patentFile)
|
||||||
|
|
||||||
const response = await apiClient.post<OpenStoreResponse>(API_ENDPOINTS.openStore, formData, {
|
// const response = await apiClient.post<OpenStoreResponse>(API_ENDPOINTS.openStore, formData, {
|
||||||
headers: {
|
// headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
// "Content-Type": "multipart/form-data",
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
return response.data
|
// return response.data
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { apiClient } from "@/lib/api";
|
|
||||||
import type { Order, PaginatedResponse } from "@/lib/types/api";
|
|
||||||
|
|
||||||
// Response tiplerini tanımlayalım
|
|
||||||
interface OrderResponse {
|
|
||||||
data?: Order | Order[];
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderActionResponse {
|
|
||||||
data?: string | Order;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response'u transform eden yardımcı fonksiyonlar
|
|
||||||
function transformOrdersResponse(response: any): Order[] {
|
|
||||||
if (typeof response === "object" && response.data) {
|
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformOrderResponse(response: any): Order | null {
|
|
||||||
if (typeof response === "object" && response.data) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformOrderActionResponse(
|
|
||||||
response: any,
|
|
||||||
defaultValue: string
|
|
||||||
): string | Order {
|
|
||||||
if (response && response.data) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHtmlResponse(response: any): boolean {
|
|
||||||
return typeof response === "string" && response.includes("<!doctype html>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orders list query
|
|
||||||
export function useOrders(options?: { page?: number; perPage?: number }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["orders", options?.page],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get("/orders", {
|
|
||||||
params: {
|
|
||||||
page: options?.page || 1,
|
|
||||||
per_page: options?.perPage || 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return transformOrdersResponse(response.data);
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 5,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single order query
|
|
||||||
export function useOrder(id: number | string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["order", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get(`/orders/${id}`);
|
|
||||||
return transformOrderResponse(response.data);
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
|
||||||
staleTime: 1000 * 60 * 5,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order times query
|
|
||||||
export function useOrderTimes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["order-times"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get("/order-time");
|
|
||||||
return transformOrdersResponse(response.data);
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 10,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order payments query
|
|
||||||
export function useOrderPayments() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["order-payments"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get("/order-payments");
|
|
||||||
return transformOrdersResponse(response.data);
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 10,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/Place order mutation
|
|
||||||
export function useCreateOrder() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (orderData: Record<string, any>) => {
|
|
||||||
try {
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
|
|
||||||
// Convert orderData to URLSearchParams
|
|
||||||
Object.entries(orderData).forEach(([key, value]) => {
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
formData.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await apiClient.post("/orders", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
validateStatus: (status) => status >= 200 && status < 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for HTML response
|
|
||||||
if (isHtmlResponse(response.data)) {
|
|
||||||
throw new Error(
|
|
||||||
"Server returned HTML instead of expected response format"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformOrderActionResponse(response.data, "Order placed");
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle HTML error response
|
|
||||||
if (error.response && isHtmlResponse(error.response.data)) {
|
|
||||||
throw new Error(
|
|
||||||
"Server returned HTML instead of expected response format"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel order mutation
|
|
||||||
export function useCancelOrder() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (orderId: number) => {
|
|
||||||
const response = await apiClient.delete(`/orders/${orderId}`);
|
|
||||||
return transformOrderActionResponse(response.data, "Order cancelled");
|
|
||||||
},
|
|
||||||
onSuccess: (_, orderId) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
// import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/lib/api"
|
// import { apiClient } from "@/lib/api"
|
||||||
import type { SearchFilters, SearchResponse } from "@/lib/types/api"
|
// import type { SearchFilters, SearchResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
export function useSearch(options: SearchFilters) {
|
// export function useSearch(options: SearchFilters) {
|
||||||
const { q, category_id, brand_id, price_from, price_to, page = 1, per_page = 20 } = options
|
// const { q, category_id, brand_id, price_from, price_to, page = 1, per_page = 20 } = options
|
||||||
|
|
||||||
return useQuery({
|
// return useQuery({
|
||||||
queryKey: ["search", { q, category_id, brand_id, price_from, price_to, page, per_page }],
|
// queryKey: ["search", { q, category_id, brand_id, price_from, price_to, page, per_page }],
|
||||||
queryFn: async () => {
|
// queryFn: async () => {
|
||||||
const params = new URLSearchParams({
|
// const params = new URLSearchParams({
|
||||||
page: String(page),
|
// page: String(page),
|
||||||
per_page: String(per_page),
|
// per_page: String(per_page),
|
||||||
})
|
// })
|
||||||
|
|
||||||
if (q) params.append("q", q)
|
// // if (q) params.append("q", q)
|
||||||
if (category_id) params.append("category_id", String(category_id))
|
// if (category_id) params.append("category_id", String(category_id))
|
||||||
if (brand_id) params.append("brand_id", String(brand_id))
|
// if (brand_id) params.append("brand_id", String(brand_id))
|
||||||
if (price_from) params.append("price_from", String(price_from))
|
// if (price_from) params.append("price_from", String(price_from))
|
||||||
if (price_to) params.append("price_to", String(price_to))
|
// if (price_to) params.append("price_to", String(price_to))
|
||||||
|
|
||||||
const response = await apiClient.get<SearchResponse>(`/search?${params}`)
|
// const response = await apiClient.get<SearchResponse>(`/search?${params}`)
|
||||||
return response.data
|
// return response.data
|
||||||
},
|
// },
|
||||||
enabled: !!q && q.length > 0,
|
// enabled: !!q && q.length > 0,
|
||||||
staleTime: 1000 * 60 * 5,
|
// staleTime: 1000 * 60 * 5,
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { apiClient } from "@/lib/api"
|
|
||||||
import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
|
||||||
import type { UserProfile } from "@/lib/types/api"
|
|
||||||
|
|
||||||
export function useUserProfile(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user", "profile"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get<UserProfile>(API_ENDPOINTS.profile)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
|
||||||
enabled: options?.enabled !== false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom axios adapter for mocking API responses in development
|
|
||||||
* Intercepts requests and routes them to mock handlers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AxiosRequestConfig } from "axios"
|
|
||||||
import { mockHandlers } from "./handlers"
|
|
||||||
|
|
||||||
interface MockRequest extends AxiosRequestConfig {
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createMockAdapter = () => {
|
|
||||||
return async (config: MockRequest) => {
|
|
||||||
const url = config.url || ""
|
|
||||||
const method = (config.method || "get").toLowerCase()
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (method === "get") {
|
|
||||||
if (url === "/products") {
|
|
||||||
const response = await mockHandlers.getProducts()
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/products\/\d+$/)) {
|
|
||||||
const id = Number.parseInt(url.split("/")[2])
|
|
||||||
const response = await mockHandlers.getProduct(id)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/categories") {
|
|
||||||
const response = await mockHandlers.getCategories()
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/categories\/.+$/)) {
|
|
||||||
const slug = url.split("/")[2]
|
|
||||||
const response = await mockHandlers.getCategory(slug)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/cart") {
|
|
||||||
const response = await mockHandlers.getCart()
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/favorites") {
|
|
||||||
const response = await mockHandlers.getFavorites()
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/orders") {
|
|
||||||
const response = await mockHandlers.getOrders()
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/orders\/\d+$/)) {
|
|
||||||
const id = Number.parseInt(url.split("/")[2])
|
|
||||||
const response = await mockHandlers.getOrder(id)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/search/)) {
|
|
||||||
const params = new URLSearchParams(url.split("?")[1] || "")
|
|
||||||
const query = params.get("q") || ""
|
|
||||||
const response = await mockHandlers.search(query, {
|
|
||||||
category: params.get("category") || undefined,
|
|
||||||
priceFrom: params.get("priceFrom") ? Number.parseInt(params.get("priceFrom")!) : undefined,
|
|
||||||
priceTo: params.get("priceTo") ? Number.parseInt(params.get("priceTo")!) : undefined,
|
|
||||||
})
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "post") {
|
|
||||||
if (url === "/cart/items") {
|
|
||||||
const { productId, quantity } = config.data
|
|
||||||
const response = await mockHandlers.addToCart(productId, quantity)
|
|
||||||
return Promise.resolve({ data: response.data, status: 201, config, headers: {}, statusText: "Created" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/favorites") {
|
|
||||||
const { productId } = config.data
|
|
||||||
const response = await mockHandlers.addToFavorites(productId)
|
|
||||||
return Promise.resolve({ data: response.data, status: 201, config, headers: {}, statusText: "Created" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/orders\/\d+\/cancel$/)) {
|
|
||||||
const orderId = Number.parseInt(url.split("/")[2])
|
|
||||||
const response = await mockHandlers.cancelOrder(orderId)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "patch") {
|
|
||||||
if (url.match(/^\/cart\/items\/\d+$/)) {
|
|
||||||
const itemId = Number.parseInt(url.split("/")[3])
|
|
||||||
const { quantity } = config.data
|
|
||||||
const response = await mockHandlers.updateCartItemQuantity(itemId, quantity)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "delete") {
|
|
||||||
if (url.match(/^\/cart\/items\/\d+$/)) {
|
|
||||||
const itemId = Number.parseInt(url.split("/")[3])
|
|
||||||
const response = await mockHandlers.removeFromCart(itemId)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/^\/favorites\/\d+$/)) {
|
|
||||||
const productId = Number.parseInt(url.split("/")[2])
|
|
||||||
const response = await mockHandlers.removeFromFavorites(productId)
|
|
||||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback - endpoint not mocked
|
|
||||||
return Promise.reject({
|
|
||||||
response: { status: 404, data: { message: "Endpoint not found" }, config },
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
|
||||||
return Promise.reject({
|
|
||||||
response: {
|
|
||||||
status: 400,
|
|
||||||
data: { message: error.message },
|
|
||||||
config,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mock data for development and testing
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const mockProducts = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Premium Wireless Headphones",
|
|
||||||
price: 199.99,
|
|
||||||
category: "electronics",
|
|
||||||
image: "/wireless-headphones.png",
|
|
||||||
description: "High-quality sound with noise cancellation",
|
|
||||||
stock: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Classic Analog Watch",
|
|
||||||
price: 89.99,
|
|
||||||
category: "accessories",
|
|
||||||
image: "/analog-watch.png",
|
|
||||||
description: "Timeless design with precision movement",
|
|
||||||
stock: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Portable Charger 20000mAh",
|
|
||||||
price: 49.99,
|
|
||||||
category: "electronics",
|
|
||||||
image: "/portable-charger-lifestyle.png",
|
|
||||||
description: "Fast charging technology with dual ports",
|
|
||||||
stock: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Leather Messenger Bag",
|
|
||||||
price: 129.99,
|
|
||||||
category: "accessories",
|
|
||||||
image: "/leather-messenger-bag.png",
|
|
||||||
description: "Premium leather construction",
|
|
||||||
stock: 25,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "4K Webcam",
|
|
||||||
price: 149.99,
|
|
||||||
category: "electronics",
|
|
||||||
image: "/4k-webcam.jpg",
|
|
||||||
description: "Professional quality streaming camera",
|
|
||||||
stock: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: "USB-C Hub 7-in-1",
|
|
||||||
price: 59.99,
|
|
||||||
category: "electronics",
|
|
||||||
image: "/usb-c-hub.jpg",
|
|
||||||
description: "Multiple ports for connectivity",
|
|
||||||
stock: 75,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockCategories = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Electronics",
|
|
||||||
slug: "electronics",
|
|
||||||
image: "/electronics-category.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Accessories",
|
|
||||||
slug: "accessories",
|
|
||||||
image: "/accessories-category.png",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockOrders = [
|
|
||||||
{
|
|
||||||
id: 101,
|
|
||||||
status: "delivered" as const,
|
|
||||||
total: 299.98,
|
|
||||||
createdAt: new Date("2024-11-01").toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 102,
|
|
||||||
status: "processing" as const,
|
|
||||||
total: 199.99,
|
|
||||||
createdAt: new Date("2024-11-05").toISOString(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockFavorites = [
|
|
||||||
{ id: 1, productId: 1, addedAt: new Date().toISOString() },
|
|
||||||
{ id: 2, productId: 3, addedAt: new Date().toISOString() },
|
|
||||||
]
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mock HTTP handlers for development
|
|
||||||
* Simulates API responses for TanStack Query testing
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mockProducts, mockCategories, mockOrders, mockFavorites } from "./data"
|
|
||||||
|
|
||||||
interface MockCart {
|
|
||||||
id: string
|
|
||||||
items: Array<{ id: number; productId: number; quantity: number; price: number }>
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory storage for development
|
|
||||||
const mockCart: MockCart = {
|
|
||||||
id: "cart-1",
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mockUserFavorites = [...mockFavorites]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate network delay for realistic testing
|
|
||||||
*/
|
|
||||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock API handlers
|
|
||||||
*/
|
|
||||||
export const mockHandlers = {
|
|
||||||
// Products
|
|
||||||
async getProducts() {
|
|
||||||
await delay(300)
|
|
||||||
return { data: mockProducts }
|
|
||||||
},
|
|
||||||
|
|
||||||
async getProduct(id: number) {
|
|
||||||
await delay(300)
|
|
||||||
const product = mockProducts.find((p) => p.id === id)
|
|
||||||
if (!product) throw new Error("Product not found")
|
|
||||||
return { data: product }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
async getCategories() {
|
|
||||||
await delay(300)
|
|
||||||
return { data: mockCategories }
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCategory(slug: string) {
|
|
||||||
await delay(300)
|
|
||||||
const category = mockCategories.find((c) => c.slug === slug)
|
|
||||||
if (!category) throw new Error("Category not found")
|
|
||||||
const products = mockProducts.filter((p) => p.category === slug)
|
|
||||||
return { data: { ...category, products } }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cart operations
|
|
||||||
async getCart() {
|
|
||||||
await delay(200)
|
|
||||||
return { data: mockCart }
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToCart(productId: number, quantity = 1) {
|
|
||||||
await delay(300)
|
|
||||||
const product = mockProducts.find((p) => p.id === productId)
|
|
||||||
if (!product) throw new Error("Product not found")
|
|
||||||
|
|
||||||
const existingItem = mockCart.items.find((item) => item.productId === productId)
|
|
||||||
if (existingItem) {
|
|
||||||
existingItem.quantity += quantity
|
|
||||||
} else {
|
|
||||||
mockCart.items.push({
|
|
||||||
id: Date.now(),
|
|
||||||
productId,
|
|
||||||
quantity,
|
|
||||||
price: product.price,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
mockCart.total = mockCart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
||||||
return { data: mockCart }
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeFromCart(itemId: number) {
|
|
||||||
await delay(300)
|
|
||||||
mockCart.items = mockCart.items.filter((item) => item.id !== itemId)
|
|
||||||
mockCart.total = mockCart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
||||||
return { data: mockCart }
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateCartItemQuantity(itemId: number, quantity: number) {
|
|
||||||
await delay(300)
|
|
||||||
const item = mockCart.items.find((i) => i.id === itemId)
|
|
||||||
if (!item) throw new Error("Item not found")
|
|
||||||
item.quantity = Math.max(1, quantity)
|
|
||||||
mockCart.total = mockCart.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
|
|
||||||
return { data: mockCart }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Favorites
|
|
||||||
async getFavorites() {
|
|
||||||
await delay(200)
|
|
||||||
return { data: mockUserFavorites }
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToFavorites(productId: number) {
|
|
||||||
await delay(300)
|
|
||||||
const product = mockProducts.find((p) => p.id === productId)
|
|
||||||
if (!product) throw new Error("Product not found")
|
|
||||||
|
|
||||||
const exists = mockUserFavorites.find((f) => f.productId === productId)
|
|
||||||
if (!exists) {
|
|
||||||
mockUserFavorites.push({
|
|
||||||
id: Date.now(),
|
|
||||||
productId,
|
|
||||||
addedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { data: mockUserFavorites }
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeFromFavorites(productId: number) {
|
|
||||||
await delay(300)
|
|
||||||
mockUserFavorites = mockUserFavorites.filter((f) => f.productId !== productId)
|
|
||||||
return { data: mockUserFavorites }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Orders
|
|
||||||
async getOrders() {
|
|
||||||
await delay(300)
|
|
||||||
return { data: mockOrders }
|
|
||||||
},
|
|
||||||
|
|
||||||
async getOrder(id: number) {
|
|
||||||
await delay(300)
|
|
||||||
const order = mockOrders.find((o) => o.id === id)
|
|
||||||
if (!order) throw new Error("Order not found")
|
|
||||||
return { data: order }
|
|
||||||
},
|
|
||||||
|
|
||||||
async cancelOrder(orderId: number) {
|
|
||||||
await delay(300)
|
|
||||||
const order = mockOrders.find((o) => o.id === orderId)
|
|
||||||
if (!order) throw new Error("Order not found")
|
|
||||||
if (order.status !== "processing" && order.status !== "pending") {
|
|
||||||
throw new Error("Cannot cancel shipped or delivered orders")
|
|
||||||
}
|
|
||||||
order.status = "cancelled"
|
|
||||||
return { data: order }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Search
|
|
||||||
async search(query: string, filters?: { category?: string; priceFrom?: number; priceTo?: number }) {
|
|
||||||
await delay(400)
|
|
||||||
let results = mockProducts
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
results = results.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
p.description.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters?.category) {
|
|
||||||
results = results.filter((p) => p.category === filters.category)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters?.priceFrom) {
|
|
||||||
results = results.filter((p) => p.price >= filters.priceFrom!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters?.priceTo) {
|
|
||||||
results = results.filter((p) => p.price <= filters.priceTo!)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: { products: results, total: results.length } }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
327
lib/types/api.ts
327
lib/types/api.ts
@@ -1,239 +1,258 @@
|
|||||||
/**
|
/**
|
||||||
* API Response and Entity Type Definitions
|
* API Response and Entity Type Definitions
|
||||||
* Based on Postman collection structure
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Product Types
|
// Product Types
|
||||||
export interface ProductMedia {
|
export interface ProductMedia {
|
||||||
thumbnail: string
|
thumbnail: string;
|
||||||
images_400x400: string
|
images_400x400: string;
|
||||||
images_720x720: string
|
images_720x720: string;
|
||||||
images_800x800: string
|
images_800x800: string;
|
||||||
images_1200x1200: string
|
images_1200x1200: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductProperty {
|
export interface ProductProperty {
|
||||||
attribute_id: number
|
attribute_id: number;
|
||||||
name: string
|
name: string;
|
||||||
value: string
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductReviews {
|
export interface ProductReviews {
|
||||||
count: number
|
count: number;
|
||||||
rating: string
|
rating: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number
|
id: number;
|
||||||
parent_id: number | null
|
parent_id: number | null;
|
||||||
name: string
|
name: string;
|
||||||
slug: string
|
slug: string;
|
||||||
description: string
|
description: string;
|
||||||
sku: string | null
|
sku: string | null;
|
||||||
barcode: string
|
barcode: string;
|
||||||
stock: number
|
stock: number;
|
||||||
price_amount: string
|
price_amount: string;
|
||||||
old_price_amount: string | null
|
old_price_amount: string | null;
|
||||||
backorder: string
|
backorder: string;
|
||||||
weight_value: number | null
|
weight_value: number | null;
|
||||||
weight_unit: string | null
|
weight_unit: string | null;
|
||||||
height_value: number | null
|
height_value: number | null;
|
||||||
height_unit: string | null
|
height_unit: string | null;
|
||||||
media: ProductMedia[]
|
media: ProductMedia[];
|
||||||
created_at: string
|
created_at: string;
|
||||||
seo_title: string | null
|
seo_title: string | null;
|
||||||
seo_description: string | null
|
seo_description: string | null;
|
||||||
colour: string | null
|
is_visible: boolean;
|
||||||
size: string | null
|
colour: string | null;
|
||||||
available_colors: string[]
|
size: string | null;
|
||||||
available_sizes: string[]
|
available_colors?: string[];
|
||||||
|
available_sizes?: string[];
|
||||||
brand: {
|
brand: {
|
||||||
id: number | null
|
id: number | null;
|
||||||
name: string | null
|
name: string | null;
|
||||||
}
|
};
|
||||||
channel: Array<{
|
channel?: Array<{
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
}>
|
}>;
|
||||||
properties: ProductProperty[]
|
properties?: ProductProperty[];
|
||||||
variations: any[]
|
variations?: any[];
|
||||||
reviews: ProductReviews
|
reviews: ProductReviews;
|
||||||
reviews_resources: any[]
|
reviews_resources?: any[];
|
||||||
categories: Array<{
|
categories?: Array<{
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
}>
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category Types
|
// Category Types
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
slug: string
|
slug: string;
|
||||||
image: string
|
image: string;
|
||||||
parent_id?: number
|
parent_id?: number;
|
||||||
children?: Category[]
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection Types
|
||||||
|
export interface Collection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cart Types
|
// Cart Types
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
id: number
|
id: number;
|
||||||
product_id: number
|
product_id: number;
|
||||||
product?: Product
|
product?: Product;
|
||||||
seller: {
|
seller: {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
}
|
};
|
||||||
quantity: number
|
quantity: number;
|
||||||
price: number
|
price: number;
|
||||||
total: number
|
total: number;
|
||||||
price_formatted?: string
|
price_formatted?: string;
|
||||||
sub_total_formatted?: string
|
sub_total_formatted?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Cart {
|
export interface Cart {
|
||||||
id: string
|
id: string;
|
||||||
items: CartItem[]
|
items: CartItem[];
|
||||||
total: number
|
total: number;
|
||||||
total_formatted?: string
|
total_formatted?: string;
|
||||||
count?: number
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorites Types
|
// Favorites Types
|
||||||
export interface Favorite {
|
export interface Favorite {
|
||||||
id: number
|
id: number;
|
||||||
product_id: number
|
product_id: number;
|
||||||
product?: Product
|
product?: Product;
|
||||||
added_at?: string
|
added_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order Types
|
// Order Types
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
id: number
|
id: number;
|
||||||
product_id: number
|
product_id: number;
|
||||||
product?: Product
|
product?: Product;
|
||||||
quantity: number
|
quantity: number;
|
||||||
price: number
|
price: number;
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: number
|
id: number;
|
||||||
number?: string
|
number?: string;
|
||||||
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"
|
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled";
|
||||||
items: OrderItem[]
|
items: OrderItem[];
|
||||||
total: number
|
total: number;
|
||||||
total_formatted?: string
|
total_formatted?: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at?: string
|
updated_at?: string;
|
||||||
estimated_delivery?: string
|
estimated_delivery?: string;
|
||||||
tracking_number?: string
|
tracking_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination Types
|
// Pagination Types
|
||||||
export interface PaginatedResponse<T> {
|
export interface Pagination {
|
||||||
data: T[]
|
page: number;
|
||||||
pagination: {
|
perPage: number;
|
||||||
current_page: number
|
count: number;
|
||||||
last_page: number
|
first_page_url?: string;
|
||||||
per_page: number
|
next_page_url?: string | null;
|
||||||
total: number
|
prev_page_url?: string | null;
|
||||||
|
current_page?: number;
|
||||||
|
last_page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
message?: string;
|
||||||
|
data: T[];
|
||||||
|
pagination: Pagination;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Types
|
// Search Types
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
q?: string
|
q?: string;
|
||||||
category_id?: number
|
category_id?: number;
|
||||||
brand_id?: number
|
brand_id?: number;
|
||||||
price_from?: number
|
price_from?: number;
|
||||||
price_to?: number
|
price_to?: number;
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
products: Product[]
|
products: Product[];
|
||||||
total: number
|
total: number;
|
||||||
filters?: {
|
filters?: {
|
||||||
brands: Array<{ id: number; name: string }>
|
brands: Array<{ id: number; name: string }>;
|
||||||
categories: Array<{ id: number; name: string }>
|
categories: Array<{ id: number; name: string }>;
|
||||||
price_range: { min: number; max: number }
|
price_range: { min: number; max: number };
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile Types
|
// Profile Types
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number
|
id: number;
|
||||||
email: string
|
email: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
first_name?: string
|
first_name?: string;
|
||||||
last_name?: string
|
last_name?: string;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth Types
|
// Auth Types
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string
|
token: string;
|
||||||
user: UserProfile
|
user: UserProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Banner Types
|
// Banner Types
|
||||||
export interface Banner {
|
export interface Banner {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
url?: string
|
thumbnail?: string;
|
||||||
type?: string
|
link?: string;
|
||||||
place?: string
|
url?: string;
|
||||||
|
type?: string;
|
||||||
|
place?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic API Error Response
|
// Generic API Error Response
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
message: string
|
message: string;
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region, Address, PaymentType, and ShippingMethod Types
|
// Region, Address, PaymentType, and ShippingMethod Types
|
||||||
export interface Region {
|
export interface Region {
|
||||||
id: number
|
id: number;
|
||||||
code: string
|
code: string;
|
||||||
name: string
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Address {
|
export interface Address {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
region_id: number
|
region_id: number;
|
||||||
address: string
|
address: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
is_default?: boolean
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentTypeOption {
|
export interface PaymentTypeOption {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
code: string
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShippingMethod {
|
export interface ShippingMethod {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
code: string
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order creation payload type
|
// Order creation payload type
|
||||||
export interface CreateOrderPayload {
|
export interface CreateOrderPayload {
|
||||||
customer_name?: string
|
customer_name?: string;
|
||||||
customer_phone?: string
|
customer_phone?: string;
|
||||||
customer_address: string
|
customer_address: string;
|
||||||
shipping_method: string
|
shipping_method: string;
|
||||||
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
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import createMiddleware from "next-intl/middleware"
|
import createMiddleware from "next-intl/middleware"
|
||||||
import { locales, defaultLocale } from "./i18n"
|
import { locales, defaultLocale } from "./i18n/i18n"
|
||||||
|
|
||||||
const middleware = createMiddleware({
|
const middleware = createMiddleware({
|
||||||
locales,
|
locales,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
import createNextIntlPlugin from "next-intl/plugin"
|
import createNextIntlPlugin from "next-intl/plugin"
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./i18n.ts")
|
const withNextIntl = createNextIntlPlugin("./i18n/i18n.ts")
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@@ -1635,6 +1636,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-focus-guards": {
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
@@ -1739,6 +1769,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
|||||||
101
public/styr.text
Normal file
101
public/styr.text
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
src/
|
||||||
|
├── app/ # Next.js 14 App Router
|
||||||
|
│ └── [locale]/
|
||||||
|
│ ├── (shop)/ # Route group - ortak layout
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ ├── category/[slug]/
|
||||||
|
│ │ ├── product/[slug]/
|
||||||
|
│ │ └── cart/
|
||||||
|
│ └── (account)/ # Route group - kullanıcı sayfaları
|
||||||
|
│ ├── me/
|
||||||
|
│ ├── orders/
|
||||||
|
│ └── favorites/
|
||||||
|
│
|
||||||
|
├── features/ # Feature-based organization
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ └── types.ts
|
||||||
|
│ ├── cart/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ └── types.ts
|
||||||
|
│ ├── products/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ └── types.ts
|
||||||
|
│ └── orders/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ └── types.ts
|
||||||
|
│
|
||||||
|
├── shared/
|
||||||
|
│ ├── components/ # Paylaşılan UI bileşenleri
|
||||||
|
│ │ ├── ui/ # shadcn/ui
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ └── skeletons/
|
||||||
|
│ ├── hooks/ # Generic hooks (use-mobile, use-toast)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ ├── config/
|
||||||
|
│ │ └── utils/
|
||||||
|
│ └── types/
|
||||||
|
│
|
||||||
|
└── i18n/ # Lokalizasyon
|
||||||
|
├── messages/
|
||||||
|
├── config.ts
|
||||||
|
└── middleware.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Tüm hooks'ları tek yere taşı
|
||||||
|
mv hooks/* lib/hooks/
|
||||||
|
rm -rf hooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Feature-Based Yapıya Geç
|
||||||
|
|
||||||
|
**Şu anki yapın:**
|
||||||
|
```
|
||||||
|
components/home/HomePage.tsx
|
||||||
|
lib/hooks/useProducts.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Olması gereken:**
|
||||||
|
```
|
||||||
|
features/products/
|
||||||
|
├── components/
|
||||||
|
│ ├── ProductCard.tsx
|
||||||
|
│ ├── ProductGrid.tsx
|
||||||
|
│ └── ProductDetails.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ └── useProducts.ts
|
||||||
|
└── types.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { ProductCard, useProducts } from '@/features/products'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty States'i Doğru Yerleştir
|
||||||
|
|
||||||
|
Şu an:
|
||||||
|
```
|
||||||
|
components/empty-states/EmptyCart.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Olmalı:
|
||||||
|
```
|
||||||
|
features/cart/components/EmptyState.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Her feature kendi empty state'ini yönetmeli.
|
||||||
|
|
||||||
|
## API Layer'ı Düzenle
|
||||||
|
```
|
||||||
|
shared/lib/api/
|
||||||
|
├── client.ts # Axios/fetch instance
|
||||||
|
├── endpoints.ts # API endpoint'leri
|
||||||
|
├── types.ts # Genel API tipleri
|
||||||
|
└── interceptors.ts # Auth, error handling
|
||||||
Reference in New Issue
Block a user