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
524 lines
21 KiB
TypeScript
524 lines
21 KiB
TypeScript
"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>
|
|
);
|
|
}
|