connected api with profile, order

This commit is contained in:
Jelaletdin12
2025-11-15 16:14:01 +05:00
parent 21b9e88c5c
commit f867896817
70 changed files with 2370 additions and 2317 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View File

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

View File

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

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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
View File

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

View 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
View 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;
}

View File

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

View File

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

View 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
View 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;
}

View 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() },
]

View File

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

View File

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

View File

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

View File

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

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

View File

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