feat: Souq native integration + auth simplification + CE-wide enhancements
Souq Marketplace: - Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat - New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook - Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters - Seller workflow: start order, mark delivered, upload files, confirm delivery - Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner - Fix: order redirect /souq/history → /souq/orders Auth System: - Unified auth page, removed OAuth provider routing (2 files deleted) - Deleted oauth.ts (319 lines) — simplified auth chain - Streamlined login/register API routes Prisma Schema (+179 lines): - New models: Listing, Purchase, Review, Group/GroupMember - Gamification: DailyStreak, XpTransaction, Achievement, UserLevel - Learning: LearnCourse/Module/Enrollment/Certificate - Notifications, Referrals, Dhikr, PremiumBenefits Deleted legacy code: - src/app/api/marketplace/* (3 files) — replaced by /api/souq/* - src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload - src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id] Infrastructure: - Dockerfile: standalone output, Prisma runtime migration - docker-compose.yml: Polar env vars, healthcheck fix - docker-compose.staging.yml: staging on port 4014 - .gitea/workflows/ci.yml: CI pipeline
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Star,
|
||||
Loader2,
|
||||
Check,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
User,
|
||||
MessageCircle,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import ErrorFeedback from "@/components/ErrorFeedback";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Package {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
deliveryDays: number;
|
||||
revisions: number;
|
||||
}
|
||||
|
||||
interface Seller {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface Reviewer {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
rating: number;
|
||||
title: string | null;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
reviewer: Reviewer;
|
||||
}
|
||||
|
||||
interface ListingDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
priceFlh: number;
|
||||
packages: Package[] | null;
|
||||
tags: string[] | null;
|
||||
images: string[] | null;
|
||||
deliveryDays: number | null;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
salesCount: number;
|
||||
seller: Seller;
|
||||
reviews: Review[];
|
||||
_count: { purchases: number };
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
"Web Development": "🌐",
|
||||
"Mobile Development": "📱",
|
||||
"Content Writing": "✍️",
|
||||
"Brand Identity": "🎨",
|
||||
"SEO/Marketing": "📈",
|
||||
"Video Production": "🎬",
|
||||
default: "📦",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
"Web Development": "from-blue-600/40 to-blue-900/30",
|
||||
"Mobile Development": "from-purple-600/40 to-purple-900/30",
|
||||
"Content Writing": "from-emerald-600/40 to-emerald-900/30",
|
||||
"Brand Identity": "from-amber-600/40 to-amber-900/30",
|
||||
"SEO/Marketing": "from-red-600/40 to-red-900/30",
|
||||
"Video Production": "from-pink-600/40 to-pink-900/30",
|
||||
default: "from-gray-600/40 to-gray-900/30",
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatFlh(amount: number): string {
|
||||
return amount.toLocaleString() + " FLH";
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 60) return "just now";
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [listing, setListing] = useState<ListingDetail | null>(null);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
||||
const [ordering, setOrdering] = useState(false);
|
||||
const [orderSuccess, setOrderSuccess] = useState(false);
|
||||
const [orderError, setOrderError] = useState<string | null>(null);
|
||||
|
||||
// ── Auth redirect ───────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/auth");
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
// ── Fetch listing ────────────────────────────────────────────────────────
|
||||
|
||||
const fetchListing = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/mobile/api/souq/listings/${id}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to load listing (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setListing(data.listing);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to load listing";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchListing();
|
||||
}
|
||||
}, [id, fetchListing]);
|
||||
|
||||
// ── Auto-select first package ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (listing?.packages && listing.packages.length > 0 && !selectedPackage) {
|
||||
setSelectedPackage(listing.packages[0]);
|
||||
}
|
||||
}, [listing, selectedPackage]);
|
||||
|
||||
// ── Place order ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!token) {
|
||||
router.push("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPackage || !listing) return;
|
||||
|
||||
setOrdering(true);
|
||||
setOrderError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/mobile/api/souq/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
listingId: listing.id,
|
||||
packageName: selectedPackage.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to place order");
|
||||
}
|
||||
|
||||
setOrderSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push("/souq/orders");
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to place order";
|
||||
setOrderError(message);
|
||||
setTimeout(() => setOrderError(null), 5000);
|
||||
} finally {
|
||||
setOrdering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Loading (auth check) ────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// ── Data loading state ──────────────────────────────────────────────────
|
||||
|
||||
if (loadingData) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading listing...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error state ─────────────────────────────────────────────────────────
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f]">
|
||||
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-white">Listing</h1>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<ErrorFeedback
|
||||
error={error}
|
||||
kind="network"
|
||||
onRetry={fetchListing}
|
||||
context="listing detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Not found state ──────────────────────────────────────────────────────
|
||||
|
||||
if (!listing) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f]">
|
||||
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-white">Listing</h1>
|
||||
</div>
|
||||
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-gray-400">Listing not found</p>
|
||||
<button
|
||||
onClick={() => router.push("/souq")}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Back to Souq
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Derived data ────────────────────────────────────────────────────────
|
||||
|
||||
const packages = listing.packages || [];
|
||||
const firstImage = listing.images?.[0] || null;
|
||||
const categoryEmoji = CATEGORY_EMOJI[listing.category] || CATEGORY_EMOJI.default;
|
||||
const categoryColor = CATEGORY_COLORS[listing.category] || CATEGORY_COLORS.default;
|
||||
const totalPrice = selectedPackage
|
||||
? Math.round(selectedPackage.price * 1.015)
|
||||
: 0;
|
||||
const sellerInitials = getInitials(listing.seller.name);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-36">
|
||||
{/* ── Header ──────────────────────────────────────────────────────── */}
|
||||
<div className="sticky top-0 z-20 bg-[#0a0a0f]/90 backdrop-blur-lg border-b border-gray-800/40">
|
||||
<div className="flex items-center gap-3 px-4 h-12">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-white truncate">
|
||||
{listing.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ─────────────────────────────────────────────────────── */}
|
||||
<div className="space-y-5 animate-fade-in px-4 pt-4">
|
||||
{/* ── Image Area ────────────────────────────────────────────────── */}
|
||||
{firstImage ? (
|
||||
<div className="rounded-2xl overflow-hidden bg-[#111118] border border-gray-800/60">
|
||||
<img
|
||||
src={firstImage}
|
||||
alt={listing.title}
|
||||
className="w-full aspect-[3/2] object-cover"
|
||||
onError={(e) => {
|
||||
// Hide broken image, show fallback
|
||||
(e.target as HTMLElement).style.display = "none";
|
||||
const fallback = (e.target as HTMLElement)
|
||||
.nextElementSibling as HTMLElement | null;
|
||||
if (fallback) fallback.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`hidden aspect-[3/2] bg-gradient-to-br ${categoryColor} flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-6xl">{categoryEmoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded-2xl bg-gradient-to-br ${categoryColor} border border-gray-800/60 aspect-[3/2] flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-6xl">{categoryEmoji}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Title & Meta ───────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white leading-tight mb-2">
|
||||
{listing.title}
|
||||
</h2>
|
||||
|
||||
{/* Category badge */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs bg-[#D4AF37]/10 border border-[#D4AF37]/20 text-[#D4AF37] rounded-full px-3 py-1">
|
||||
{listing.category}
|
||||
</span>
|
||||
{listing.subcategory && (
|
||||
<span className="text-xs text-gray-500">{listing.subcategory}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rating row */}
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star size={14} className="text-[#D4AF37] fill-[#D4AF37]" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{listing.rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
({listing.reviewCount} {listing.reviewCount === 1 ? "review" : "reviews"})
|
||||
</span>
|
||||
<span className="text-gray-700">·</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{listing.salesCount} {listing.salesCount === 1 ? "sale" : "sales"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Seller row */}
|
||||
<button
|
||||
onClick={() => router.push(`/souq/seller/${listing.seller.id}`)}
|
||||
className="w-full flex items-center gap-3 py-3 px-4 bg-[#111118] border border-gray-800/60 rounded-xl active:scale-[0.99] transition"
|
||||
>
|
||||
{listing.seller.avatar ? (
|
||||
<img
|
||||
src={listing.seller.avatar}
|
||||
alt={listing.seller.name}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-sm font-bold text-[#D4AF37]">
|
||||
{sellerInitials}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm font-semibold text-white truncate">
|
||||
{listing.seller.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Seller</p>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Description ────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-2">About This Listing</h3>
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{listing.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Packages ────────────────────────────────────────────────────── */}
|
||||
{packages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-3">
|
||||
Select a Package
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{packages.map((pkg) => {
|
||||
const isSelected = selectedPackage?.name === pkg.name;
|
||||
const feeAmount = Math.round(pkg.price * 0.015);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pkg.name}
|
||||
onClick={() => setSelectedPackage(pkg)}
|
||||
className={`w-full text-left bg-[#111118] border rounded-2xl p-4 transition-all active:scale-[0.98] ${
|
||||
isSelected
|
||||
? "border-[#D4AF37] ring-1 ring-[#D4AF37]/30"
|
||||
: "border-gray-800/60 hover:border-gray-700/60"
|
||||
}`}
|
||||
>
|
||||
{/* Package header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isSelected ? "bg-[#D4AF37]" : "bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-bold text-white">
|
||||
{pkg.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-[#D4AF37]">
|
||||
{formatFlh(pkg.price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-gray-400 mb-3 leading-relaxed">
|
||||
{pkg.description}
|
||||
</p>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span>{pkg.deliveryDays} {pkg.deliveryDays === 1 ? "day" : "days"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<RefreshCw size={12} />
|
||||
<span>{pkg.revisions} {pkg.revisions === 1 ? "revision" : "revisions"}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] text-[#D4AF37] ml-auto">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fee breakdown (only when selected) */}
|
||||
{isSelected && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-800/40 text-xs text-gray-500 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Package price</span>
|
||||
<span>{formatFlh(pkg.price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Service fee (1.5%)</span>
|
||||
<span>{formatFlh(feeAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Seller Info Section ─────────────────────────────────────────── */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<User size={14} className="text-[#D4AF37]" />
|
||||
About the Seller
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{listing.seller.avatar ? (
|
||||
<img
|
||||
src={listing.seller.avatar}
|
||||
alt={listing.seller.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-base font-bold text-[#D4AF37]">
|
||||
{sellerInitials}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">{listing.seller.name}</p>
|
||||
<p className="text-xs text-gray-500">Member since{" "}
|
||||
{new Date(listing.createdAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seller stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1 text-[#D4AF37] mb-0.5">
|
||||
<Star size={12} className="fill-[#D4AF37]" />
|
||||
<span className="text-sm font-bold text-white">{listing.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">Rating</p>
|
||||
</div>
|
||||
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-sm font-bold text-white">{listing.reviewCount}</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{listing.reviewCount === 1 ? "Review" : "Reviews"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-sm font-bold text-white">{listing.salesCount}</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{listing.salesCount === 1 ? "Sale" : "Sales"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push(`/souq/seller/${listing.seller.id}`)}
|
||||
className="w-full mt-3 py-2.5 rounded-xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 text-xs font-semibold text-[#D4AF37] active:scale-[0.98] transition"
|
||||
>
|
||||
View Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Reviews ─────────────────────────────────────────────────────── */}
|
||||
{listing.reviews && listing.reviews.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<MessageCircle size={14} className="text-[#D4AF37]" />
|
||||
Reviews ({listing.reviews.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{listing.reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4"
|
||||
>
|
||||
{/* Reviewer info */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
{review.reviewer.avatar ? (
|
||||
<img
|
||||
src={review.reviewer.avatar}
|
||||
alt={review.reviewer.name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-800/80 flex items-center justify-center text-[10px] font-bold text-gray-400">
|
||||
{getInitials(review.reviewer.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-white truncate">
|
||||
{review.reviewer.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
size={10}
|
||||
className={
|
||||
i < review.rating
|
||||
? "text-[#D4AF37] fill-[#D4AF37]"
|
||||
: "text-gray-700"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{timeAgo(review.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review title */}
|
||||
{review.title && (
|
||||
<p className="text-sm font-semibold text-white mb-1">
|
||||
{review.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Review text */}
|
||||
<p className="text-xs text-gray-300 leading-relaxed">
|
||||
{review.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
|
||||
{/* ── Fixed Bottom Order Bar ───────────────────────────────────────── */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60">
|
||||
{/* Safe area inset wrapper */}
|
||||
<div
|
||||
className="bg-[#111118] border-t border-gray-800/60 px-4 py-3"
|
||||
style={{ paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 56px)" }}
|
||||
>
|
||||
{/* Success toast */}
|
||||
{orderSuccess && (
|
||||
<div className="absolute bottom-full left-0 right-0 mx-4 mb-2 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400 animate-fade-in">
|
||||
<Check size={16} />
|
||||
Order placed! Redirecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error toast */}
|
||||
{orderError && (
|
||||
<div className="absolute bottom-full left-0 right-0 mx-4 mb-2">
|
||||
<ErrorFeedback
|
||||
error={orderError}
|
||||
kind="default"
|
||||
onRetry={() => setOrderError(null)}
|
||||
context="order"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Price info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedPackage ? selectedPackage.name : "No package selected"}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-[#D4AF37]">
|
||||
{selectedPackage ? formatFlh(totalPrice) : "—"}
|
||||
</p>
|
||||
{selectedPackage && (
|
||||
<p className="text-[10px] text-gray-600">
|
||||
{formatFlh(selectedPackage.price)} + 1.5% fee
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={ordering || !selectedPackage}
|
||||
className="px-6 py-3.5 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm transition active:bg-[#c5a233] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 min-h-[48px]"
|
||||
>
|
||||
{ordering ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Placing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingCart size={16} />
|
||||
Continue
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user