connected api with profile, order

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

View File

@@ -0,0 +1,176 @@
"use client"
import { useState, useEffect, useRef } from "react"
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
onUpdate?: () => void
}
export default function CartItemCard({ item, translations: t, onUpdate }: CartItemCardProps) {
const [localQuantity, setLocalQuantity] = useState(item.quantity)
const [pendingQuantity, setPendingQuantity] = useState(item.quantity)
const [isLoading, setIsLoading] = useState(false)
const updateTimeoutRef = useRef<NodeJS.Timeout>()
const { mutate: updateQuantity } = useUpdateCartItemQuantity()
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
useEffect(() => {
setLocalQuantity(item.quantity)
setPendingQuantity(item.quantity)
}, [item.quantity])
useEffect(() => {
if (pendingQuantity === item.quantity) return
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current)
}
updateTimeoutRef.current = setTimeout(() => {
setIsLoading(true)
if (pendingQuantity <= 0) {
removeItem(item.product_id, {
onSuccess: () => onUpdate?.(),
onError: () => {
setLocalQuantity(item.quantity)
setPendingQuantity(item.quantity)
},
onSettled: () => setIsLoading(false),
})
} else {
updateQuantity(
{ productId: item.product_id, quantity: pendingQuantity },
{
onSuccess: () => onUpdate?.(),
onError: () => {
setLocalQuantity(item.quantity)
setPendingQuantity(item.quantity)
},
onSettled: () => setIsLoading(false),
}
)
}
}, 300)
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current)
}
}
}, [pendingQuantity, item.quantity, item.product_id, updateQuantity, removeItem, onUpdate])
const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (isLoading) return
const newQuantity = localQuantity + 1
setLocalQuantity(newQuantity)
setPendingQuantity(newQuantity)
}
const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (isLoading) return
const newQuantity = localQuantity - 1
if (newQuantity < 1) {
handleDelete()
return
}
setLocalQuantity(newQuantity)
setPendingQuantity(newQuantity)
}
const handleDelete = () => {
setIsLoading(true)
removeItem(item.product_id, {
onSuccess: () => onUpdate?.(),
onSettled: () => setIsLoading(false),
})
}
const getImageSrc = () => {
if (item.product.image) return item.product.image
if (item.product.images?.length > 0) return item.product.images[0]
return "/placeholder.svg"
}
return (
<Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
<Image src={getImageSrc()} alt={item.product.name} fill className="object-contain" />
</div>
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3>
<p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isRemoving || isLoading}
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500"
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">
{t.pricePerUnit} <span className="text-primary">{item.price_formatted}</span>
</p>
<p className="text-sm font-semibold">
{t.additionalPrice} {item.sub_total_formatted}
</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}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
disabled={isLoading || isRemoving}
className="rounded-xl bg-blue-50"
>
<Minus className="h-4 w-4" />
</Button>
<div className="w-12 text-center font-semibold">{localQuantity}</div>
<Button
variant="outline"
size="icon"
onClick={handleQuantityIncrease}
disabled={isLoading || isRemoving}
className="rounded-xl bg-blue-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
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 hover:shadow-md ${
selectedType === type
? "border-2 border-[#005bff] bg-blue-50"
: "border-2 border-gray-200"
}`}
onClick={() => onSelect(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<Icon
className={`h-8 w-8 ${
selectedType === type ? "text-[#005bff]" : "text-gray-600"
}`}
/>
<span className={`text-xs font-medium ${
selectedType === type ? "text-[#005bff]" : "text-gray-700"
}`}>
{label}
</span>
</div>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,176 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Textarea } from "@/components/ui/textarea"
import { Separator } from "@/components/ui/separator"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import DeliveryTypeSelector from "./DeliveryTypeSelector"
import type { Order, Province, DeliveryType, CartTranslations, PaymentType } from "../types"
interface OrderSummaryProps {
order: Order
translations: CartTranslations
paymentType: PaymentType | null
deliveryType: DeliveryType
selectedRegion: string
selectedProvince: number | null
note: string
regionGroups: Record<string, Province[]>
availableRegions: string[]
paymentTypes: PaymentType[]
onPaymentTypeChange: (type: PaymentType) => void
onDeliveryTypeChange: (type: DeliveryType) => void
onRegionChange: (regionCode: string) => void
onProvinceChange: (provinceId: number) => void
onNoteChange: (note: string) => void
onCompleteOrder: () => void
isLoading: boolean
}
export default function OrderSummary({
order,
translations: t,
paymentType,
deliveryType,
selectedRegion,
selectedProvince,
note,
regionGroups,
availableRegions,
paymentTypes,
onPaymentTypeChange,
onDeliveryTypeChange,
onRegionChange,
onProvinceChange,
onNoteChange,
onCompleteOrder,
isLoading,
}: OrderSummaryProps) {
const provincesForSelectedRegion = selectedRegion ? regionGroups[selectedRegion] || [] : []
const isFormValid = selectedRegion && selectedProvince && paymentType
return (
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
{/* Payment Type */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</h3>
<div className="flex gap-2">
{paymentTypes.map((type) => (
<Card
key={type.id}
className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50"
: "border-2 border-gray-200"
}`}
onClick={() => onPaymentTypeChange(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<span className={`text-xs font-medium ${
paymentType?.id === type.id ? "text-[#005bff]" : ""
}`}>
{type.name}
</span>
</div>
</Card>
))}
</div>
</div>
{/* Delivery Type */}
<DeliveryTypeSelector
selectedType={deliveryType}
onSelect={onDeliveryTypeChange}
translations={t}
/>
{/* Region Selection */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.selectRegion}</Label>
<RadioGroup
value={selectedRegion}
onValueChange={(value) => {
onRegionChange(value)
onProvinceChange(null as any)
}}
className="flex flex-wrap gap-4"
>
{availableRegions.map((regionCode) => (
<div key={regionCode} className="flex items-center space-x-2">
<RadioGroupItem
value={regionCode}
id={`region-${regionCode}`}
className="border-2 border-gray-400 data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white"
/>
<Label htmlFor={`region-${regionCode}`} className="cursor-pointer uppercase">
{regionCode}
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.selectAddress}</Label>
<Select
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger className="rounded-xl">
<SelectValue placeholder={t.selectAddress} />
</SelectTrigger>
<SelectContent>
{provincesForSelectedRegion.map((province) => (
<SelectItem key={province.id} value={province.id.toString()}>
{province.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Note */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.note}</Label>
<Textarea
value={note}
onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none"
rows={3}
placeholder={t.note}
/>
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{order.billing.body.map((item, index) => (
<div key={index} className="flex justify-between text-base font-medium">
<span>{item.title}:</span>
<span>{item.value}</span>
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<span className="text-lg font-semibold">{order.billing.footer.title}:</span>
<span className="text-lg font-bold text-green-600">{order.billing.footer.value}</span>
</div>
<Button
onClick={onCompleteOrder}
disabled={!isFormValid || isLoading}
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg"
>
{isLoading ? `${t.placeOrder}...` : t.placeOrder}
</Button>
</Card>
)
}

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

@@ -0,0 +1,248 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
import type { CartItem } from "@/lib/types/api"
interface CartResponse {
message: string
data: CartItem[]
errorDetails?: string
}
// Transform response to handle HTML/malformed responses
function transformCartResponse(response: any): CartResponse {
if (
typeof response === "string" &&
(response.trim().startsWith("<!DOCTYPE") || response.trim().startsWith("<html"))
) {
console.error("Received HTML response instead of JSON:", response.substring(0, 100))
return {
message: "error",
data: [],
errorDetails: "Server returned HTML instead of JSON. The server might be down or experiencing issues.",
}
}
if (typeof response === "object") {
if (response.data) {
return response
}
return { message: "success", data: [] }
}
if (typeof response === "string") {
try {
const parsed = JSON.parse(response)
return parsed
} catch (error) {
console.error("Failed to parse response:", error)
return { message: "error", data: [] }
}
}
return { message: "unknown", data: [] }
}
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
return useQuery({
queryKey: ["cart"],
queryFn: async () => {
const response = await apiClient.get("/carts")
return transformCartResponse(response.data)
},
refetchInterval: 5000, // Poll every 5 seconds like RTK
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: 0,
retry: 1,
...options,
})
}
export function useAddToCart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ productId, quantity = 1 }: { productId: number; quantity?: number }) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
})
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
if (typeof response.data === "object" && response.data.data) {
return response.data
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed
} catch (error) {
console.error("Failed to parse add to cart response:", error)
return { message: "success", data: "Added to cart" }
}
}
return { message: "success", data: "Added to cart" }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
},
onError: (error: any) => {
console.error("Add to cart error:", error.response?.data?.message || error.message)
},
})
}
export function useRemoveFromCart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (productId: number) => {
const params = new URLSearchParams({ product_id: String(productId) })
const response = await apiClient.patch("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
if (typeof response.data === "object" && response.data.data) {
return response.data.data
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed.data || []
} catch (error) {
console.error("Failed to parse cart response:", error)
return []
}
}
return []
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
},
onError: (error: any) => {
console.error("Remove from cart error:", error.response?.data?.message || error.message)
},
})
}
export function useCleanCart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const response = await apiClient.delete("/carts", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
if (typeof response.data === "object" && response.data.data) {
return response.data.data
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed.data || []
} catch (error) {
console.error("Failed to parse cart response:", error)
return []
}
}
return []
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
},
})
}
export function useUpdateCartItemQuantity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ productId, quantity }: { productId: number; quantity: number }) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
})
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
if (typeof response.data === "object" && response.data.data) {
return response.data
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed
} catch (error) {
console.error("Failed to parse update cart response:", error)
return { message: "success", data: "Updated cart" }
}
}
return { message: "success", data: "Updated cart" }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
},
onError: (error: any) => {
console.error("API update failed:", error.response?.data?.message || error.message)
},
})
}
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("/orders", payload)
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
queryClient.invalidateQueries({ queryKey: ["orders"] })
},
onError: (error: any) => {
console.error("Create order error:", error.response?.data?.message || error.message)
},
})
}

