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
+523
View File
@@ -0,0 +1,523 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Plus,
X,
Loader2,
Check,
Package,
Image as ImageIcon,
Tags,
Clock,
} from "lucide-react";
/* ------------------------------------------------------------------ */
/* Categories */
/* ------------------------------------------------------------------ */
const CATEGORIES = [
{ id: "graphics-design", name: "Graphics & Design", icon: "🎨" },
{ id: "digital-marketing", name: "Digital Marketing", icon: "📈" },
{ id: "writing-translation", name: "Writing & Translation", icon: "✍️" },
{ id: "video-animation", name: "Video & Animation", icon: "🎬" },
{ id: "music-audio", name: "Music & Audio", icon: "🎵" },
{ id: "programming-tech", name: "Programming & Tech", icon: "💻" },
{ id: "ai-services", name: "AI Services", icon: "🤖" },
{ id: "consulting", name: "Consulting", icon: "💼" },
{ id: "data", name: "Data", icon: "📊" },
{ id: "business", name: "Business", icon: "🏢" },
{ id: "personal-growth", name: "Personal Growth", icon: "🌱" },
{ id: "photography", name: "Photography", icon: "📷" },
{ id: "finance", name: "Finance", icon: "💰" },
];
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface PackageField {
id: string;
name: string;
description: string;
price: string;
deliveryDays: string;
revisions: string;
}
interface FormErrors {
title?: string;
category?: string;
description?: string;
priceFlh?: string;
}
/* ------------------------------------------------------------------ */
/* Page Component */
/* ------------------------------------------------------------------ */
export default function SouqCreatePage() {
const { user, token, loading } = useAuth();
const router = useRouter();
/* ---- form fields ---- */
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [priceFlh, setPriceFlh] = useState("");
const [deliveryDays, setDeliveryDays] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [imageUrl, setImageUrl] = useState("");
/* ---- packages ---- */
const [packages, setPackages] = useState<PackageField[]>([]);
/* ---- ui state ---- */
const [errors, setErrors] = useState<FormErrors>({});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/* ---- auth guard ---- */
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
}
}, [loading, token, router]);
/* ---- auto-redirect on success ---- */
useEffect(() => {
if (success) {
const timer = setTimeout(() => router.push("/souq"), 2000);
return () => clearTimeout(timer);
}
}, [success, router]);
/* ---- helpers ---- */
const generateId = useCallback(
() => Math.random().toString(36).substring(2, 10),
[]
);
const addPackage = () => {
setPackages((prev) => [
...prev,
{
id: generateId(),
name: "",
description: "",
price: "",
deliveryDays: "",
revisions: "",
},
]);
};
const removePackage = (id: string) => {
setPackages((prev) => prev.filter((p) => p.id !== id));
};
const updatePackage = (id: string, field: keyof PackageField, value: string) => {
setPackages((prev) =>
prev.map((p) => (p.id === id ? { ...p, [field]: value } : p))
);
};
/* ---- validation ---- */
const validate = (): boolean => {
const errs: FormErrors = {};
if (!title.trim()) errs.title = "Title is required";
if (!category) errs.category = "Please select a category";
if (!description.trim()) errs.description = "Description is required";
if (!priceFlh || isNaN(Number(priceFlh)) || Number(priceFlh) < 0) {
errs.priceFlh = "Enter a valid price (0 or more)";
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
/* ---- submit ---- */
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
setErrorMessage("");
try {
// Parse tags from comma-separated string
const tagsArr = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
// Build images array
const imagesArr = imageUrl.trim() ? [imageUrl.trim()] : [];
// Build packages array (only if user added any)
const packagesArr = packages
.filter((p) => p.name.trim())
.map((p) => ({
name: p.name.trim(),
description: p.description.trim(),
price: Number(p.price) || 0,
deliveryDays: p.deliveryDays ? Number(p.deliveryDays) : undefined,
revisions: p.revisions ? Number(p.revisions) : undefined,
}));
const body: Record<string, unknown> = {
title: title.trim(),
description: description.trim(),
category,
priceFlh: Number(priceFlh),
deliveryDays: deliveryDays ? Number(deliveryDays) : undefined,
tags: tagsArr.length > 0 ? tagsArr : undefined,
images: imagesArr.length > 0 ? imagesArr : undefined,
packages: packagesArr.length > 0 ? packagesArr : undefined,
};
const res = await fetch("/mobile/api/souq/listings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create listing");
}
setSuccess(true);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setErrorMessage(msg);
} finally {
setSubmitting(false);
}
};
/* ---- early returns ---- */
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;
/* ---- success state ---- */
if (success) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
<div className="w-16 h-16 rounded-full bg-emerald-900/30 border border-emerald-500/30 flex items-center justify-center mb-5">
<Check size={28} className="text-emerald-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Listing Created!</h2>
<p className="text-sm text-gray-500 text-center mb-2">
Your service has been published on Souq.
</p>
<p className="text-xs text-gray-600">Redirecting to Souq</p>
<div className="mt-6 w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<button
onClick={() => router.push("/souq")}
className="mt-6 text-xs text-[#D4AF37] underline underline-offset-2"
>
Go now
</button>
</div>
);
}
/* ---- main render ---- */
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-4 flex items-center gap-3">
<button
onClick={() => router.push("/souq")}
className="w-9 h-9 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center text-gray-400 hover:text-white transition shrink-0 active:scale-95"
>
<ArrowLeft size={18} />
</button>
<div>
<h1 className="text-xl font-bold text-white">Create Listing</h1>
<p className="text-xs text-gray-500 mt-0.5">Sell your service on Souq</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-4 space-y-5">
{/* Title */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 space-y-4">
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Title <span className="text-red-400">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. I will build a modern website"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
{errors.title && (
<p className="text-xs text-red-400 mt-1.5">{errors.title}</p>
)}
</div>
</div>
{/* Category */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-3 block">
Category <span className="text-red-400">*</span>
</label>
<div className="grid grid-cols-3 gap-2.5">
{CATEGORIES.map((cat) => {
const isActive = category === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setCategory(isActive ? "" : cat.id)}
className={`relative bg-[#0a0a0f] border rounded-xl p-3 text-center active:scale-95 transition min-h-[72px] ${
isActive
? "border-[#D4AF37]/50 ring-1 ring-[#D4AF37]/30"
: "border-gray-800/60 hover:border-gray-700/60"
}`}
>
<span className="text-lg block mb-1">{cat.icon}</span>
<p className="text-[10px] font-medium text-white leading-tight">
{cat.name}
</p>
{isActive && (
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[#D4AF37] flex items-center justify-center">
<Check size={10} className="text-black" />
</span>
)}
</button>
);
})}
</div>
{errors.category && (
<p className="text-xs text-red-400 mt-2">{errors.category}</p>
)}
</div>
{/* Description */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Description <span className="text-red-400">*</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your service in detail..."
rows={5}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition resize-none min-h-[120px]"
/>
{errors.description && (
<p className="text-xs text-red-400 mt-1.5">{errors.description}</p>
)}
</div>
{/* Price + Delivery Days */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 space-y-4">
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Price in FLH <span className="text-red-400">*</span>
</label>
<input
type="number"
value={priceFlh}
onChange={(e) => setPriceFlh(e.target.value)}
placeholder="0"
min={0}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
{errors.priceFlh && (
<p className="text-xs text-red-400 mt-1.5">{errors.priceFlh}</p>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<Clock size={12} /> Delivery Days <span className="text-gray-700">(optional)</span>
</label>
<input
type="number"
value={deliveryDays}
onChange={(e) => setDeliveryDays(e.target.value)}
placeholder="e.g. 7"
min={1}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
</div>
{/* Packages (optional) */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<label className="text-xs font-medium text-gray-500 flex items-center gap-1.5">
<Package size={12} /> Packages <span className="text-gray-700">(optional)</span>
</label>
<button
type="button"
onClick={addPackage}
className="flex items-center gap-1 text-xs font-medium text-[#D4AF37] bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-xl px-3 py-1.5 hover:bg-[#D4AF37]/20 transition active:scale-95"
>
<Plus size={12} /> Add Package
</button>
</div>
{packages.length === 0 && (
<p className="text-xs text-gray-600 text-center py-4">
No packages yet. Offer multiple tiers to attract more buyers.
</p>
)}
<div className="space-y-3">
{packages.map((pkg, idx) => (
<div
key={pkg.id}
className="bg-[#0a0a0f] border border-gray-800/60 rounded-xl p-4 relative"
>
<div className="flex items-center justify-between mb-3">
<span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
Package {idx + 1}
</span>
<button
type="button"
onClick={() => removePackage(pkg.id)}
className="w-6 h-6 rounded-full bg-red-900/20 border border-red-800/30 flex items-center justify-center text-red-400 hover:bg-red-900/40 transition active:scale-90"
>
<X size={12} />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<input
type="text"
value={pkg.name}
onChange={(e) => updatePackage(pkg.id, "name", e.target.value)}
placeholder="Package name (e.g. Basic, Standard, Premium)"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={pkg.description}
onChange={(e) =>
updatePackage(pkg.id, "description", e.target.value)
}
placeholder="Brief description of this package"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<input
type="number"
value={pkg.price}
onChange={(e) => updatePackage(pkg.id, "price", e.target.value)}
placeholder="Price (FLH)"
min={0}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<input
type="number"
value={pkg.deliveryDays}
onChange={(e) =>
updatePackage(pkg.id, "deliveryDays", e.target.value)
}
placeholder="Delivery (days)"
min={1}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div className="col-span-2">
<input
type="number"
value={pkg.revisions}
onChange={(e) =>
updatePackage(pkg.id, "revisions", e.target.value)
}
placeholder="Revisions included"
min={0}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Tags */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<Tags size={12} /> Tags <span className="text-gray-700">(optional, comma-separated)</span>
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="e.g. web development, react, responsive"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
{/* Image URL */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<ImageIcon size={12} /> Image URL <span className="text-gray-700">(optional)</span>
</label>
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
{/* Error message */}
{errorMessage && (
<div className="bg-red-900/20 border border-red-800/30 rounded-2xl p-4 flex items-start gap-3">
<X size={16} className="text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-300">{errorMessage}</p>
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={submitting}
className="w-full bg-[#D4AF37] text-black text-sm font-semibold rounded-2xl py-4 hover:bg-[#D4AF37]/90 transition active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 flex items-center justify-center gap-2 min-h-[56px]"
>
{submitting ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating
</>
) : (
"Publish Listing"
)}
</button>
<div className="h-4" />
</form>
</div>
);
}