first commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
171
app/[locale]/cart/page.tsx
Normal file
171
app/[locale]/cart/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import CartItemCard from "./ui/CartItemCard"
|
||||||
|
import OrderSummary from "./ui/OrderSummary"
|
||||||
|
import { useCart, useCreateOrder, useRegions, useAddresses, usePaymentTypes } from "@/lib/hooks"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import type { DeliveryType, PaymentTypeOption } from "./ui/types"
|
||||||
|
|
||||||
|
export default function CartPage() {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [paymentType, setPaymentType] = useState<PaymentTypeOption | null>(null)
|
||||||
|
const [deliveryType, setDeliveryType] = useState<DeliveryType>("SELECTED_DELIVERY")
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState<string | null>(null)
|
||||||
|
const [selectedAddress, setSelectedAddress] = useState<string>("")
|
||||||
|
const [note, setNote] = useState<string>("")
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const t = useTranslations()
|
||||||
|
|
||||||
|
const { data: cart, isLoading, isError } = useCart()
|
||||||
|
const { data: regions = [] } = useRegions()
|
||||||
|
const { data: addresses = [] } = useAddresses()
|
||||||
|
const { data: paymentTypes = [] } = usePaymentTypes()
|
||||||
|
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeliveryTypeChange = (type: DeliveryType) => {
|
||||||
|
setDeliveryType(type)
|
||||||
|
setSelectedAddress("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompleteOrder = () => {
|
||||||
|
if (!selectedRegion || !selectedAddress || !paymentType) {
|
||||||
|
console.warn("[v0] Missing required fields for order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRegionObj = regions.find((r) => r.code === selectedRegion)
|
||||||
|
|
||||||
|
createOrder(
|
||||||
|
{
|
||||||
|
customer_address: selectedAddress,
|
||||||
|
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart",
|
||||||
|
payment_type_id: paymentType.id,
|
||||||
|
region: selectedRegion,
|
||||||
|
note: note || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Navigate to orders page after successful order creation
|
||||||
|
router.push(`/orders`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClient) return null
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
|
||||||
|
<p>{t("loading")}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !cart?.items || cart.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{t("emptyCart") || "Your cart is empty"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
cart: t("cart"),
|
||||||
|
ordersIn: t("order_available_in_shops"),
|
||||||
|
pricePerUnit: t("unit_price"),
|
||||||
|
additionalPrice: t("extra_price"),
|
||||||
|
discount: t("discount"),
|
||||||
|
totalPrice: t("total_price"),
|
||||||
|
paymentType: t("payment_type"),
|
||||||
|
cash: t("cash"),
|
||||||
|
card: t("card"),
|
||||||
|
deliveryType: t("delivery_type"),
|
||||||
|
delivery: t("delivery"),
|
||||||
|
pickup: t("pickup"),
|
||||||
|
selectRegion: t("choose_region"),
|
||||||
|
selectAddress: t("choose_address"),
|
||||||
|
note: t("note"),
|
||||||
|
placeOrder: t("order"),
|
||||||
|
emptyCart: t("cart_empty"),
|
||||||
|
map: t("address"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsBySeller = cart.items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const sellerId = item.seller.id
|
||||||
|
if (!acc[sellerId]) {
|
||||||
|
acc[sellerId] = { seller: item.seller, items: [] }
|
||||||
|
}
|
||||||
|
acc[sellerId].items.push(item)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<number, { seller: any; items: typeof cart.items }>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{translations.cart}</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{/* Cart Items Section */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Card className="p-6 rounded-xl">
|
||||||
|
{/* Sellers */}
|
||||||
|
{Object.entries(itemsBySeller).map(([sellerId, { seller, items }]) => (
|
||||||
|
<div key={sellerId} className="mb-6">
|
||||||
|
<p className="text-base font-semibold mb-3">{seller.name}</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<CartItemCard key={item.id} item={item} translations={translations} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Object.entries(itemsBySeller).length > 1 && <Separator className="mt-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Summary Sidebar */}
|
||||||
|
<OrderSummary
|
||||||
|
order={{
|
||||||
|
id: 1,
|
||||||
|
seller: { id: 1, name: "Store" },
|
||||||
|
items: cart.items,
|
||||||
|
billing: {
|
||||||
|
body: [{ title: t("goods"), value: `${cart.total_formatted || `${cart.total} TMT`}` }],
|
||||||
|
footer: { title: t("total"), value: `${cart.total_formatted || `${cart.total} TMT`}` },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
translations={translations}
|
||||||
|
paymentType={paymentType}
|
||||||
|
deliveryType={deliveryType}
|
||||||
|
selectedRegion={selectedRegion}
|
||||||
|
selectedAddress={selectedAddress}
|
||||||
|
note={note}
|
||||||
|
regions={regions}
|
||||||
|
addresses={addresses}
|
||||||
|
paymentTypes={paymentTypes}
|
||||||
|
onPaymentTypeChange={setPaymentType}
|
||||||
|
onDeliveryTypeChange={handleDeliveryTypeChange}
|
||||||
|
onRegionChange={setSelectedRegion}
|
||||||
|
onAddressChange={setSelectedAddress}
|
||||||
|
onNoteChange={setNote}
|
||||||
|
onMapOpen={() => {}}
|
||||||
|
onCompleteOrder={handleCompleteOrder}
|
||||||
|
isLoading={isCreatingOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
app/[locale]/cart/ui/CartItemCard.tsx
Normal file
105
app/[locale]/cart/ui/CartItemCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { Minus, Plus, Trash2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks"
|
||||||
|
import type { CartItem, CartTranslations } from "./types"
|
||||||
|
|
||||||
|
interface CartItemCardProps {
|
||||||
|
item: CartItem
|
||||||
|
translations: CartTranslations
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartItemCard({ item, translations: t }: CartItemCardProps) {
|
||||||
|
const { mutate: updateQuantity, isPending: isUpdating } = useUpdateCartItemQuantity()
|
||||||
|
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
|
||||||
|
|
||||||
|
const handleQuantityChange = (delta: number) => {
|
||||||
|
const newQuantity = item.quantity + delta
|
||||||
|
if (newQuantity >= 1) {
|
||||||
|
updateQuantity({ itemId: item.id, quantity: newQuantity })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeItem(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4 shadow-none border">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
{/* Product Image & Info */}
|
||||||
|
<div className="flex gap-4 flex-1">
|
||||||
|
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={item.product.image || item.product.images[0] || "/placeholder.svg"}
|
||||||
|
alt={item.product.name}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="font-semibold text-base">{item.product.name}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{item.seller.name}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isRemoving}
|
||||||
|
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price & Quantity */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t.pricePerUnit} <span className="text-primary">{item.price_formatted || `${item.price} TMT`}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t.additionalPrice} {item.sub_total_formatted || `${item.total} TMT`}
|
||||||
|
</p>
|
||||||
|
{item.discount_formatted && item.discount_formatted !== "0 TMT" && (
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t.discount} {item.discount_formatted}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">{t.totalPrice}</span>
|
||||||
|
<span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
|
||||||
|
{item.total_formatted || `${item.total} TMT`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleQuantityChange(-1)}
|
||||||
|
disabled={item.quantity === 1 || isUpdating}
|
||||||
|
className="rounded-xl bg-blue-50"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-12 text-center font-semibold">{item.quantity}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleQuantityChange(1)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="rounded-xl bg-blue-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
app/[locale]/cart/ui/DeliveryTypeSelector.tsx
Normal file
53
app/[locale]/cart/ui/DeliveryTypeSelector.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Truck, Warehouse } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { DeliveryType, CartTranslations } from "./types";
|
||||||
|
|
||||||
|
interface DeliveryTypeSelectorProps {
|
||||||
|
selectedType: DeliveryType;
|
||||||
|
onSelect: (type: DeliveryType) => void;
|
||||||
|
translations: CartTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeliveryTypeSelector({
|
||||||
|
selectedType,
|
||||||
|
onSelect,
|
||||||
|
translations: t,
|
||||||
|
}: DeliveryTypeSelectorProps) {
|
||||||
|
const deliveryOptions: {
|
||||||
|
type: DeliveryType;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Truck;
|
||||||
|
}[] = [
|
||||||
|
{ type: "SELECTED_DELIVERY", label: t.delivery, icon: Truck },
|
||||||
|
{ type: "PICK_UP", label: t.pickup, icon: Warehouse },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{t.deliveryType}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{deliveryOptions.map(({ type, label, icon: Icon }) => (
|
||||||
|
<Card
|
||||||
|
key={type}
|
||||||
|
className={`flex-1 cursor-pointer transition-all ${
|
||||||
|
selectedType === type
|
||||||
|
? "border-2 border-[#005bff]"
|
||||||
|
: "border-2 border-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(type)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center p-4 gap-2">
|
||||||
|
<Icon
|
||||||
|
className={`h-8 w-8 ${
|
||||||
|
selectedType === type ? "text-[#005bff]" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
app/[locale]/cart/ui/OrderSummary.tsx
Normal file
177
app/[locale]/cart/ui/OrderSummary.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"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) {
|
||||||
|
const filteredAddresses = selectedRegion
|
||||||
|
? addresses.filter((addr) => {
|
||||||
|
const region = regions.find((r) => r.code === selectedRegion)
|
||||||
|
return region && addr.region_id === region.id
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const isFormValid = selectedRegion && selectedAddress && 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]" : "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 */}
|
||||||
|
<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 */}
|
||||||
|
{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 */}
|
||||||
|
<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 */}
|
||||||
|
<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-[#005bff] cursor-pointer h-12 text-lg font-bold disabled:opacity-50"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading ? t.placeOrder + "..." : t.placeOrder}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
app/[locale]/cart/ui/PaymentTypeSelector.tsx
Normal file
49
app/[locale]/cart/ui/PaymentTypeSelector.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CreditCard } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { PaymentType, CartTranslations } from "./types";
|
||||||
|
|
||||||
|
interface PaymentTypeSelectorProps {
|
||||||
|
selectedType: PaymentType;
|
||||||
|
onSelect: (type: PaymentType) => void;
|
||||||
|
translations: CartTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentTypeSelector({
|
||||||
|
selectedType,
|
||||||
|
onSelect,
|
||||||
|
translations: t,
|
||||||
|
}: PaymentTypeSelectorProps) {
|
||||||
|
const paymentOptions: { type: PaymentType; label: string }[] = [
|
||||||
|
{ type: "CASH", label: t.cash },
|
||||||
|
{ type: "CARD", label: t.card },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{paymentOptions.map(({ type, label }) => (
|
||||||
|
<Card
|
||||||
|
key={type}
|
||||||
|
className={`flex-1 cursor-pointer transition-all ${
|
||||||
|
selectedType === type
|
||||||
|
? "border-2 border-[#005bff]"
|
||||||
|
: "border-2 border-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(type)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center p-4 gap-2">
|
||||||
|
<CreditCard
|
||||||
|
className={`h-8 w-8 ${
|
||||||
|
selectedType === type ? "text-[#005bff]" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/[locale]/cart/ui/types.ts
Normal file
87
app/[locale]/cart/ui/types.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { StaticImageData } from "next/image"
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: number
|
||||||
|
product_id: number
|
||||||
|
product: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
images: (StaticImageData | string)[]
|
||||||
|
image?: StaticImageData | string
|
||||||
|
}
|
||||||
|
seller: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
total: number
|
||||||
|
price_formatted?: string
|
||||||
|
sub_total_formatted?: string
|
||||||
|
discount_formatted?: string
|
||||||
|
total_formatted?: 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 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"
|
||||||
36
app/[locale]/category/[slug]/page.tsx
Normal file
36
app/[locale]/category/[slug]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 600 // ISR: Revalidate every 10 minutes
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
|
||||||
|
description: `Browse ${slug} products in our store`,
|
||||||
|
openGraph: {
|
||||||
|
locale,
|
||||||
|
type: "website",
|
||||||
|
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
|
||||||
|
description: `Browse ${slug} products in our store`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
// Generate static params for popular categories
|
||||||
|
const categories = ["electronics", "clothing", "home-garden"]
|
||||||
|
return categories.map((slug) => ({ slug }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryPage(props: Props) {
|
||||||
|
const params = await props.params
|
||||||
|
const { slug } = params
|
||||||
|
|
||||||
|
const CategoryPageClient = (await import("./ui/CategoryClient")).default
|
||||||
|
return <CategoryPageClient params={params} />
|
||||||
|
}
|
||||||
490
app/[locale]/category/[slug]/ui/CategoryClient.tsx
Normal file
490
app/[locale]/category/[slug]/ui/CategoryClient.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { ChevronLeft, SlidersHorizontal, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import CategoryPageContent from "./CategoryContent"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface FilterItem {
|
||||||
|
key: string
|
||||||
|
value: number
|
||||||
|
name: string
|
||||||
|
hex?: string
|
||||||
|
image?: string
|
||||||
|
selected?: boolean
|
||||||
|
slug?: string
|
||||||
|
children?: FilterItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filter {
|
||||||
|
uuid: string
|
||||||
|
title: string
|
||||||
|
type: "TREE" | "SELECTABLE" | "VOLUME" | "TAB" | "COLOR"
|
||||||
|
items: FilterItem | FilterItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryPageClientProps {
|
||||||
|
params: { locale: string; slug: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryPageClient({ params }: CategoryPageClientProps) {
|
||||||
|
const { slug, locale } = params
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
// Price filter state
|
||||||
|
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000])
|
||||||
|
|
||||||
|
// Selected filters state
|
||||||
|
const [selectedFilters, setSelectedFilters] = useState<Record<string, Set<number>>>({
|
||||||
|
brand: new Set(),
|
||||||
|
color: new Set(),
|
||||||
|
tag: new Set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
filter: "Filters",
|
||||||
|
from: "From",
|
||||||
|
to: "To",
|
||||||
|
reset: "Reset",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
{
|
||||||
|
uuid: "1",
|
||||||
|
title: "Category",
|
||||||
|
type: "TREE",
|
||||||
|
items: {
|
||||||
|
key: "category",
|
||||||
|
value: 1,
|
||||||
|
name: "All",
|
||||||
|
slug: slug,
|
||||||
|
selected: true,
|
||||||
|
children: [
|
||||||
|
{ key: "category", value: 2, name: "Electronics", slug: "electronics", selected: false },
|
||||||
|
{ key: "category", value: 3, name: "Clothing", slug: "clothing", selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "2",
|
||||||
|
title: "Brand",
|
||||||
|
type: "SELECTABLE",
|
||||||
|
items: [
|
||||||
|
{ key: "brand", value: 10, name: "Brand A", image: "/brand-a.png", selected: false },
|
||||||
|
{ key: "brand", value: 11, name: "Brand B", image: "/brand-b.png", selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "3",
|
||||||
|
title: "Price",
|
||||||
|
type: "VOLUME",
|
||||||
|
items: {} as FilterItem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "4",
|
||||||
|
title: "Color",
|
||||||
|
type: "COLOR",
|
||||||
|
items: [
|
||||||
|
{ key: "color", value: 100, name: "Red", hex: "#FF0000", selected: false },
|
||||||
|
{ key: "color", value: 101, name: "Blue", hex: "#0000FF", selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "5",
|
||||||
|
title: "Tags",
|
||||||
|
type: "TAB",
|
||||||
|
items: [
|
||||||
|
{ key: "tag", value: 200, name: "New Arrival", selected: false },
|
||||||
|
{ key: "tag", value: 201, name: "Sale", selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleFilterChange = (key: string, value: number) => {
|
||||||
|
setSelectedFilters((prev) => {
|
||||||
|
const newFilters = { ...prev }
|
||||||
|
if (!newFilters[key]) {
|
||||||
|
newFilters[key] = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFilters[key].has(value)) {
|
||||||
|
newFilters[key].delete(value)
|
||||||
|
} else {
|
||||||
|
newFilters[key].add(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFilters
|
||||||
|
})
|
||||||
|
|
||||||
|
updateURLParams(key, Array.from(selectedFilters[key] || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePriceChange = (values: number[]) => {
|
||||||
|
setPriceRange([values[0], values[1]])
|
||||||
|
updateURLParams("price_min", values[0])
|
||||||
|
updateURLParams("price_max", values[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePriceInputChange = (type: "from" | "to", value: string) => {
|
||||||
|
const numValue = parseInt(value) || 0
|
||||||
|
if (type === "from") {
|
||||||
|
setPriceRange([numValue, priceRange[1]])
|
||||||
|
updateURLParams("price_min", numValue)
|
||||||
|
} else {
|
||||||
|
setPriceRange([priceRange[0], numValue])
|
||||||
|
updateURLParams("price_max", numValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateURLParams = (key: string, value: number | number[]) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
params.delete(key)
|
||||||
|
value.forEach((v) => params.append(key, v.toString()))
|
||||||
|
} else {
|
||||||
|
params.set(key, value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/${locale}/category/${slug}?${params.toString()}`, { scroll: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setSelectedFilters({
|
||||||
|
brand: new Set(),
|
||||||
|
color: new Set(),
|
||||||
|
tag: new Set(),
|
||||||
|
})
|
||||||
|
setPriceRange([0, 10000])
|
||||||
|
router.push(`/${locale}/category/${slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FiltersContent = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
switch (filter.type) {
|
||||||
|
case "TREE":
|
||||||
|
return (
|
||||||
|
<CategoryFilter
|
||||||
|
key={filter.uuid}
|
||||||
|
data={filter.items as FilterItem}
|
||||||
|
title={filter.title}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "SELECTABLE":
|
||||||
|
return (
|
||||||
|
<BrandFilter
|
||||||
|
key={filter.uuid}
|
||||||
|
data={filter.items as FilterItem[]}
|
||||||
|
title={filter.title}
|
||||||
|
selectedValues={selectedFilters.brand}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "VOLUME":
|
||||||
|
return (
|
||||||
|
<PriceFilter
|
||||||
|
key={filter.uuid}
|
||||||
|
title={filter.title}
|
||||||
|
priceRange={priceRange}
|
||||||
|
onPriceChange={handlePriceChange}
|
||||||
|
onInputChange={handlePriceInputChange}
|
||||||
|
translations={{ from: t.from, to: t.to }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "COLOR":
|
||||||
|
return (
|
||||||
|
<ColorFilter
|
||||||
|
key={filter.uuid}
|
||||||
|
data={filter.items as FilterItem[]}
|
||||||
|
title={filter.title}
|
||||||
|
selectedValues={selectedFilters.color}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "TAB":
|
||||||
|
return (
|
||||||
|
<TagFilter
|
||||||
|
key={filter.uuid}
|
||||||
|
data={filter.items as FilterItem[]}
|
||||||
|
title={filter.title}
|
||||||
|
selectedValues={selectedFilters.tag}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
<Button variant="outline" className="w-full rounded-xl bg-transparent" onClick={resetFilters}>
|
||||||
|
{t.reset}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Desktop Filters - LEFT SIDE */}
|
||||||
|
<div className="hidden sm:block w-[280px] flex-shrink-0 border-r pr-4">
|
||||||
|
<ScrollArea className="h-[calc(100vh-120px)]">
|
||||||
|
<FiltersContent />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - RIGHT SIDE */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<CategoryPageContent slug={slug} filters={selectedFilters} priceRange={priceRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter Sheet */}
|
||||||
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg" size="lg">
|
||||||
|
{t.filter}
|
||||||
|
<SlidersHorizontal className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-[290px] p-0">
|
||||||
|
<SheetHeader className="p-4 border-b">
|
||||||
|
<SheetTitle>{t.filter}</SheetTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</button>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
||||||
|
<FiltersContent />
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryFilter({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
locale
|
||||||
|
}: {
|
||||||
|
data: FilterItem
|
||||||
|
title: string
|
||||||
|
locale: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/category/${data.slug}?category_id=${data.value}`}
|
||||||
|
className={`flex items-center gap-2 py-2 px-2 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||||
|
data.selected ? "text-primary font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
{data.name}
|
||||||
|
</Link>
|
||||||
|
{data.children && data.children.length > 0 && (
|
||||||
|
<div className="ml-6 space-y-1">
|
||||||
|
{data.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.value}
|
||||||
|
href={`/${locale}/category/${child.slug}?category_id=${child.value}`}
|
||||||
|
className={`block py-2 px-2 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||||
|
child.selected ? "text-primary font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandFilter({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
selectedValues,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
data: FilterItem[]
|
||||||
|
title: string
|
||||||
|
selectedValues: Set<number>
|
||||||
|
onFilterChange: (key: string, value: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||||
|
<ScrollArea className="max-h-[410px]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{data.map((item) => {
|
||||||
|
const isSelected = selectedValues.has(item.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => onFilterChange(item.key, item.value)}
|
||||||
|
className={`w-full flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-gray-100 transition-colors group ${
|
||||||
|
isSelected ? "text-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.image && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-[50px] h-[50px] bg-gray-50 rounded-lg border-2 transition-colors ${
|
||||||
|
isSelected ? "border-primary" : "border-transparent group-hover:border-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative w-8 h-8">
|
||||||
|
<Image src={item.image || "/placeholder.svg"} alt={item.name} fill className="object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-left">{item.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceFilter({
|
||||||
|
title,
|
||||||
|
priceRange,
|
||||||
|
onPriceChange,
|
||||||
|
onInputChange,
|
||||||
|
translations,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
priceRange: [number, number]
|
||||||
|
onPriceChange: (values: number[]) => void
|
||||||
|
onInputChange: (type: "from" | "to", value: string) => void
|
||||||
|
translations: { from: string; to: string }
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="price-from" className="text-xs mb-1">
|
||||||
|
{translations.from}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="price-from"
|
||||||
|
type="number"
|
||||||
|
value={priceRange[0]}
|
||||||
|
onChange={(e) => onInputChange("from", e.target.value)}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="price-to" className="text-xs mb-1">
|
||||||
|
{translations.to}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="price-to"
|
||||||
|
type="number"
|
||||||
|
value={priceRange[1]}
|
||||||
|
onChange={(e) => onInputChange("to", e.target.value)}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider min={0} max={99999} step={100} value={priceRange} onValueChange={onPriceChange} className="mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagFilter({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
selectedValues,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
data: FilterItem[]
|
||||||
|
title: string
|
||||||
|
selectedValues: Set<number>
|
||||||
|
onFilterChange: (key: string, value: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.map((item) => {
|
||||||
|
const isSelected = selectedValues.has(item.value)
|
||||||
|
return (
|
||||||
|
<div key={item.value} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`tag-${item.value}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => onFilterChange(item.key, item.value)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`tag-${item.value}`} className="text-sm font-normal cursor-pointer">
|
||||||
|
{item.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorFilter({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
selectedValues,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
data: FilterItem[]
|
||||||
|
title: string
|
||||||
|
selectedValues: Set<number>
|
||||||
|
onFilterChange: (key: string, value: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{data.map((item) => {
|
||||||
|
const isSelected = selectedValues.has(item.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => onFilterChange(item.key, item.value)}
|
||||||
|
className={`w-[36px] h-[36px] rounded-lg border-2 p-1 transition-all hover:scale-110 ${
|
||||||
|
isSelected ? "border-primary shadow-md" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full rounded-md border-2 border-gray-200" style={{ backgroundColor: item.hex }} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
app/[locale]/category/[slug]/ui/CategoryContent.tsx
Normal file
9
app/[locale]/category/[slug]/ui/CategoryContent.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
export default function CategoryPageContent({ slug }: { slug: string }) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Category: {slug}</h1>
|
||||||
|
{/* Category content will go here */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
app/[locale]/favicon.ico
Normal file
BIN
app/[locale]/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
162
app/[locale]/favorites/page.tsx
Normal file
162
app/[locale]/favorites/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
import { useFavorites, useAddToCart, useRemoveFromFavorites } from "@/lib/hooks"
|
||||||
|
import { useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Heart, ShoppingCart } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import type { Product } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export default function FavoritesPage() {
|
||||||
|
const [isHovered, setIsHovered] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const { data: favorites, isLoading, isError } = useFavorites()
|
||||||
|
const { mutate: removeFromFavorites } = useRemoveFromFavorites()
|
||||||
|
const { mutate: addToCart } = useAddToCart()
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
favorites: "Избранные",
|
||||||
|
addToCart: "В корзину",
|
||||||
|
emptyFavorites: "У вас пока нет избранных товаров",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="w-full h-64 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !favorites || favorites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-2xl text-gray-400">{t.emptyFavorites}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{favorites.map((favorite) => (
|
||||||
|
<ProductCard
|
||||||
|
key={favorite.id}
|
||||||
|
productId={favorite.product_id}
|
||||||
|
product={favorite.product}
|
||||||
|
onRemove={() => removeFromFavorites(favorite.product_id)}
|
||||||
|
onAddToCart={() => addToCart({ productId: favorite.product_id })}
|
||||||
|
onHover={setIsHovered}
|
||||||
|
isHovered={isHovered === favorite.product_id}
|
||||||
|
translations={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
productId: number
|
||||||
|
product?: Product
|
||||||
|
onRemove: () => void
|
||||||
|
onAddToCart: () => void
|
||||||
|
onHover: (id: number | null) => void
|
||||||
|
isHovered: boolean
|
||||||
|
translations: { addToCart: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductCard({
|
||||||
|
productId,
|
||||||
|
product,
|
||||||
|
onRemove,
|
||||||
|
onAddToCart,
|
||||||
|
onHover,
|
||||||
|
isHovered,
|
||||||
|
translations,
|
||||||
|
}: ProductCardProps) {
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="group overflow-hidden rounded-xl transition-shadow hover:shadow-lg relative"
|
||||||
|
onMouseEnter={() => onHover(productId)}
|
||||||
|
onMouseLeave={() => onHover(null)}
|
||||||
|
>
|
||||||
|
<Link href={`/product/${product.slug || productId}`} className="block">
|
||||||
|
<div className="relative aspect-square bg-gray-50">
|
||||||
|
{/* Labels */}
|
||||||
|
{product.labels && product.labels.length > 0 && (
|
||||||
|
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1">
|
||||||
|
{product.labels.map((label) => (
|
||||||
|
<Badge
|
||||||
|
key={label.text}
|
||||||
|
className="text-white text-[10px] font-bold uppercase px-2 py-0.5"
|
||||||
|
style={{ backgroundColor: label.bg_color }}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Favorite Button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 z-10 bg-white rounded-full p-2 shadow-md hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={product.image || product.images?.[0] || "/placeholder.svg"}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
className="object-contain p-4 group-hover:scale-105 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-sm line-clamp-2 mb-2 min-h-[40px]">{product.name}</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-bold">{product.struct_price_text || `$${product.price}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Add to Cart Button */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-white to-transparent">
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onAddToCart()
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl gap-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{translations.addToCart}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
app/[locale]/globals.css
Normal file
122
app/[locale]/globals.css
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/[locale]/layout.tsx
Normal file
68
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type React from "react"
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { NextIntlClientProvider } from "next-intl"
|
||||||
|
import "./globals.css"
|
||||||
|
import Header from "@/components/layout/Header"
|
||||||
|
import MobileBottomNav from "@/components/layout/MobileBar"
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { Providers } from "@/context/provider"
|
||||||
|
import AuthWrapper from "@/context/AuthWrapper"
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Postshop",
|
||||||
|
description: "E-commerce platform",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const locales = ["ru", "tm"]
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({ locale }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children, params }: Props) {
|
||||||
|
const { locale } = await params
|
||||||
|
|
||||||
|
if (!locales.includes(locale)) notFound()
|
||||||
|
|
||||||
|
let messages
|
||||||
|
try {
|
||||||
|
messages = (await import(`../../messages/${locale}.json`)).default
|
||||||
|
} catch {
|
||||||
|
messages = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
<Providers>
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
{/* AuthWrapper handles guest token initialization */}
|
||||||
|
<AuthWrapper locale={locale}>
|
||||||
|
<Header locale={locale} />
|
||||||
|
{children}
|
||||||
|
<MobileBottomNav locale={locale} />
|
||||||
|
<Toaster />
|
||||||
|
</AuthWrapper>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
app/[locale]/me/client-page.tsx
Normal file
123
app/[locale]/me/client-page.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
import { LogOut } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { useUserProfile } from "@/lib/hooks"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
interface ProfilePageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientProfilePage(props: ProfilePageProps) {
|
||||||
|
const { data: user, isLoading, error } = useUserProfile()
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
profile: "Профиль",
|
||||||
|
firstName: "Имя",
|
||||||
|
lastName: "Фамилия",
|
||||||
|
phone: "Номер телефона",
|
||||||
|
email: "Email",
|
||||||
|
logout: "Выйти",
|
||||||
|
loading: "Загрузка...",
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
// Clear auth token
|
||||||
|
window.location.href = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-4 pt-20">
|
||||||
|
<div className="container mx-auto max-w-2xl">
|
||||||
|
<Skeleton className="h-10 w-48 mb-6" />
|
||||||
|
<Card className="shadow-lg mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<p className="text-red-600 mb-4">Failed to load profile</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-4 pt-20">
|
||||||
|
<div className="container mx-auto max-w-2xl">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{translations.profile}</h1>
|
||||||
|
|
||||||
|
<Card className="shadow-lg mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Личная информация</CardTitle>
|
||||||
|
<CardDescription>Ваши данные профиля</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
{/* First Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">{translations.firstName}</Label>
|
||||||
|
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">{translations.lastName}</Label>
|
||||||
|
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">{translations.phone}</Label>
|
||||||
|
<Input id="phone" value={user.phone || ""} disabled className="bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{translations.email}</Label>
|
||||||
|
<Input id="email" value={user.email || ""} disabled className="bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className="w-full max-w-md flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
{translations.logout}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/[locale]/me/page.tsx
Normal file
16
app/[locale]/me/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import ClientProfilePage from "./client-page"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "My Profile | E-Commerce",
|
||||||
|
description: "Manage your profile settings",
|
||||||
|
robots: "noindex, nofollow", // Private page
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}) {
|
||||||
|
return <ClientProfilePage params={params} />
|
||||||
|
}
|
||||||
66
app/[locale]/me/profile-content.tsx
Normal file
66
app/[locale]/me/profile-content.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
phone: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileContentProps {
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePageContent({ locale }: ProfileContentProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
profile: "Профиль",
|
||||||
|
firstName: "Имя",
|
||||||
|
lastName: "Фамилия",
|
||||||
|
phone: "Номер телефона",
|
||||||
|
email: "Email",
|
||||||
|
logout: "Выйти",
|
||||||
|
loading: "Загрузка...",
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserData = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setUser({
|
||||||
|
first_name: "Иван",
|
||||||
|
last_name: "Иванов",
|
||||||
|
phone: "+99361234567",
|
||||||
|
email: "ivan@example.com",
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
window.location.href = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<p className="text-lg text-gray-600">{t.loading}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-4 pt-20">
|
||||||
|
<div className="container mx-auto max-w-2xl">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{t.profile}</h1>
|
||||||
|
{/* Profile content */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
app/[locale]/openStore/page.tsx
Normal file
274
app/[locale]/openStore/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Upload } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { useOpenStore } from "@/lib/hooks"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
|
||||||
|
interface OpenStorePageProps {
|
||||||
|
locale?: string
|
||||||
|
translations?: {
|
||||||
|
title: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
uploadPatent: string
|
||||||
|
submit: string
|
||||||
|
selectedFile: string
|
||||||
|
firstNameRequired: string
|
||||||
|
lastNameRequired: string
|
||||||
|
emailInvalid: string
|
||||||
|
phoneInvalid: string
|
||||||
|
fileRequired: string
|
||||||
|
fileSizeError: string
|
||||||
|
fileTypeError: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
file: File | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
file?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) {
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "+993",
|
||||||
|
file: null,
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({})
|
||||||
|
const [fileName, setFileName] = useState("")
|
||||||
|
|
||||||
|
const { mutate: submitOpenStore, isPending: loading } = useOpenStore()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const t = translations || {
|
||||||
|
title: "Форма подачи заявления на открытие магазина",
|
||||||
|
firstName: "Имя",
|
||||||
|
lastName: "Фамилия",
|
||||||
|
email: "Email",
|
||||||
|
phone: "Телефон",
|
||||||
|
uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)",
|
||||||
|
submit: "Отправить",
|
||||||
|
selectedFile: "Выбранный файл",
|
||||||
|
firstNameRequired: "Имя обязательно",
|
||||||
|
lastNameRequired: "Фамилия обязательна",
|
||||||
|
emailInvalid: "Некорректный email",
|
||||||
|
phoneInvalid: "Некорректный номер телефона",
|
||||||
|
fileRequired: "Патент обязателен",
|
||||||
|
fileSizeError: "Файл слишком большой (макс. 25MB)",
|
||||||
|
fileTypeError: "Только PDF и JPG документы",
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {}
|
||||||
|
|
||||||
|
if (!formData.firstName.trim()) {
|
||||||
|
newErrors.firstName = t.firstNameRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.lastName.trim()) {
|
||||||
|
newErrors.lastName = t.lastNameRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
newErrors.email = t.emailInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneRegex = /^\+?[0-9]{6,15}$/
|
||||||
|
if (!phoneRegex.test(formData.phone)) {
|
||||||
|
newErrors.phone = t.phoneInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.file) {
|
||||||
|
newErrors.file = t.fileRequired
|
||||||
|
} else {
|
||||||
|
const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"]
|
||||||
|
if (!allowedTypes.includes(formData.file.type)) {
|
||||||
|
newErrors.file = t.fileTypeError
|
||||||
|
}
|
||||||
|
if (formData.file.size > 25 * 1024 * 1024) {
|
||||||
|
newErrors.file = t.fileSizeError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||||
|
if (errors[name as keyof FormErrors]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [name]: undefined }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setFormData((prev) => ({ ...prev, file }))
|
||||||
|
setFileName(file.name)
|
||||||
|
if (errors.file) {
|
||||||
|
setErrors((prev) => ({ ...prev, file: undefined }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
if (formData.file) {
|
||||||
|
submitOpenStore(
|
||||||
|
{
|
||||||
|
firstName: formData.firstName,
|
||||||
|
lastName: formData.lastName,
|
||||||
|
email: formData.email,
|
||||||
|
phone: formData.phone,
|
||||||
|
patentFile: formData.file,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Your store request has been submitted successfully",
|
||||||
|
})
|
||||||
|
setFormData({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "+993",
|
||||||
|
file: null,
|
||||||
|
})
|
||||||
|
setFileName("")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error?.message || "Failed to submit store request",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl text-center">{t.title}</CardTitle>
|
||||||
|
<CardDescription className="text-center">Заполните форму для подачи заявления</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* First Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">{t.firstName}</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={errors.firstName ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.firstName && <p className="text-sm text-red-500">{errors.firstName}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">{t.lastName}</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={errors.lastName ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.lastName && <p className="text-sm text-red-500">{errors.lastName}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t.email}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={errors.email ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">{t.phone}</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="+99361111111"
|
||||||
|
className={errors.phone ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.phone && <p className="text-sm text-red-500">{errors.phone}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="file">{t.uploadPatent}</Label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input id="file" type="file" accept=".pdf,.jpg,.jpeg" onChange={handleFileChange} className="hidden" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full bg-transparent"
|
||||||
|
onClick={() => document.getElementById("file")?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
{t.uploadPatent}
|
||||||
|
</Button>
|
||||||
|
{fileName && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{t.selectedFile}: {fileName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errors.file && <p className="text-sm text-red-500">{errors.file}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Загрузка..." : t.submit}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
app/[locale]/orders/orders-content.tsx
Normal file
46
app/[locale]/orders/orders-content.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
// ... existing types and code ...
|
||||||
|
|
||||||
|
interface OrdersContentProps {
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPageContent({ locale }: OrdersContentProps) {
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
|
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<"active" | "completed">("active")
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
orders: "Заказы",
|
||||||
|
active: "Активные",
|
||||||
|
completed: "Завершенные",
|
||||||
|
cancelOrder: "Отменить заказ",
|
||||||
|
areYouSure: "Вы уверены?",
|
||||||
|
yes: "Да",
|
||||||
|
no: "Нет",
|
||||||
|
orderNumber: "№",
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelOrder = (orderId: number) => {
|
||||||
|
setSelectedOrderId(orderId)
|
||||||
|
setIsDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCancelOrder = async () => {
|
||||||
|
if (selectedOrderId) {
|
||||||
|
console.log("Canceling order:", selectedOrderId)
|
||||||
|
setIsDeleteModalOpen(false)
|
||||||
|
setSelectedOrderId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 min-h-screen">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">{t.orders}</h1>
|
||||||
|
{/* Orders content */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
app/[locale]/orders/orders-page-client.tsx
Normal file
231
app/[locale]/orders/orders-page-client.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { useOrders, useCancelOrder } from "@/lib/hooks"
|
||||||
|
import type { Order } from "@/lib/types/api"
|
||||||
|
|
||||||
|
interface OrdersPageProps {
|
||||||
|
locale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPageClient({ locale }: OrdersPageProps) {
|
||||||
|
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
||||||
|
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null)
|
||||||
|
|
||||||
|
const { data: orders, isLoading, isError } = useOrders()
|
||||||
|
const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder()
|
||||||
|
|
||||||
|
const handleCancelOrder = (order: Order) => {
|
||||||
|
setOrderToCancel(order)
|
||||||
|
setIsCancelDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCancelOrder = () => {
|
||||||
|
if (orderToCancel) {
|
||||||
|
cancelOrder(orderToCancel.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsCancelDialogOpen(false)
|
||||||
|
setOrderToCancel(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Order["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
case "processing":
|
||||||
|
return <Badge variant="secondary">{status}</Badge>
|
||||||
|
case "shipped":
|
||||||
|
return <Badge variant="default">{status}</Badge>
|
||||||
|
case "delivered":
|
||||||
|
return <Badge className="bg-green-600">{status}</Badge>
|
||||||
|
case "cancelled":
|
||||||
|
return <Badge variant="destructive">{status}</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge>{status}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeOrders = orders?.filter((o) => ["pending", "processing", "shipped"].includes(o.status)) || []
|
||||||
|
const completedOrders = orders?.filter((o) => ["delivered", "cancelled"].includes(o.status)) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">My Orders</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-64 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||||
|
<p className="text-red-600">Failed to load orders. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
) : !orders || orders.length === 0 ? (
|
||||||
|
<p className="text-gray-500">You have no orders yet.</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="active" className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="active">Active Orders ({activeOrders.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">Completed Orders ({completedOrders.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="active">
|
||||||
|
{activeOrders.length === 0 ? (
|
||||||
|
<p className="text-gray-500">You have no active orders.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{activeOrders.map((order) => (
|
||||||
|
<Card key={order.id} className="p-4 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Order #{order.id}</h3>
|
||||||
|
{getStatusBadge(order.status)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Ordered: {new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
{order.estimated_delivery && (
|
||||||
|
<p className="text-sm text-gray-600">Est. Delivery: {order.estimated_delivery}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{order.items?.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-3">
|
||||||
|
{item.product?.image && (
|
||||||
|
<Image
|
||||||
|
src={item.product.image || "/placeholder.svg"}
|
||||||
|
alt={item.product.name}
|
||||||
|
width={50}
|
||||||
|
height={50}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{item.product?.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">Qty: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex justify-between font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{order.total_formatted || `$${order.total}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleCancelOrder(order)}
|
||||||
|
disabled={isCancellingOrder || order.status === "shipped" || order.status === "delivered"}
|
||||||
|
>
|
||||||
|
Cancel Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completed">
|
||||||
|
{completedOrders.length === 0 ? (
|
||||||
|
<p className="text-gray-500">You have no completed orders.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{completedOrders.map((order) => (
|
||||||
|
<Card key={order.id} className="p-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Order #{order.id}</h3>
|
||||||
|
{getStatusBadge(order.status)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Ordered: {new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
{order.updated_at && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Completed: {new Date(order.updated_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{order.items?.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-3">
|
||||||
|
{item.product?.image && (
|
||||||
|
<Image
|
||||||
|
src={item.product.image || "/placeholder.svg"}
|
||||||
|
alt={item.product.name}
|
||||||
|
width={50}
|
||||||
|
height={50}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{item.product?.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">Qty: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex justify-between font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{order.total_formatted || `$${order.total}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cancel Order #{orderToCancel?.id}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to cancel this order? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCancelDialogOpen(false)}>
|
||||||
|
Keep Order
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirmCancelOrder} disabled={isCancellingOrder}>
|
||||||
|
{isCancellingOrder ? "Cancelling..." : "Cancel Order"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
app/[locale]/orders/page.tsx
Normal file
20
app/[locale]/orders/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import OrdersPageClient from "./orders-page-client"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "My Orders | E-Commerce",
|
||||||
|
description: "View your order history",
|
||||||
|
robots: "noindex, nofollow", // Private page
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
locale: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrdersPage({ params }: PageProps) {
|
||||||
|
const resolvedParams = await params
|
||||||
|
|
||||||
|
return <OrdersPageClient locale={resolvedParams.locale} />
|
||||||
|
}
|
||||||
35
app/[locale]/page.tsx
Normal file
35
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import HomePage from "@/components/home/HomePage"
|
||||||
|
|
||||||
|
export const revalidate = 300
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
ru: {
|
||||||
|
title: "Интернет магазин - Лучшие товары по низким ценам",
|
||||||
|
description: "Качественные товары с быстрой доставкой по всей стране"
|
||||||
|
},
|
||||||
|
tm: {
|
||||||
|
title: "Satym dükanı - Iň gowy harytlar aşak bahada",
|
||||||
|
description: "Suw harytly towarnama. Elektrika, eşik, ev we bag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description } = meta[locale as keyof typeof meta] || meta.ru
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { type: "website", locale, title, description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HomePage />
|
||||||
|
}
|
||||||
292
app/[locale]/product/[slug]/ProductPageContent.tsx
Normal file
292
app/[locale]/product/[slug]/ProductPageContent.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Minus, Plus, Heart, ShoppingCart, Store } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import placeholder from "@/public/jb.webp"
|
||||||
|
import { useProduct, useCategories } from "@/lib/hooks"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
interface ProductDetailProps {
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductPageContent = ({ slug }: ProductDetailProps) => {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [selectedImage, setSelectedImage] = useState(0)
|
||||||
|
const [quantity, setQuantity] = useState(1)
|
||||||
|
const [isFavorite, setIsFavorite] = useState(false)
|
||||||
|
const [isInCart, setIsInCart] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const { data: product, isLoading: productLoading, error } = useProduct(slug)
|
||||||
|
const { data: categoriesData } = useCategories()
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
typeof window !== "undefined" && setIsClient(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
addToCart: "Add to Cart",
|
||||||
|
goToCart: "Go to Cart",
|
||||||
|
price: "Price:",
|
||||||
|
aboutProduct: "About Product",
|
||||||
|
brand: "Brand",
|
||||||
|
model: "Model",
|
||||||
|
description: "Product Description",
|
||||||
|
recommended: "Recommended Products",
|
||||||
|
store: "Store",
|
||||||
|
writeToStore: "Write to Store",
|
||||||
|
color: "Color:",
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCart = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
// TODO: implement cart API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
setIsInCart(true)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuantityChange = async (newQuantity: number) => {
|
||||||
|
if (newQuantity < 1) return
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
setQuantity(newQuantity)
|
||||||
|
// TODO: implement cart quantity update API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
setIsFavorite(!isFavorite)
|
||||||
|
// TODO: implement favorites API call
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 max-w-2xl">
|
||||||
|
<Skeleton className="aspect-square w-full rounded-2xl" />
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="w-16 h-16 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !product) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-red-600">Product not found</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
{/* Product Images */}
|
||||||
|
<div className="flex-1 max-w-2xl">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-gray-50">
|
||||||
|
{product.labels && product.labels.length > 0 && (
|
||||||
|
<div className="absolute top-0 right-0 z-10 flex flex-col gap-1">
|
||||||
|
{product.labels.map((label) => (
|
||||||
|
<Badge
|
||||||
|
key={label.text}
|
||||||
|
className="rounded-l-md rounded-r-none text-white text-xs font-bold uppercase"
|
||||||
|
style={{ backgroundColor: label.bg_color }}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Image
|
||||||
|
src={product.images?.[selectedImage] || product.image || placeholder}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Images */}
|
||||||
|
{product.images && product.images.length > 1 && (
|
||||||
|
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{product.images.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedImage(index)}
|
||||||
|
className={`relative w-16 h-16 rounded overflow-hidden border ${
|
||||||
|
selectedImage === index ? "border-black" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={image || "/placeholder.svg"}
|
||||||
|
alt={`${product.name} thumbnail ${index + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
|
||||||
|
{product.category && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-gray-500">Category: {product.category}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info Table */}
|
||||||
|
<Card className="p-4 rounded-xl">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">{t.aboutProduct}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{product.brand && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<span className="text-gray-500">{t.brand}</span>
|
||||||
|
<span className="font-medium">{product.brand}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{product.stock !== undefined && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<span className="text-gray-500">Stock</span>
|
||||||
|
<span className="font-medium">{product.stock}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{product.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-3">{t.description}</h3>
|
||||||
|
<p className="text-gray-700 leading-relaxed">{product.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price & Actions Sidebar */}
|
||||||
|
<div className="lg:w-[420px] space-y-4">
|
||||||
|
<Card className="p-6 rounded-xl shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<span className="text-lg text-gray-500">{t.price}</span>
|
||||||
|
<span className="text-3xl font-bold">${product.price}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isInCart ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link href="/cart">
|
||||||
|
<Button size="lg" className="w-full rounded-xl text-lg font-bold bg-green-600 hover:bg-green-700">
|
||||||
|
<ShoppingCart className="mr-2 h-5 w-5" />
|
||||||
|
{t.goToCart}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleQuantityChange(quantity - 1)}
|
||||||
|
disabled={quantity === 1 || isLoading}
|
||||||
|
className="rounded-xl bg-blue-50 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Minus className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 text-center font-semibold text-lg">{quantity}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleQuantityChange(quantity + 1)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-xl bg-blue-50 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-xl text-lg font-bold"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="mr-2 h-5 w-5" />
|
||||||
|
{t.addToCart}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
className={`w-full rounded-xl ${
|
||||||
|
isFavorite ? "bg-red-50 border-red-200 hover:bg-red-100" : "bg-blue-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-6 w-6 ${isFavorite ? "fill-red-500 text-red-500" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Seller Card */}
|
||||||
|
<Card className="p-6 rounded-xl">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<Avatar className="w-14 h-14">
|
||||||
|
<AvatarFallback>
|
||||||
|
<Store className="h-6 w-6" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{t.store}</p>
|
||||||
|
<h4 className="text-xl font-bold hover:text-primary cursor-pointer">Official Store</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="lg" disabled className="w-full rounded-xl bg-transparent">
|
||||||
|
{t.writeToStore}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductPageContent
|
||||||
39
app/[locale]/product/[slug]/page.tsx
Normal file
39
app/[locale]/product/[slug]/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import ProductPageContent from "./ProductPageContent"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 3600 // ISR: Revalidate every hour
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Product ${slug} | E-Commerce`,
|
||||||
|
description: `View details for product ${slug}`,
|
||||||
|
openGraph: {
|
||||||
|
locale,
|
||||||
|
type: "website",
|
||||||
|
title: `Product ${slug} | E-Commerce`,
|
||||||
|
description: `View details for product ${slug}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
// Generate static params for popular products
|
||||||
|
return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage(props: Props) {
|
||||||
|
const params = await props.params
|
||||||
|
|
||||||
|
if (!params.slug) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProductPageContent slug={params.slug} />
|
||||||
|
}
|
||||||
9
app/[locale]/product/[slug]/product-content.tsx
Normal file
9
app/[locale]/product/[slug]/product-content.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
export default function ProductPageContent({ slug }: { slug: string }) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold">Product: {slug}</h1>
|
||||||
|
{/* Product content will go here */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
167
components/ProductCard.tsx
Normal file
167
components/ProductCard.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, MouseEvent } from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Heart, HeartOff, Minus, Plus } from "lucide-react";
|
||||||
|
import Image, { StaticImageData } from "next/image";
|
||||||
|
type ProductCardProps = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number | null;
|
||||||
|
struct_price_text: string;
|
||||||
|
discount?: number | null;
|
||||||
|
discount_text?: string | null;
|
||||||
|
images: (StaticImageData | string)[];
|
||||||
|
is_favorite: boolean;
|
||||||
|
labels?: { text: string; bg_color: string }[];
|
||||||
|
price_color?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
button?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductCard({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
struct_price_text,
|
||||||
|
discount,
|
||||||
|
discount_text,
|
||||||
|
images,
|
||||||
|
is_favorite,
|
||||||
|
labels = [],
|
||||||
|
price_color = "#005bff",
|
||||||
|
height = 360,
|
||||||
|
width = 280,
|
||||||
|
button = true,
|
||||||
|
}: ProductCardProps) {
|
||||||
|
const [favorite, setFavorite] = useState(is_favorite);
|
||||||
|
const [cart, setCart] = useState(false);
|
||||||
|
const [count, setCount] = useState(1);
|
||||||
|
|
||||||
|
const handleFavorite = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setFavorite((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCart = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCart(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncrement = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecrement = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCount((c) => (c > 1 ? c - 1 : c));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/product/${id}`} className="no-underline">
|
||||||
|
<Card
|
||||||
|
className={`relative gap-2 border-none shadow-none! p-0 w-full max-w-[${width}px] overflow-hidden rounded-2xl hover:shadow-md transition-all cursor-pointer`}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{/* Image Section */}
|
||||||
|
<div className="relative w-full h-[260px] ">
|
||||||
|
{images?.[0] && (
|
||||||
|
<Image
|
||||||
|
src={images[0]}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 600px) 100vw, 33vw"
|
||||||
|
className="object-contain "
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Favorite Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleFavorite}
|
||||||
|
className="absolute top-3 right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white"
|
||||||
|
>
|
||||||
|
{favorite ? (
|
||||||
|
<Heart className="w-5 h-5 text-red-500 fill-red-500" />
|
||||||
|
) : (
|
||||||
|
<Heart className="w-5 h-5 text-gray-700" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{labels?.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 flex flex-col gap-1">
|
||||||
|
{labels.map((label) => (
|
||||||
|
<Badge
|
||||||
|
key={label.text}
|
||||||
|
className="text-white text-[10px] font-bold uppercase rounded-r-md"
|
||||||
|
style={{ backgroundColor: label.bg_color }}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<CardContent className="p-0 space-y-1">
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold mx-2"
|
||||||
|
style={{ color: price_color }}
|
||||||
|
>
|
||||||
|
{struct_price_text}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-800 text-sm truncate mx-2">{name}</p>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
{/* {button && (
|
||||||
|
<div className="p-3">
|
||||||
|
{!cart ? (
|
||||||
|
<Button
|
||||||
|
className="w-full font-bold text-base rounded-xl"
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
>
|
||||||
|
Заказать
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={count === 1}
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 text-center text-gray-700 border rounded-xl py-2">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleIncrement}
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/empty-states/EmptyCart.tsx
Normal file
32
components/empty-states/EmptyCart.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ShoppingCart } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface EmptyCartProps {
|
||||||
|
locale?: string
|
||||||
|
message?: string
|
||||||
|
actionText?: string
|
||||||
|
actionHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyCart({
|
||||||
|
locale = "ru",
|
||||||
|
message = "Your cart is empty",
|
||||||
|
actionText = "Start Shopping",
|
||||||
|
actionHref = "/",
|
||||||
|
}: EmptyCartProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
|
||||||
|
<ShoppingCart className="h-16 w-16 text-gray-300 mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
|
||||||
|
<p className="text-gray-500 mb-6 text-center max-w-sm">
|
||||||
|
{locale === "ru"
|
||||||
|
? "Добавьте товары в корзину, чтобы начать покупки"
|
||||||
|
: "Add items to your cart to start shopping"}
|
||||||
|
</p>
|
||||||
|
<Link href={actionHref}>
|
||||||
|
<Button className="rounded-xl">{actionText}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
components/empty-states/EmptyFavorites.tsx
Normal file
32
components/empty-states/EmptyFavorites.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Heart } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface EmptyFavoritesProps {
|
||||||
|
locale?: string
|
||||||
|
message?: string
|
||||||
|
actionText?: string
|
||||||
|
actionHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyFavorites({
|
||||||
|
locale = "ru",
|
||||||
|
message = "No favorite items yet",
|
||||||
|
actionText = "Browse Products",
|
||||||
|
actionHref = "/",
|
||||||
|
}: EmptyFavoritesProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
|
||||||
|
<Heart className="h-16 w-16 text-gray-300 mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
|
||||||
|
<p className="text-gray-500 mb-6 text-center max-w-sm">
|
||||||
|
{locale === "ru"
|
||||||
|
? "Сохраняйте понравившиеся товары, чтобы найти их позже"
|
||||||
|
: "Save items you love to find them later"}
|
||||||
|
</p>
|
||||||
|
<Link href={actionHref}>
|
||||||
|
<Button className="rounded-xl">{actionText}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
components/empty-states/EmptyOrders.tsx
Normal file
32
components/empty-states/EmptyOrders.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Package } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface EmptyOrdersProps {
|
||||||
|
locale?: string
|
||||||
|
message?: string
|
||||||
|
actionText?: string
|
||||||
|
actionHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyOrders({
|
||||||
|
locale = "ru",
|
||||||
|
message = "No orders yet",
|
||||||
|
actionText = "Start Shopping",
|
||||||
|
actionHref = "/",
|
||||||
|
}: EmptyOrdersProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
|
||||||
|
<Package className="h-16 w-16 text-gray-300 mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
|
||||||
|
<p className="text-gray-500 mb-6 text-center max-w-sm">
|
||||||
|
{locale === "ru"
|
||||||
|
? "У вас еще нет заказов. Начните покупки прямо сейчас!"
|
||||||
|
: "You haven't placed any orders yet. Start shopping now!"}
|
||||||
|
</p>
|
||||||
|
<Link href={actionHref}>
|
||||||
|
<Button className="rounded-xl">{actionText}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
components/empty-states/EmptySearch.tsx
Normal file
34
components/empty-states/EmptySearch.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Search } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface EmptySearchProps {
|
||||||
|
locale?: string
|
||||||
|
query?: string
|
||||||
|
message?: string
|
||||||
|
actionText?: string
|
||||||
|
actionHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptySearch({
|
||||||
|
locale = "ru",
|
||||||
|
query = "",
|
||||||
|
message = "No results found",
|
||||||
|
actionText = "Back to Home",
|
||||||
|
actionHref = "/",
|
||||||
|
}: EmptySearchProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
|
||||||
|
<Search className="h-16 w-16 text-gray-300 mb-4" />
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
|
||||||
|
{query && (
|
||||||
|
<p className="text-gray-500 mb-6 text-center max-w-sm">
|
||||||
|
{locale === "ru" ? `No products found for "${query}"` : `No products found for "${query}"`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link href={actionHref}>
|
||||||
|
<Button className="rounded-xl">{actionText}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
components/home/Carousel.tsx
Normal file
38
components/home/Carousel.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
import Image, { type StaticImageData } from "next/image"
|
||||||
|
import { Swiper, SwiperSlide } from "swiper/react"
|
||||||
|
import { Autoplay } from "swiper/modules"
|
||||||
|
import "swiper/css"
|
||||||
|
|
||||||
|
type CarouselItem = {
|
||||||
|
title: string
|
||||||
|
image: StaticImageData | string
|
||||||
|
url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroCarousel({ items }: { items: CarouselItem[] }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl overflow-hidden">
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay]}
|
||||||
|
slidesPerView={1}
|
||||||
|
loop
|
||||||
|
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<SwiperSlide key={i}>
|
||||||
|
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[420px]">
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority={i === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
components/home/CategoryGrid.tsx
Normal file
61
components/home/CategoryGrid.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import type { Category } from "@/lib/types/api"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
categories: Category[] | undefined
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
locale: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryGrid({ categories, isLoading, isError, locale, title }: Props) {
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
||||||
|
<p className="text-red-600">Failed to load categories. Please try again.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<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">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="w-full h-36 rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-4 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<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">
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<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">
|
||||||
|
<div className="relative w-full h-36 overflow-hidden rounded-lg">
|
||||||
|
<Image src={cat.image || "/placeholder.svg"} alt={cat.name} fill className="object-contain" />
|
||||||
|
</div>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<p className="text-sm font-medium text-gray-800 truncate text-center">{cat.name}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
components/home/HomePage.tsx
Normal file
182
components/home/HomePage.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
components/home/ProductGrid.tsx
Normal file
112
components/home/ProductGrid.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ChevronRight } from "lucide-react"
|
||||||
|
import ProductCard from "@/components/ProductCard"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { useCollectionProducts } from "@/lib/hooks"
|
||||||
|
import type { Collection } from "@/lib/types/api"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collection: Collection
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionSection({ collection, locale }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [shouldRender, setShouldRender] = useState(true)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: productsData,
|
||||||
|
isLoading,
|
||||||
|
isError
|
||||||
|
} = useCollectionProducts(collection.id, { enabled: shouldRender })
|
||||||
|
|
||||||
|
// Determine if section should render based on products
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && productsData) {
|
||||||
|
const hasProducts = productsData.data && productsData.data.length > 0
|
||||||
|
setShouldRender(hasProducts)
|
||||||
|
}
|
||||||
|
}, [isLoading, productsData])
|
||||||
|
|
||||||
|
// Don't render if no products after loading
|
||||||
|
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTitleClick = () => {
|
||||||
|
router.push(`/${locale}/collections/${collection.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show skeleton while loading
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-6 w-6 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="w-full h-64 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
if (isError) {
|
||||||
|
return null // Silently skip errored collections
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice to show only first 4 products
|
||||||
|
const displayProducts = productsData?.data.slice(0, 4) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between mb-4 cursor-pointer group"
|
||||||
|
onClick={handleTitleClick}
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
|
||||||
|
{collection.name}
|
||||||
|
</h2>
|
||||||
|
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{displayProducts.map((product) => {
|
||||||
|
// Extract first media image or use placeholder
|
||||||
|
const firstImage = product.media?.[0]?.images_800x800 ||
|
||||||
|
product.media?.[0]?.images_720x720 ||
|
||||||
|
product.media?.[0]?.thumbnail ||
|
||||||
|
"/placeholder-product.jpg"
|
||||||
|
|
||||||
|
// Format price
|
||||||
|
const formattedPrice = product.price_amount
|
||||||
|
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
|
||||||
|
: "Price not available"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
id={product.id}
|
||||||
|
name={product.name}
|
||||||
|
price={product.price_amount ? parseFloat(product.price_amount) : null}
|
||||||
|
struct_price_text={formattedPrice}
|
||||||
|
images={[firstImage]}
|
||||||
|
is_favorite={false}
|
||||||
|
labels={[]}
|
||||||
|
price_color="#111"
|
||||||
|
height={360}
|
||||||
|
width={250}
|
||||||
|
button={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
components/home/mockData.tsx
Normal file
56
components/home/mockData.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
153
components/layout/Header.tsx
Normal file
153
components/layout/Header.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { X, Menu, Search, Store } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Logo from "@/public/logo.png"
|
||||||
|
import CategoryMenu from "./ui/CategoryMenu"
|
||||||
|
import SearchBar from "./ui/SearchBar"
|
||||||
|
import AuthDialog from "./ui/AuthDialog"
|
||||||
|
import ActionButtons from "./ui/ActionButtons"
|
||||||
|
import LanguageSelector from "./ui/LanguageSelector"
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
locale?: string
|
||||||
|
isAuthenticated?: boolean
|
||||||
|
translations?: {
|
||||||
|
catalog: string
|
||||||
|
search: string
|
||||||
|
orders: string
|
||||||
|
favorites: string
|
||||||
|
cart: string
|
||||||
|
login: string
|
||||||
|
profile: string
|
||||||
|
openStore: string
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
send: string
|
||||||
|
enterPhone: string
|
||||||
|
weWillSendCode: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TRANSLATIONS = {
|
||||||
|
catalog: "Каталог",
|
||||||
|
search: "Поиск продукта",
|
||||||
|
orders: "Заказы",
|
||||||
|
favorites: "Избранное",
|
||||||
|
cart: "Корзина",
|
||||||
|
login: "Войти",
|
||||||
|
profile: "Профиль",
|
||||||
|
openStore: "Открыть магазин",
|
||||||
|
phone: "Номер телефона",
|
||||||
|
code: "Код",
|
||||||
|
send: "Отправить",
|
||||||
|
enterPhone: "Введите свой номер телефона",
|
||||||
|
weWillSendCode: "Мы вышлем вам код",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ locale = "ru", isAuthenticated = false, translations }: HeaderProps) {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
|
||||||
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false)
|
||||||
|
const [isLoginOpen, setIsLoginOpen] = useState(false)
|
||||||
|
|
||||||
|
const t = translations || DEFAULT_TRANSLATIONS
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAuthClick = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
window.location.href = `/${locale}/me`
|
||||||
|
} else {
|
||||||
|
setIsLoginOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen)
|
||||||
|
const closeCategoryMenu = () => setIsCategoryOpen(false)
|
||||||
|
|
||||||
|
if (!isClient) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex h-16 items-center justify-between gap-4">
|
||||||
|
<Link href="/" className="shrink-0">
|
||||||
|
<div className="relative h-8 w-[180px]">
|
||||||
|
<Image src={Logo || "/placeholder.svg"} alt="Logo" fill className="object-contain" priority />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={toggleCategoryMenu}
|
||||||
|
className="hidden gap-2 rounded-xl font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isCategoryOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
{t.catalog}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:hidden">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setIsMobileSearchOpen(true)}>
|
||||||
|
<Search className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBar isMobile={false} searchPlaceholder={t.search} className="hidden flex-1 md:flex" />
|
||||||
|
|
||||||
|
<ActionButtons
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
onAuthClick={handleAuthClick}
|
||||||
|
translations={{
|
||||||
|
profile: t.profile,
|
||||||
|
login: t.login,
|
||||||
|
orders: t.orders,
|
||||||
|
favorites: t.favorites,
|
||||||
|
cart: t.cart,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/openStore">
|
||||||
|
<Button variant="ghost" size="sm" className="relative flex gap-0.5 h-auto pb-2">
|
||||||
|
<Store className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-xs text-gray-700">{t.openStore}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CategoryMenu isOpen={isCategoryOpen} onClose={closeCategoryMenu} />
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
isMobile={true}
|
||||||
|
isOpen={isMobileSearchOpen}
|
||||||
|
onClose={() => setIsMobileSearchOpen(false)}
|
||||||
|
searchPlaceholder={t.search}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthDialog
|
||||||
|
isOpen={isLoginOpen}
|
||||||
|
onClose={() => setIsLoginOpen(false)}
|
||||||
|
translations={{
|
||||||
|
enterPhone: t.enterPhone,
|
||||||
|
weWillSendCode: t.weWillSendCode,
|
||||||
|
phone: t.phone,
|
||||||
|
code: t.code,
|
||||||
|
send: t.send,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
components/layout/MobileBar.tsx
Normal file
177
components/layout/MobileBar.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Menu, Heart, Truck, ShoppingCart, User } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { useCategories, useCart, useFavorites, useOrders } from "@/lib/hooks"
|
||||||
|
|
||||||
|
interface MobileBottomNavProps {
|
||||||
|
locale?: string
|
||||||
|
isAuthenticated?: boolean
|
||||||
|
translations?: {
|
||||||
|
catalog: string
|
||||||
|
favorites: string
|
||||||
|
orders: string
|
||||||
|
cart: string
|
||||||
|
login: string
|
||||||
|
profile: string
|
||||||
|
}
|
||||||
|
onLoginClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileBottomNav({
|
||||||
|
locale = "ru",
|
||||||
|
isAuthenticated = false,
|
||||||
|
translations,
|
||||||
|
onLoginClick,
|
||||||
|
}: MobileBottomNavProps) {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data: categories = [] } = useCategories()
|
||||||
|
const { data: cartData } = useCart()
|
||||||
|
const { data: favoritesData } = useFavorites()
|
||||||
|
const { data: ordersData } = useOrders()
|
||||||
|
|
||||||
|
const t = translations || {
|
||||||
|
catalog: "Каталог",
|
||||||
|
favorites: "Избранное",
|
||||||
|
orders: "Заказы",
|
||||||
|
cart: "Корзина",
|
||||||
|
login: "Войти",
|
||||||
|
profile: "Профиль",
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAuthClick = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
window.location.href = `/${locale}/me`
|
||||||
|
} else if (onLoginClick) {
|
||||||
|
onLoginClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClient) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Bottom Navigation */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t shadow-lg md:hidden">
|
||||||
|
<div className="flex items-center justify-around h-16 px-2">
|
||||||
|
{/* Catalog Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-col gap-0.5 h-auto px-2 py-2"
|
||||||
|
onClick={() => setIsCategoryOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-xs text-gray-700">{t.catalog}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Favorites Button */}
|
||||||
|
<Link href="/favorites">
|
||||||
|
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Heart className="h-5 w-5 text-gray-600" />
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{favoritesData?.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-700">{t.favorites}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Orders Button */}
|
||||||
|
<Link href="/orders">
|
||||||
|
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Truck className="h-5 w-5 text-gray-600" />
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{ordersData?.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-700">{t.orders}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Cart Button */}
|
||||||
|
<Link href="/cart">
|
||||||
|
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<ShoppingCart className="h-5 w-5 text-gray-600" />
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{cartData?.count || 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-700">{t.cart}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Profile/Login Button */}
|
||||||
|
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={handleAuthClick}>
|
||||||
|
<User className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-xs text-gray-700">{isAuthenticated ? t.profile : t.login}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Sheet/Drawer */}
|
||||||
|
<Sheet open={isCategoryOpen} onOpenChange={setIsCategoryOpen}>
|
||||||
|
<SheetContent side="left" className="w-[300px] p-0">
|
||||||
|
<SheetHeader className="p-4 border-b">
|
||||||
|
<SheetTitle>{t.catalog}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100vh-80px)]">
|
||||||
|
<div className="p-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div key={category.id} className="mb-4">
|
||||||
|
<Link
|
||||||
|
href={`/category/${category.slug}?category_id=${category.id}`}
|
||||||
|
onClick={() => setIsCategoryOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Subcategories */}
|
||||||
|
{category.children && category.children.length > 0 && (
|
||||||
|
<div className="ml-8 mt-2 space-y-1">
|
||||||
|
{category.children.map((child: any) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
href={`/category/${child.slug}?category_id=${child.id}`}
|
||||||
|
onClick={() => setIsCategoryOpen(false)}
|
||||||
|
className="block px-3 py-2 text-sm text-gray-600 hover:text-primary hover:bg-gray-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
components/layout/ui/ActionButtons.tsx
Normal file
98
components/layout/ui/ActionButtons.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { User, Truck, Heart, ShoppingCart } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useCart, useFavorites, useOrders } from "@/lib/hooks"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
interface ActionButtonsProps {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
onAuthClick: () => void
|
||||||
|
translations: {
|
||||||
|
profile: string
|
||||||
|
login: string
|
||||||
|
orders: string
|
||||||
|
favorites: string
|
||||||
|
cart: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionButtonData {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
onClick?: () => void
|
||||||
|
badgeCount?: number
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionButtons({ isAuthenticated, onAuthClick, translations: t }: ActionButtonsProps) {
|
||||||
|
const { data: cartData, isLoading: cartLoading } = useCart()
|
||||||
|
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites()
|
||||||
|
const { data: ordersData, isLoading: ordersLoading } = useOrders()
|
||||||
|
|
||||||
|
const buttons: ActionButtonData[] = [
|
||||||
|
{
|
||||||
|
icon: <User className="h-5 w-5 text-gray-600" />,
|
||||||
|
label: isAuthenticated ? t.profile : t.login,
|
||||||
|
onClick: onAuthClick,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Truck className="h-5 w-5 text-gray-600" />,
|
||||||
|
label: t.orders,
|
||||||
|
href: "/orders",
|
||||||
|
badgeCount: ordersData?.length || 0,
|
||||||
|
isLoading: ordersLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Heart className="h-5 w-5 text-gray-600" />,
|
||||||
|
label: t.favorites,
|
||||||
|
href: "/favorites",
|
||||||
|
badgeCount: favoritesData?.length || 0,
|
||||||
|
isLoading: favoritesLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ShoppingCart className="h-5 w-5 text-gray-600" />,
|
||||||
|
label: t.cart,
|
||||||
|
href: "/cart",
|
||||||
|
badgeCount: cartData?.count || 0,
|
||||||
|
isLoading: cartLoading,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden items-center gap-1 md:flex">
|
||||||
|
{buttons.map((button, index) => (
|
||||||
|
<ActionButton key={index} {...button} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: ActionButtonData) {
|
||||||
|
const buttonContent = (
|
||||||
|
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2" onClick={onClick}>
|
||||||
|
<div className="relative">
|
||||||
|
{icon}
|
||||||
|
{badgeCount !== undefined && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{isLoading ? <Skeleton className="h-3 w-3 rounded-full" /> : badgeCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-700">{label}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return <Link href={href}>{buttonContent}</Link>
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonContent
|
||||||
|
}
|
||||||
109
components/layout/ui/AuthDialog.tsx
Normal file
109
components/layout/ui/AuthDialog.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import Logo from "@/public/logo.png";
|
||||||
|
|
||||||
|
interface AuthDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
translations: {
|
||||||
|
enterPhone: string;
|
||||||
|
weWillSendCode: string;
|
||||||
|
phone: string;
|
||||||
|
code: string;
|
||||||
|
send: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
translations: t,
|
||||||
|
}: AuthDialogProps) {
|
||||||
|
const [phone, setPhone] = useState("993");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [otpSent, setOtpSent] = useState(false);
|
||||||
|
|
||||||
|
const handleSendOtp = () => {
|
||||||
|
if (phone.length > 3) {
|
||||||
|
setOtpSent(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
// Here you can add authentication logic
|
||||||
|
resetDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDialog = () => {
|
||||||
|
onClose();
|
||||||
|
setOtpSent(false);
|
||||||
|
setPhone("993");
|
||||||
|
setOtp("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent, action: () => void) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={resetDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<div className="relative h-8 w-[180px]">
|
||||||
|
<Image src={Logo} alt="Logo" fill className="object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-2xl text-center">
|
||||||
|
{t.enterPhone}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-center text-sm text-gray-600">
|
||||||
|
{t.weWillSendCode}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
placeholder={t.phone}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="h-12 rounded-xl"
|
||||||
|
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
|
||||||
|
disabled={otpSent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{otpSent && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t.code}
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
className="h-12 rounded-xl"
|
||||||
|
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={otpSent ? handleLogin : handleSendOtp}
|
||||||
|
className="w-full h-12 rounded-xl font-bold text-base"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{t.send}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
components/layout/ui/CategoryMenu.tsx
Normal file
101
components/layout/ui/CategoryMenu.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useCategories } from "@/lib/hooks"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
icon_class?: string
|
||||||
|
children?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryMenuProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) {
|
||||||
|
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null)
|
||||||
|
const { data: categories, isLoading } = useCategories()
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const categoryList = categories || []
|
||||||
|
const activeCategory = hoveredCategory !== null ? categoryList[hoveredCategory] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 right-0 top-22 z-40 bg-white border-b shadow-lg">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex">
|
||||||
|
<CategoryList
|
||||||
|
categories={categoryList}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCategoryHover={setHoveredCategory}
|
||||||
|
onCategoryClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeCategory?.children && <SubcategoryList category={activeCategory} onSubcategoryClick={onClose} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryListProps {
|
||||||
|
categories: any[]
|
||||||
|
isLoading: boolean
|
||||||
|
onCategoryHover: (index: number) => void
|
||||||
|
onCategoryClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryList({ categories, isLoading, onCategoryHover, onCategoryClick }: CategoryListProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-[280px] border-r">
|
||||||
|
<div className="max-h-[calc(100vh-4rem)] overflow-y-auto py-2">
|
||||||
|
{isLoading
|
||||||
|
? [1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-10 mx-4 my-2 rounded" />)
|
||||||
|
: categories.map((category, index) => (
|
||||||
|
<Link
|
||||||
|
key={category.id}
|
||||||
|
href={`/category/${category.slug}?category_id=${category.id}`}
|
||||||
|
onClick={onCategoryClick}
|
||||||
|
onMouseEnter={() => onCategoryHover(index)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-gray-100 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubcategoryListProps {
|
||||||
|
category: any
|
||||||
|
onSubcategoryClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">{category.name}</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{category.children?.map((subCategory: any) => (
|
||||||
|
<Link
|
||||||
|
key={subCategory.id}
|
||||||
|
href={`/category/${subCategory.slug}?category_id=${subCategory.id}`}
|
||||||
|
onClick={onSubcategoryClick}
|
||||||
|
className="text-gray-600 hover:text-black text-sm py-1 hover:underline"
|
||||||
|
>
|
||||||
|
{subCategory.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
components/layout/ui/LanguageSelector.tsx
Normal file
59
components/layout/ui/LanguageSelector.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useLocale } from "next-intl"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import tm from "@/public/tm.png"
|
||||||
|
import ru from "@/public/ru.png"
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
flag: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGES: Language[] = [
|
||||||
|
{ code: "ru", name: "Russian", flag: ru },
|
||||||
|
{ code: "tm", name: "Turkmen", flag: tm },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LanguageSelector() {
|
||||||
|
const locale = useLocale()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLanguageChange = (newLocale: string) => {
|
||||||
|
router.push(`/${newLocale}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={locale} onValueChange={handleLanguageChange}>
|
||||||
|
<SelectTrigger className="w-[70px] rounded-xl border-gray-300">
|
||||||
|
<SelectValue>
|
||||||
|
<FlagIcon locale={locale} />
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LANGUAGES.map((language) => (
|
||||||
|
<SelectItem key={language.code} value={language.code}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlagIcon locale={language.code} />
|
||||||
|
<span>{language.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagIcon({ locale }: { locale: string }) {
|
||||||
|
const language = LANGUAGES.find((lang) => lang.code === locale)
|
||||||
|
|
||||||
|
if (!language) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-5 w-7">
|
||||||
|
<Image src={language.flag || "/placeholder.svg"} alt={language.name} fill className="object-cover rounded" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
components/layout/ui/SearchBar.tsx
Normal file
75
components/layout/ui/SearchBar.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
isMobile: boolean;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({
|
||||||
|
isMobile,
|
||||||
|
searchPlaceholder,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
className = "",
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
// Here you can add search logic or API call
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="top-4 translate-y-0">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{searchPlaceholder}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="h-10 rounded-xl focus:border-[#005bff] focus-visible:border-[#005bff] focus-visible:ring-0 active:border-[#005bff]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-[#005bff] rounded-xl ${className}`}>
|
||||||
|
<div className="w-full">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
|
||||||
|
>
|
||||||
|
<Search className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/skeletons/CartItemSkeleton.tsx
Normal file
27
components/skeletons/CartItemSkeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function CartItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 rounded-xl">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Product Image */}
|
||||||
|
<Skeleton className="w-24 h-24 rounded-lg flex-shrink-0 bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||||
|
<Skeleton className="h-4 w-1/2 bg-gray-200" />
|
||||||
|
<Skeleton className="h-6 w-20 bg-gray-200 mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
|
||||||
|
<Skeleton className="w-8 h-8 bg-gray-200" />
|
||||||
|
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
components/skeletons/CategorySkeleton.tsx
Normal file
17
components/skeletons/CategorySkeleton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function CategorySkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden rounded-xl">
|
||||||
|
{/* Image */}
|
||||||
|
<Skeleton className="w-full h-36 bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
components/skeletons/HomeSkeleton.tsx
Normal file
30
components/skeletons/HomeSkeleton.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import ProductGridSkeleton from "./ProductGridSkeleton"
|
||||||
|
import CategorySkeleton from "./CategorySkeleton"
|
||||||
|
|
||||||
|
export default function HomeSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
|
||||||
|
{/* Hero Carousel Skeleton */}
|
||||||
|
<section className="rounded-2xl overflow-hidden">
|
||||||
|
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[420px] bg-gray-200" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Categories Section Skeleton */}
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<Skeleton className="h-6 w-32 mb-4 bg-gray-200" />
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<CategorySkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Products Section Skeleton */}
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<Skeleton className="h-6 w-32 mb-4 bg-gray-200" />
|
||||||
|
<ProductGridSkeleton count={10} columns="5" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
components/skeletons/PageLoader.tsx
Normal file
129
components/skeletons/PageLoader.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import ProductGridSkeleton from "./ProductGridSkeleton"
|
||||||
|
import CartItemSkeleton from "./CartItemSkeleton" // Added import for CartItemSkeleton
|
||||||
|
|
||||||
|
interface PageLoaderProps {
|
||||||
|
/**
|
||||||
|
* Type of page loading skeleton
|
||||||
|
* home, products, category, search, cart, favorites, orders, profile
|
||||||
|
*/
|
||||||
|
type?: "home" | "products" | "category" | "search" | "cart" | "favorites" | "orders" | "profile"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageLoader({ type = "products" }: PageLoaderProps) {
|
||||||
|
switch (type) {
|
||||||
|
case "home":
|
||||||
|
return (
|
||||||
|
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<Skeleton className="w-full h-[300px] rounded-2xl bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-6 w-32 bg-gray-200" />
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-square bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-6 w-32 bg-gray-200" />
|
||||||
|
<ProductGridSkeleton count={8} columns="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "products":
|
||||||
|
case "search":
|
||||||
|
return (
|
||||||
|
<div className="px-4 md:px-8 lg:px-12 py-8">
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<Skeleton className="h-8 w-40 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
<ProductGridSkeleton count={12} columns="5" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "category":
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Filters Sidebar */}
|
||||||
|
<div className="hidden sm:block w-[280px] space-y-6">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-24 bg-gray-200" />
|
||||||
|
<Skeleton className="h-4 w-full bg-gray-200" />
|
||||||
|
<Skeleton className="h-4 w-full bg-gray-200" />
|
||||||
|
<Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<ProductGridSkeleton count={12} columns="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "cart":
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<CartItemSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Order Summary */}
|
||||||
|
<div className="lg:w-[420px]">
|
||||||
|
<div className="space-y-4 bg-gray-50 p-6 rounded-xl">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-full bg-gray-200" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "orders":
|
||||||
|
case "favorites":
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-64 w-full bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "profile":
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-4 pt-20">
|
||||||
|
<div className="container mx-auto max-w-2xl">
|
||||||
|
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
|
||||||
|
<div className="bg-white p-6 rounded-xl space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32 bg-gray-200" />
|
||||||
|
<Skeleton className="h-10 w-full bg-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <ProductGridSkeleton count={12} columns="5" />
|
||||||
|
}
|
||||||
|
}
|
||||||
23
components/skeletons/ProductCardSkeleton.tsx
Normal file
23
components/skeletons/ProductCardSkeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function ProductCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden rounded-xl">
|
||||||
|
{/* Image Skeleton */}
|
||||||
|
<Skeleton className="aspect-square w-full bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Content Skeleton */}
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{/* Title skeleton - 2 lines */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full bg-gray-200" />
|
||||||
|
<Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price skeleton */}
|
||||||
|
<Skeleton className="h-6 w-1/2 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
components/skeletons/ProductGridSkeleton.tsx
Normal file
24
components/skeletons/ProductGridSkeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import ProductCardSkeleton from "./ProductCardSkeleton"
|
||||||
|
|
||||||
|
interface ProductGridSkeletonProps {
|
||||||
|
count?: number
|
||||||
|
columns?: "2" | "3" | "4" | "5"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductGridSkeleton({ count = 8, columns = "4" }: ProductGridSkeletonProps) {
|
||||||
|
const gridClass =
|
||||||
|
{
|
||||||
|
"2": "grid-cols-2",
|
||||||
|
"3": "md:grid-cols-3",
|
||||||
|
"4": "md:grid-cols-4 lg:grid-cols-4",
|
||||||
|
"5": "md:grid-cols-4 xl:grid-cols-5",
|
||||||
|
}[columns] || "md:grid-cols-4"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-2 sm:grid-cols-3 ${gridClass} gap-4`}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<ProductCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
60
components/ui/button.tsx
Normal file
60
components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
45
components/ui/radio-group.tsx
Normal file
45
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
187
components/ui/select.tsx
Normal file
187
components/ui/select.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
40
components/ui/sonner.tsx
Normal file
40
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
72
context/AuthWrapper.tsx
Normal file
72
context/AuthWrapper.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, type ReactNode } from "react"
|
||||||
|
import { useRouter, usePathname } from "next/navigation"
|
||||||
|
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth"
|
||||||
|
|
||||||
|
interface AuthWrapperProps {
|
||||||
|
children: ReactNode
|
||||||
|
requireAuth?: boolean
|
||||||
|
redirectTo?: 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({
|
||||||
|
children,
|
||||||
|
requireAuth = false,
|
||||||
|
redirectTo,
|
||||||
|
locale,
|
||||||
|
}: AuthWrapperProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { isAuthenticated, isLoading, user } = useAuthStatus()
|
||||||
|
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for auth check to complete
|
||||||
|
if (isLoading || isGettingGuestToken) return
|
||||||
|
|
||||||
|
// If auth required and user not authenticated
|
||||||
|
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("; ")
|
||||||
|
.some((row) => row.startsWith("guestToken="))
|
||||||
|
|
||||||
|
if (!hasGuestToken) {
|
||||||
|
getGuestToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, getGuestToken, isGettingGuestToken])
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (isLoading || (requireAuth && !isAuthenticated)) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
20
context/provider.tsx
Normal file
20
context/provider.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { QueryClientProvider, HydrationBoundary } from "@tanstack/react-query"
|
||||||
|
import { queryClient } from "@/lib/queryClient"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
interface ProvidersProps {
|
||||||
|
children: ReactNode
|
||||||
|
dehydratedState?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children, dehydratedState }: ProvidersProps) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<HydrationBoundary state={dehydratedState}>
|
||||||
|
{children}
|
||||||
|
</HydrationBoundary>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
19
hooks/use-mobile.ts
Normal file
19
hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener('change', onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener('change', onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
191
hooks/use-toast.ts
Normal file
191
hooks/use-toast.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
|
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||||
|
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||||
|
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType['ADD_TOAST']
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['UPDATE_TOAST']
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['DISMISS_TOAST']
|
||||||
|
toastId?: ToasterToast['id']
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['REMOVE_TOAST']
|
||||||
|
toastId?: ToasterToast['id']
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: 'REMOVE_TOAST',
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UPDATE_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DISMISS_TOAST': {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'REMOVE_TOAST':
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, 'id'>
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TOAST',
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'ADD_TOAST',
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
32
i18n.ts
Normal file
32
i18n.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getRequestConfig } from "next-intl/server"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
export const locales = ["ru", "tm"] as const
|
||||||
|
export const defaultLocale = "ru" as const
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
let locale = await requestLocale
|
||||||
|
|
||||||
|
// Fallback to default if undefined
|
||||||
|
if (!locale) {
|
||||||
|
locale = defaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate locale
|
||||||
|
if (!locales.includes(locale as any)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = (await import(`./messages/${locale}.json`)).default
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
212
lib/api.ts
Normal file
212
lib/api.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token management utilities
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTokenInCookie = (name: string, token: string): void => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
document.cookie = `${name}=${token}; path=/; secure; SameSite=Strict; max-age=2592000`
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTokenFromCookie = (name: string): void => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToken = (): string | null => {
|
||||||
|
// Check cookies first (more secure)
|
||||||
|
const authToken = getTokenFromCookie("authToken")
|
||||||
|
if (authToken) return authToken
|
||||||
|
|
||||||
|
const guestToken = getTokenFromCookie("guestToken")
|
||||||
|
if (guestToken) return guestToken
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized API client with interceptors
|
||||||
|
*/
|
||||||
|
class APIClient {
|
||||||
|
private client: AxiosInstance
|
||||||
|
private baseUrl: string
|
||||||
|
private isRefreshing = false
|
||||||
|
private failedQueue: Array<{
|
||||||
|
resolve: (value?: unknown) => void
|
||||||
|
reject: (reason?: unknown) => void
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com"
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: `${this.baseUrl}/api/v1`,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setupInterceptors()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInterceptors(): void {
|
||||||
|
// Request interceptor
|
||||||
|
this.client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add language parameter if i18n is available
|
||||||
|
if (typeof window !== "undefined" && (window as any).i18n) {
|
||||||
|
const lang = (window as any).i18n.language || "tm"
|
||||||
|
const url = config.url || ""
|
||||||
|
config.url = `${url}${url.includes("?") ? "&" : "?"}lang=${lang}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
|
||||||
|
// Handle 401 errors
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
// Queue requests while refreshing
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.failedQueue.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
.then(() => this.client(originalRequest))
|
||||||
|
.catch((err) => Promise.reject(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
this.isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to get guest token
|
||||||
|
const guestTokenResponse = await axios.post(
|
||||||
|
`${this.baseUrl}/api/v1/auth/guest-token`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data
|
||||||
|
|
||||||
|
if (newToken) {
|
||||||
|
setTokenInCookie("guestToken", newToken)
|
||||||
|
this.processQueue(null)
|
||||||
|
return this.client(originalRequest)
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
this.processQueue(refreshError)
|
||||||
|
this.clearAuthToken()
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.href = "/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle HTML error responses
|
||||||
|
if (
|
||||||
|
error.response?.data &&
|
||||||
|
typeof error.response.data === "string" &&
|
||||||
|
error.response.data.includes("<!DOCTYPE html>")
|
||||||
|
) {
|
||||||
|
return Promise.reject({
|
||||||
|
...error,
|
||||||
|
response: {
|
||||||
|
...error.response,
|
||||||
|
data: { message: "Server returned HTML instead of JSON" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private processQueue(error: any): void {
|
||||||
|
this.failedQueue.forEach((promise) => {
|
||||||
|
if (error) {
|
||||||
|
promise.reject(error)
|
||||||
|
} else {
|
||||||
|
promise.resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.failedQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.client.get<T>(url, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.client.post<T>(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.client.put<T>(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.client.patch<T>(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
return this.client.delete<T>(url, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthToken(token: string): void {
|
||||||
|
removeTokenFromCookie("guestToken")
|
||||||
|
setTokenInCookie("authToken", token)
|
||||||
|
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
setGuestToken(token: string): void {
|
||||||
|
setTokenInCookie("guestToken", token)
|
||||||
|
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthToken(): void {
|
||||||
|
removeTokenFromCookie("authToken")
|
||||||
|
removeTokenFromCookie("guestToken")
|
||||||
|
delete this.client.defaults.headers.common["Authorization"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const apiClient = new APIClient()
|
||||||
|
|
||||||
|
// Export helper functions
|
||||||
|
export const setAuthToken = (token: string) => apiClient.setAuthToken(token)
|
||||||
|
export const setGuestToken = (token: string) => apiClient.setGuestToken(token)
|
||||||
|
export const clearAuthToken = () => apiClient.clearAuthToken()
|
||||||
58
lib/config/api-endpoints.ts
Normal file
58
lib/config/api-endpoints.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
18
lib/hooks/index.ts
Normal file
18
lib/hooks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export * from "./useProducts"
|
||||||
|
export * from "./useCategories"
|
||||||
|
export * from "./useCart"
|
||||||
|
export * from "./useFavorites"
|
||||||
|
export * from "./useOrders"
|
||||||
|
export * from "./useSearch"
|
||||||
|
export * from "./useUserProfile"
|
||||||
|
export * from "./useOpenStore"
|
||||||
|
export * from "./useRegions"
|
||||||
|
export * from "./useAddresses"
|
||||||
|
export * from "./usePaymentTypes"
|
||||||
|
export * from "./useCategories"
|
||||||
|
|
||||||
|
export * from "./useMedia"
|
||||||
|
export * from "./useCollections"
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type { Product, Category, Cart, CartItem, Order, Favorite, Banner } from "@/lib/types/api"
|
||||||
14
lib/hooks/useAddresses.ts
Normal file
14
lib/hooks/useAddresses.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
192
lib/hooks/useAuth.ts
Normal file
192
lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient, setAuthToken, clearAuthToken, setGuestToken } from "@/lib/api"
|
||||||
|
import { queryClient } from "@/lib/queryClient"
|
||||||
|
|
||||||
|
interface LoginCredentials {
|
||||||
|
phone_number: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterData {
|
||||||
|
phone_number: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifyTokenData {
|
||||||
|
phone_number: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthResponse {
|
||||||
|
token?: string
|
||||||
|
data?: string
|
||||||
|
user?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guest Token alma (RTK mantığı)
|
||||||
|
*/
|
||||||
|
export function useGetGuestToken() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {}, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const token = data?.token || data?.data
|
||||||
|
if (token) {
|
||||||
|
setGuestToken(token)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error fetching guest token:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login mutation (RTK mantığı)
|
||||||
|
*/
|
||||||
|
export function useLogin() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>("/auth/login", credentials)
|
||||||
|
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) => {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register mutation (RTK mantığı)
|
||||||
|
*/
|
||||||
|
export function useRegister() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>("/auth/register", userData)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const token = data?.token || data?.data
|
||||||
|
if (token) {
|
||||||
|
setAuthToken(token)
|
||||||
|
apiClient.setAuthToken(token)
|
||||||
|
|
||||||
|
// Tüm cache'i temizle
|
||||||
|
queryClient.invalidateQueries()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Register error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token doğrulama (RTK mantığı)
|
||||||
|
*/
|
||||||
|
export function useVerifyToken() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>(
|
||||||
|
"/auth/verify",
|
||||||
|
verifyData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const token = data?.data || data?.token
|
||||||
|
if (token) {
|
||||||
|
setAuthToken(token)
|
||||||
|
apiClient.setAuthToken(token)
|
||||||
|
|
||||||
|
// Tüm cache'i temizle
|
||||||
|
queryClient.invalidateQueries()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error verifying token:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout işlemi
|
||||||
|
*/
|
||||||
|
export function useLogout() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Backend'e logout isteği gönder (eğer endpoint varsa)
|
||||||
|
try {
|
||||||
|
await apiClient.post("/auth/logout")
|
||||||
|
} catch (error) {
|
||||||
|
// Logout endpoint yoksa da devam et
|
||||||
|
console.warn("Logout endpoint not available")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
clearAuthToken()
|
||||||
|
apiClient.clearAuthToken()
|
||||||
|
|
||||||
|
// Tüm cache'i temizle
|
||||||
|
queryClient.clear()
|
||||||
|
|
||||||
|
// Login sayfasına yönlendir
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.href = "/login"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
lib/hooks/useCart.ts
Normal file
92
lib/hooks/useCart.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Cart, CartItem } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cart"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Cart>("/api/v1/carts")
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 0, // Always fetch fresh
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddToCart() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ productId, quantity = 1 }: { productId: number; quantity?: number }) => {
|
||||||
|
const response = await apiClient.post<Cart>("/api/v1/carts", {
|
||||||
|
product_id: productId,
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("[v0] Add to cart error:", error.response?.data?.message || error.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveFromCart() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (itemId: number) => {
|
||||||
|
await apiClient.delete(`/api/v1/carts/${itemId}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCartItemQuantity() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ itemId, quantity }: { itemId: number; quantity: number }) => {
|
||||||
|
const response = await apiClient.patch<CartItem>(`/api/v1/carts/${itemId}`, {
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOrder() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: {
|
||||||
|
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
|
||||||
|
}) => {
|
||||||
|
const response = await apiClient.post("/api/v1/orders", payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("[v0] Create order error:", error.response?.data?.message || error.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
158
lib/hooks/useCategories.ts
Normal file
158
lib/hooks/useCategories.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Category, Product, PaginatedResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
|
// Get all categories as tree
|
||||||
|
export function useCategories(options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Category>>("/categories", {
|
||||||
|
params: { type: "tree" },
|
||||||
|
})
|
||||||
|
return response.data.data || response.data
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false,
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get single category by ID
|
||||||
|
export function useCategory(id: number | string, options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Category>(`/categories/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!id,
|
||||||
|
staleTime: 1000 * 60 * 15,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products for a single category with pagination
|
||||||
|
export function useCategoryProducts(
|
||||||
|
categoryId: number | string,
|
||||||
|
options?: {
|
||||||
|
enabled?: boolean
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category", categoryId, "products", options?.page, options?.limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/categories/${categoryId}/products`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page: options?.page || 1,
|
||||||
|
limit: options?.limit
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
pagination: response.data.pagination || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!categoryId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ALL products from category and its children - NO pagination (for initial load)
|
||||||
|
export function useAllCategoryProducts(
|
||||||
|
category: Category | undefined,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category", category?.id, "all-products"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!category) return []
|
||||||
|
|
||||||
|
const fetchProducts = async (categoryId: number) => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/categories/${categoryId}/products`
|
||||||
|
)
|
||||||
|
return response.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
let allProducts = await fetchProducts(category.id)
|
||||||
|
|
||||||
|
if (category.children && category.children.length > 0) {
|
||||||
|
for (const child of category.children) {
|
||||||
|
const childProducts = await fetchProducts(child.id)
|
||||||
|
allProducts = [...allProducts, ...childProducts]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allProducts
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated)
|
||||||
|
export function useAllCategoryProductsPaginated(
|
||||||
|
category: Category | undefined,
|
||||||
|
options?: {
|
||||||
|
enabled?: boolean
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const page = options?.page || 1
|
||||||
|
const limit = options?.limit || 6
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category", category?.id, "paginated-products", page, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!category) {
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
hasMorePages: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryIds = [category.id]
|
||||||
|
if (category.children && category.children.length > 0) {
|
||||||
|
category.children.forEach((child) => categoryIds.push(child.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const perCategoryLimit = Math.ceil(limit / categoryIds.length)
|
||||||
|
const hasMoreByCategory: Record<number, boolean> = {}
|
||||||
|
let allPageProducts: Product[] = []
|
||||||
|
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||||
|
`/categories/${categoryId}/products`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
limit: perCategoryLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.data) {
|
||||||
|
allPageProducts = [...allPageProducts, ...response.data.data]
|
||||||
|
hasMoreByCategory[categoryId] = !!response.data.pagination?.next_page_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMorePages = Object.values(hasMoreByCategory).some((hasMore) => hasMore)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: allPageProducts,
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
hasMorePages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!category,
|
||||||
|
})
|
||||||
|
}
|
||||||
108
lib/hooks/useCollections.ts
Normal file
108
lib/hooks/useCollections.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
42
lib/hooks/useFavorites.ts
Normal file
42
lib/hooks/useFavorites.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Favorite } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function useFavorites() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["favorites"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Favorite[]>("/favorites")
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddToFavorites() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (productId: number) => {
|
||||||
|
const response = await apiClient.post<Favorite[]>("/favorites", { product_id: productId })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["favorites"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveFromFavorites() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (productId: number) => {
|
||||||
|
await apiClient.delete(`/favorites/${productId}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["favorites"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
29
lib/hooks/useMedia.ts
Normal file
29
lib/hooks/useMedia.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Carousel, Banner, PaginatedResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
|
// Get all carousels
|
||||||
|
export function useCarousels(options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["carousels"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Carousel>>("/media/carousels")
|
||||||
|
return response.data.data || response.data
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false,
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all banners
|
||||||
|
export function useBanners(options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["banners"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Banner>>("/media/banners")
|
||||||
|
return response.data.data || response.data
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false,
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
38
lib/hooks/useOpenStore.ts
Normal file
38
lib/hooks/useOpenStore.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
||||||
|
|
||||||
|
interface OpenStoreData {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
patentFile: File
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenStoreResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenStore() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: OpenStoreData) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("first_name", data.firstName)
|
||||||
|
formData.append("last_name", data.lastName)
|
||||||
|
formData.append("email", data.email)
|
||||||
|
formData.append("phone", data.phone)
|
||||||
|
formData.append("patent_file", data.patentFile)
|
||||||
|
|
||||||
|
const response = await apiClient.post<OpenStoreResponse>(API_ENDPOINTS.openStore, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
59
lib/hooks/useOrders.ts
Normal file
59
lib/hooks/useOrders.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Order, PaginatedResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function useOrders(options?: { page?: number; perPage?: number }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["orders", options?.page],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Order>>("/orders", {
|
||||||
|
params: {
|
||||||
|
page: options?.page || 1,
|
||||||
|
per_page: options?.perPage || 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data.data || response.data
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrder(id: number | string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["order", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Order>(`/orders/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelOrder() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orderId: number) => {
|
||||||
|
await apiClient.post(`/orders/${orderId}/cancel`, {})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOrder() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orderData: any) => {
|
||||||
|
const response = await apiClient.post<Order>("/orders", orderData)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
14
lib/hooks/usePaymentTypes.ts
Normal file
14
lib/hooks/usePaymentTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { PaymentTypeOption } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function usePaymentTypes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["paymentTypes"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaymentTypeOption[]>("/api/v1/order-payments")
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
|
})
|
||||||
|
}
|
||||||
50
lib/hooks/useProducts.ts
Normal file
50
lib/hooks/useProducts.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Product, PaginatedResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
|
interface UseProductsOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
staleTime?: number
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProducts(options?: UseProductsOptions) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["products", options?.page, options?.perPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<Product>>("/products", {
|
||||||
|
params: {
|
||||||
|
page: options?.page || 1,
|
||||||
|
per_page: options?.perPage || 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data.data || response.data
|
||||||
|
},
|
||||||
|
staleTime: options?.staleTime ?? 1000 * 60 * 5, // 5 minutes
|
||||||
|
enabled: options?.enabled !== false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProduct(id: number | string, options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["product", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Product>(`/products/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||||
|
enabled: options?.enabled !== false && !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProductsBySlug(slug: string, options?: { enabled?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["products", "slug", slug],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Product>(`/products/${slug}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: options?.enabled !== false && !!slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
14
lib/hooks/useRegions.ts
Normal file
14
lib/hooks/useRegions.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { Region } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function useRegions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["regions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<Region[]>("/api/v1/regions")
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
|
})
|
||||||
|
}
|
||||||
28
lib/hooks/useSearch.ts
Normal file
28
lib/hooks/useSearch.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { apiClient } from "@/lib/api"
|
||||||
|
import type { SearchFilters, SearchResponse } from "@/lib/types/api"
|
||||||
|
|
||||||
|
export function useSearch(options: SearchFilters) {
|
||||||
|
const { q, category_id, brand_id, price_from, price_to, page = 1, per_page = 20 } = options
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["search", { q, category_id, brand_id, price_from, price_to, page, per_page }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
per_page: String(per_page),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (q) params.append("q", q)
|
||||||
|
if (category_id) params.append("category_id", String(category_id))
|
||||||
|
if (brand_id) params.append("brand_id", String(brand_id))
|
||||||
|
if (price_from) params.append("price_from", String(price_from))
|
||||||
|
if (price_to) params.append("price_to", String(price_to))
|
||||||
|
|
||||||
|
const response = await apiClient.get<SearchResponse>(`/search?${params}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!q && q.length > 0,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
18
lib/hooks/useUserProfile.ts
Normal file
18
lib/hooks/useUserProfile.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
29
lib/i18n-utils.ts
Normal file
29
lib/i18n-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useLocale } from "next-intl"
|
||||||
|
|
||||||
|
export function useLocaleInfo() {
|
||||||
|
const locale = useLocale()
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
isRussian: locale === "ru",
|
||||||
|
isTurkmen: locale === "tm",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleFlag(locale: string) {
|
||||||
|
const flags: Record<string, string> = {
|
||||||
|
ru: "🇷🇺",
|
||||||
|
tm: "🇹🇲",
|
||||||
|
}
|
||||||
|
return flags[locale] || "🌐"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleName(locale: string) {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
ru: "Русский",
|
||||||
|
tm: "Türkmençe",
|
||||||
|
}
|
||||||
|
return names[locale] || locale.toUpperCase()
|
||||||
|
}
|
||||||
46
lib/loading-utils.ts
Normal file
46
lib/loading-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Debounce function for handling rapid state changes
|
||||||
|
* @param func - Function to debounce
|
||||||
|
* @param delay - Delay in milliseconds
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(func: T, delay: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => func(...args), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle function for rate-limiting function calls
|
||||||
|
* @param func - Function to throttle
|
||||||
|
* @param limit - Minimum time between calls
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(func: T, limit: number): (...args: Parameters<T>) => void {
|
||||||
|
let lastRun = 0
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastRun >= limit) {
|
||||||
|
func(...args)
|
||||||
|
lastRun = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep utility for simulating delays
|
||||||
|
* @param ms - Milliseconds to sleep
|
||||||
|
*/
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate loading state
|
||||||
|
* @param duration - Duration of loading state
|
||||||
|
*/
|
||||||
|
export async function simulateLoading(duration = 500): Promise<void> {
|
||||||
|
return sleep(duration)
|
||||||
|
}
|
||||||
132
lib/mock-server/axios-adapter.ts
Normal file
132
lib/mock-server/axios-adapter.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/mock-server/data.ts
Normal file
95
lib/mock-server/data.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 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() },
|
||||||
|
]
|
||||||
181
lib/mock-server/handlers.ts
Normal file
181
lib/mock-server/handlers.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* 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 } }
|
||||||
|
},
|
||||||
|
}
|
||||||
19
lib/queryClient.ts
Normal file
19
lib/queryClient.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
195
lib/types/api.ts
Normal file
195
lib/types/api.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* API Response and Entity Type Definitions
|
||||||
|
* Based on Postman collection structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Product Types
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug?: string
|
||||||
|
price: number
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
images?: string[]
|
||||||
|
category: string
|
||||||
|
brand?: string
|
||||||
|
stock?: number
|
||||||
|
rating?: number
|
||||||
|
reviews_count?: number
|
||||||
|
is_favorite?: boolean
|
||||||
|
is_in_cart?: boolean
|
||||||
|
labels?: Array<{ text: string; bg_color: string }>
|
||||||
|
struct_price_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Types
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
image: string
|
||||||
|
parent_id?: number
|
||||||
|
children?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cart Types
|
||||||
|
export interface CartItem {
|
||||||
|
id: number
|
||||||
|
product_id: number
|
||||||
|
product?: Product
|
||||||
|
seller: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
total: number
|
||||||
|
price_formatted?: string
|
||||||
|
sub_total_formatted?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
id: string
|
||||||
|
items: CartItem[]
|
||||||
|
total: number
|
||||||
|
total_formatted?: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites Types
|
||||||
|
export interface Favorite {
|
||||||
|
id: number
|
||||||
|
product_id: number
|
||||||
|
product?: Product
|
||||||
|
added_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order Types
|
||||||
|
export interface OrderItem {
|
||||||
|
id: number
|
||||||
|
product_id: number
|
||||||
|
product?: Product
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: number
|
||||||
|
number?: string
|
||||||
|
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"
|
||||||
|
items: OrderItem[]
|
||||||
|
total: number
|
||||||
|
total_formatted?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
|
estimated_delivery?: string
|
||||||
|
tracking_number?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination Types
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
pagination: {
|
||||||
|
current_page: number
|
||||||
|
last_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Types
|
||||||
|
export interface SearchFilters {
|
||||||
|
q?: string
|
||||||
|
category_id?: number
|
||||||
|
brand_id?: number
|
||||||
|
price_from?: number
|
||||||
|
price_to?: number
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
products: Product[]
|
||||||
|
total: number
|
||||||
|
filters?: {
|
||||||
|
brands: Array<{ id: number; name: string }>
|
||||||
|
categories: Array<{ id: number; name: string }>
|
||||||
|
price_range: { min: number; max: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile Types
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
phone?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
avatar?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Types
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
user: UserProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner Types
|
||||||
|
export interface Banner {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
image: string
|
||||||
|
url?: string
|
||||||
|
type?: string
|
||||||
|
place?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic API Error Response
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region, Address, PaymentType, and ShippingMethod Types
|
||||||
|
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 PaymentTypeOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShippingMethod {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order creation payload type
|
||||||
|
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
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user