View File

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

171
features/cart/types.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { StaticImageData } from "next/image";
export interface Cart {
message: string;
data: CartItem[];
errorDetails?: string;
total?: number;
total_formatted?: string;
items?: CartItem[]; // Alternative structure
}
export interface Order {
id: number;
seller: {
id: number;
name: string;
};
items: CartItem[];
billing: {
body: Array<{ title: string; value: string }>;
footer: { title: string; value: string };
};
}
export interface Region {
id: number;
code: string;
name: string;
}
export interface Address {
id: number;
title: string;
region_id: number;
address: string;
phone?: string;
is_default?: boolean;
}
export interface PickUpPoint {
id: number;
name: string;
address: string;
}
export interface PaymentTypeOption {
id: number;
name: string;
code: string;
}
export interface CartTranslations {
cart: string;
ordersIn: string;
pricePerUnit: string;
additionalPrice: string;
discount: string;
totalPrice: string;
paymentType: string;
cash: string;
card: string;
deliveryType: string;
delivery: string;
pickup: string;
selectRegion: string;
selectAddress: string;
note: string;
placeOrder: string;
emptyCart: string;
map: string;
}
// API Response types
export interface ApiResponse<T> {
message: string;
data: T;
errorDetails?: string;
}
export interface CreateOrderPayload {
customer_name?: string;
customer_phone?: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}
export interface CartItem {
id: number;
product_id: number;
product: {
id: number;
name: string;
description?: string;
media?: Array<{ images_800x800?: string; thumbnail?: string }>;
channel?: Array<{ id: number; name: string }>;
price_amount?: string;
stock?: number;
};
product_quantity: number;
quantity?: number; // For compatibility
seller?: {
id: number;
name: string;
};
price?: number;
total?: number;
price_formatted?: string;
sub_total_formatted?: string;
discount_formatted?: string;
total_formatted?: string;
}
export interface Province {
id: number;
region: string;
name: string;
}
export interface PaymentType {
id: number;
name: string;
}
export interface Order {
id: number;
seller: {
id: number;
name: string;
};
items: CartItem[];
billing: {
body: Array<{ title: string; value: string }>;
footer: { title: string; value: string };
};
}
export interface CartTranslations {
cart: string;
ordersIn: string;
pricePerUnit: string;
additionalPrice: string;
discount: string;
totalPrice: string;
paymentType: string;
cash: string;
card: string;
deliveryType: string;
delivery: string;
pickup: string;
selectRegion: string;
selectAddress: string;
note: string;
placeOrder: string;
emptyCart: string;
map: string;
}
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface CreateOrderPayload {
customer_address: string;
shipping_method: string;
payment_type_id: number;
region: string;
note?: string;
}