cfff74e2db
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
720 lines
28 KiB
TypeScript
720 lines
28 KiB
TypeScript
"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>
|
|
);
|
|
}
|