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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user