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:
root
2026-06-24 07:02:03 +02:00
parent 913fa94fb9
commit cfff74e2db
81 changed files with 12132 additions and 2101 deletions
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
ShoppingBag,
Package,
User,
Clock,
ChevronRight,
Loader2,
Inbox,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface OrderListing {
id: string;
title: string;
category: string;
priceFlh: number;
}
interface OrderParty {
id: string;
name: string;
email: string;
avatar?: string | null;
}
interface Order {
id: string;
listingId: string;
buyerId: string;
sellerId: string;
status: string;
amountFlh: number;
platformFee: number;
packageName?: string | null;
createdAt: string;
listing: OrderListing;
buyer: OrderParty;
seller: OrderParty;
}
// ── Status helpers ──
const STATUS_COLORS: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: "bg-gray-800/60", text: "text-gray-400", label: "Pending" },
paid: { bg: "bg-blue-900/20 border border-blue-700/30", text: "text-blue-400", label: "Paid" },
in_progress: { bg: "bg-amber-900/20 border border-amber-700/30", text: "text-amber-400", label: "In Progress" },
delivered: { bg: "bg-purple-900/20 border border-purple-700/30", text: "text-purple-400", label: "Delivered" },
completed: { bg: "bg-emerald-900/20 border border-emerald-700/30", text: "text-emerald-400", label: "Completed" },
disputed: { bg: "bg-red-900/20 border border-red-700/30", text: "text-red-400", label: "Disputed" },
cancelled: { bg: "bg-gray-800/40 border border-gray-700/30", text: "text-gray-500", label: "Cancelled" },
};
function getStatusStyle(status: string) {
return STATUS_COLORS[status] || STATUS_COLORS.pending;
}
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("en-MY", {
year: "numeric",
month: "short",
day: "numeric",
});
}
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 formatDate(dateStr);
}
export default function OrdersPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<"buyer" | "seller">("buyer");
const [orders, setOrders] = useState<Order[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Auth redirect ──
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
}
}, [loading, token, router]);
// ── Fetch orders ──
const fetchOrders = useCallback(async () => {
if (!token) return;
setLoadingOrders(true);
setError(null);
try {
const res = await fetch(`/mobile/api/souq/orders?role=${tab}&limit=50`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (res.ok) {
setOrders(data.orders || []);
} else {
setError(data.error || "Failed to load orders");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingOrders(false);
}
}, [token, tab]);
useEffect(() => {
if (token) fetchOrders();
}, [token, fetchOrders]);
// ── Loading (auth) ──
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;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2">
<h1 className="text-xl font-bold text-white">My Orders</h1>
</div>
{/* Tabs */}
<div className="px-4 pb-4">
<div className="flex bg-[#111118] border border-gray-800/60 rounded-xl p-1">
<button
onClick={() => setTab("buyer")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-medium transition ${
tab === "buyer"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "text-gray-500 active:text-gray-300"
}`}
>
<ShoppingBag size={14} />
As Buyer
</button>
<button
onClick={() => setTab("seller")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-medium transition ${
tab === "seller"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "text-gray-500 active:text-gray-300"
}`}
>
<Package size={14} />
As Seller
</button>
</div>
</div>
{/* Content */}
<div className="px-4 space-y-3">
{loadingOrders ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading orders...</p>
</div>
) : error ? (
<ErrorFeedback error={error} kind="network" onRetry={fetchOrders} context="orders" />
) : orders.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-gray-800/40 flex items-center justify-center mx-auto mb-4">
<Inbox size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-white mb-1">
No orders yet
</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
{tab === "buyer"
? "You haven't placed any orders yet. Browse the marketplace to get started."
: "No one has ordered from you yet. List your services to start selling."}
</p>
<button
onClick={() => router.push("/souq")}
className="mt-4 inline-flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 text-xs font-medium text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
>
<ShoppingBag size={12} />
{tab === "buyer" ? "Browse Marketplace" : "Manage Listings"}
</button>
</div>
) : (
orders.map((order) => {
const sc = getStatusStyle(order.status);
return (
<button
key={order.id}
onClick={() => router.push(`/souq/orders/${order.id}`)}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:bg-[#1a1a24] transition animate-fade-in"
>
{/* Listing title + status */}
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2 flex-1">
{order.listing.title}
</h3>
<span
className={`shrink-0 text-[10px] font-medium px-2.5 py-1 rounded-full ${sc.bg} ${sc.text}`}
>
{sc.label}
</span>
</div>
{/* Buyer/Seller + Amount */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<User size={11} className="text-gray-600 shrink-0" />
<span className="text-xs text-gray-400 truncate">
{tab === "buyer" ? order.seller.name : order.buyer.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-bold text-[#D4AF37]">
{order.amountFlh.toLocaleString()} FLH
</span>
<ChevronRight size={14} className="text-gray-600" />
</div>
</div>
{/* Date */}
<div className="flex items-center gap-1 mt-1.5">
<Clock size={10} className="text-gray-600" />
<span className="text-[10px] text-gray-600">
{timeAgo(order.createdAt)}
</span>
</div>
</button>
);
})
)}
</div>
</div>
);
}