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
+163 -65
View File
@@ -1,6 +1,6 @@
"use client";
import { useAuth } from "@/lib/AuthContext";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
@@ -10,20 +10,34 @@ import {
Wallet,
User,
Clock,
Compass,
BookOpen,
MapPin,
Sparkles,
Star,
X,
Home,
GraduationCap,
} from "lucide-react";
const navItems = [
{ href: "/nur", label: "Nur", icon: Bot },
{ href: "/souq", label: "Souq", icon: ShoppingBag },
{ href: "/", label: "Home", icon: null, isHome: true },
{ href: "/prayer", label: "Prayer", icon: Clock },
{ href: "/forum", label: "Forum", icon: MessageCircle },
{ href: "/wallet", label: "Wallet", icon: Wallet },
{ href: "/profile", label: "Profile", icon: User },
// All destinations except Home (Home stays in center nav)
const overflowItems = [
{ href: "/nur", label: "Nur AI", icon: Bot, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/prayer", label: "Prayer", icon: Clock, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/forum", label: "Forum", icon: MessageCircle, color: "text-sky-400", bg: "bg-sky-900/20" },
{ href: "/wallet", label: "Wallet", icon: Wallet, color: "text-blue-400", bg: "bg-blue-900/20" },
{ href: "/profile", label: "Profile", icon: User, color: "text-purple-400", bg: "bg-purple-900/20" },
{ href: "/dhikr", label: "Dhikr", icon: BookOpen, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/qibla", label: "Qibla", icon: Compass, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/souq", label: "Souq", icon: ShoppingBag, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/halal-monitor", label: "Halal Monitor", icon: MapPin, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/learn", label: "Learn", icon: GraduationCap, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/upgrade", label: "Upgrade", icon: Sparkles, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
];
export default function BottomNav() {
const pathname = usePathname();
const [showSheet, setShowSheet] = useState(false);
// Hide bottom nav on auth pages
if (pathname.startsWith("/auth")) {
@@ -31,65 +45,149 @@ export default function BottomNav() {
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex items-stretch h-14 max-w-lg mx-auto">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href + "/"));
const Icon = item.icon;
const isHome = item.isHome;
<>
{/* Overflow Bottom Sheet */}
{showSheet && (
<div className="fixed inset-0 z-[70] flex flex-col justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowSheet(false)}
/>
{/* Sheet */}
<div
className="relative w-full bg-[#111118] border-t border-gray-800/60 rounded-t-2xl animate-fade-in max-h-[80dvh] overflow-y-auto"
style={{ paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" }}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-gray-700" />
</div>
if (isHome) {
return (
<Link
key={item.href}
href="/"
className="flex-1 flex items-center justify-center"
{/* Header */}
<div className="flex items-center justify-between px-5 py-2">
<h2 className="text-sm font-semibold text-gray-400">Navigate</h2>
<button
onClick={() => setShowSheet(false)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<div
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
pathname === "/"
? "bg-[#D4AF37] text-[#0a0a0f] shadow-[0_0_12px_rgba(212,175,55,0.3)]"
: "bg-gray-800/60 text-gray-500"
}`}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
</Link>
);
}
<X size={16} className="text-gray-500" />
</button>
</div>
return (
<Link
key={item.href}
href={item.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 active:opacity-60 transition-opacity relative"
{/* Grid */}
<div className="grid grid-cols-3 gap-2 px-4 py-3">
{overflowItems.map((item) => {
const isActive = !item.external && (pathname === item.href || (item.href !== "/" && pathname?.startsWith(item.href + "/")));
const Icon = item.icon;
if (item.external) {
return (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setShowSheet(false)}
className="flex flex-col items-center gap-1.5 py-3 rounded-xl active:scale-95 transition-all active:bg-gray-800/40"
>
<div className={`w-12 h-12 rounded-2xl ${item.bg} flex items-center justify-center ${item.color}`}>
<Icon size={22} />
</div>
<span className="text-xs text-gray-500 font-medium truncate max-w-[80px] text-center">
{item.label}
</span>
</a>
);
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setShowSheet(false)}
className={`flex flex-col items-center gap-1.5 py-3 rounded-xl active:scale-95 transition-all ${
isActive ? "bg-gray-800/40" : "active:bg-gray-800/40"
}`}
>
<div className={`w-12 h-12 rounded-2xl ${isActive ? `${item.bg} ring-1 ring-white/10` : item.bg} flex items-center justify-center ${item.color}`}>
<Icon size={22} />
</div>
<span className={`text-xs font-medium truncate max-w-[80px] text-center ${
isActive ? "text-white" : "text-gray-500"
}`}>
{item.label}
</span>
</Link>
);
})}
</div>
</div>
</div>
)}
{/* Bottom Nav */}
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex items-stretch h-14 max-w-lg mx-auto">
{/* Left three-dots */}
<button
onClick={() => setShowSheet(true)}
className="flex-1 flex items-center justify-center active:opacity-60 transition-opacity"
aria-label="Open navigation menu"
>
<div className="w-9 h-9 rounded-full bg-gray-800/50 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
</svg>
</div>
</button>
{/* Spacer */}
<div className="flex-1" />
{/* Home — center */}
<Link
href="/"
className="flex items-center justify-center"
style={{ flex: "0 0 auto" }}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
pathname === "/"
? "bg-[#D4AF37] text-[#0a0a0f] shadow-[0_0_16px_rgba(212,175,55,0.4)]"
: "bg-gray-800/60 text-gray-500"
}`}
>
{Icon && (
<Icon
size={22}
className={isActive ? "text-[#D4AF37]" : "text-gray-600"}
/>
)}
<span
className={`text-xs font-medium ${
isActive ? "text-[#D4AF37]" : "text-gray-600"
}`}
>
{item.label}
</span>
{/* Active indicator */}
{isActive && (
<div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-5 h-0.5 rounded-full bg-[#D4AF37]" />
)}
</Link>
);
})}
</div>
</nav>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
</Link>
{/* Spacer */}
<div className="flex-1" />
{/* Right three-dots */}
<button
onClick={() => setShowSheet(true)}
className="flex-1 flex items-center justify-center active:opacity-60 transition-opacity"
aria-label="Open navigation menu"
>
<div className="w-9 h-9 rounded-full bg-gray-800/50 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
</svg>
</div>
</button>
</div>
</nav>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export default function PWARegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/mobile/sw.js", { scope: "/mobile" })
.then((reg) => {
console.log("[PWA] SW registered:", reg.scope);
})
.catch((err) => console.warn("[PWA] SW registration failed:", err));
// Listen for beforeinstallprompt (Android Chrome)
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
(window as Record<string, unknown>).__deferredPrompt = e;
});
window.addEventListener("appinstalled", () => {
console.log("[PWA] App installed");
(window as Record<string, unknown>).__deferredPrompt = null;
});
}
}, []);
return null;
}