initial commit: rocket.new export of broadcastbeat
This commit is contained in:
157
src/app/marketplace/account/billing/page.tsx
Normal file
157
src/app/marketplace/account/billing/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing | Broadcast Beat Marketplace',
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-3xl mx-auto px-4 py-24">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">
|
||||
Marketplace
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href="/marketplace/account" className="hover:text-[#CC0000]">
|
||||
Account
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">Billing</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide mb-4">Billing & Subscription</h1>
|
||||
|
||||
{/* Coming soon banner */}
|
||||
<div className="bg-gradient-to-r from-[#CC0000]/20 to-transparent border border-[#CC0000]/30 rounded-2xl p-8 mb-10 text-center">
|
||||
<div className="text-4xl mb-4">💳</div>
|
||||
<h2 className="text-2xl font-bold mb-3">Payments Coming Soon</h2>
|
||||
<p className="text-gray-400 max-w-md mx-auto leading-relaxed">
|
||||
We're finalizing our payment integration. Pro and Featured subscriptions will be
|
||||
available shortly. Your free profile is already live and ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan comparison */}
|
||||
<h2 className="text-xl font-bold mb-6">Available Plans</h2>
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-10">
|
||||
{[
|
||||
{
|
||||
name: 'Free',
|
||||
price: '$0',
|
||||
period: 'forever',
|
||||
current: true,
|
||||
features: [
|
||||
'Basic profile',
|
||||
'Appear in search',
|
||||
'3 gig responses/month',
|
||||
'Apply to unlimited jobs',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$9.99',
|
||||
period: '/month',
|
||||
current: false,
|
||||
highlight: true,
|
||||
features: [
|
||||
'Priority placement',
|
||||
'Unlimited gig responses',
|
||||
'Post jobs & gigs',
|
||||
'Profile analytics',
|
||||
'Pro Member badge',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Studio / Vendor',
|
||||
price: '$29.99',
|
||||
period: '/month',
|
||||
current: false,
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'Company profile',
|
||||
'Up to 5 staff profiles',
|
||||
'Featured placement',
|
||||
'Priority lead routing',
|
||||
],
|
||||
},
|
||||
]?.map((plan) => (
|
||||
<div
|
||||
key={plan?.name}
|
||||
className={`rounded-xl p-6 border relative ${
|
||||
plan?.highlight
|
||||
? 'bg-[#CC0000]/10 border-[#CC0000]'
|
||||
: 'bg-[#1a1a1a] border-white/10'
|
||||
}`}
|
||||
>
|
||||
{plan?.highlight && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#CC0000] text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
{plan?.current && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
CURRENT PLAN
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-lg mb-1">{plan?.name}</div>
|
||||
<div className="text-3xl font-black text-[#CC0000] mb-4">
|
||||
{plan?.price}
|
||||
<span className="text-sm text-gray-400 font-normal">{plan?.period}</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan?.features?.map((f) => (
|
||||
<li key={f} className="text-sm text-gray-300 flex items-center gap-2">
|
||||
<span className="text-[#CC0000]">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
disabled
|
||||
className="w-full py-2.5 rounded-lg font-bold text-sm bg-white/5 text-gray-500 cursor-not-allowed border border-white/10"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Job post pricing */}
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-6 mb-6">
|
||||
<h3 className="font-bold text-lg mb-4">One-Time Listings (Coming Soon)</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-gray-400">Standard Job Post</span>
|
||||
<span className="font-bold">$49</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-gray-400">Featured Job Post</span>
|
||||
<span className="font-bold">$99</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-gray-400">Job Pack (5 posts)</span>
|
||||
<span className="font-bold">$199</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-white/5">
|
||||
<span className="text-gray-400">Job Pack (10 posts)</span>
|
||||
<span className="font-bold">$349</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/marketplace/account"
|
||||
className="text-[#CC0000] hover:underline text-sm"
|
||||
>
|
||||
← Back to Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
561
src/app/marketplace/account/page.tsx
Normal file
561
src/app/marketplace/account/page.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const GIG_TYPES = [
|
||||
'Commercial',
|
||||
'Documentary',
|
||||
'Corporate Video',
|
||||
'Live Event',
|
||||
'News / ENG',
|
||||
'Sports',
|
||||
'Music Video',
|
||||
'Film',
|
||||
'Streaming / OTT',
|
||||
'Social Media',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const JOB_TYPES = [
|
||||
{ value: 'full_time', label: 'Full-Time' },
|
||||
{ value: 'part_time', label: 'Part-Time' },
|
||||
{ value: 'contract', label: 'Contract' },
|
||||
{ value: 'temp', label: 'Temporary' },
|
||||
];
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [details, setDetails] = useState<any>(null);
|
||||
const [profileLoading, setProfileLoading] = useState(true);
|
||||
|
||||
// Job form
|
||||
const [showJobForm, setShowJobForm] = useState(false);
|
||||
const [jobTitle, setJobTitle] = useState('');
|
||||
const [jobCompany, setJobCompany] = useState('');
|
||||
const [jobLocation, setJobLocation] = useState('');
|
||||
const [jobType, setJobType] = useState('full_time');
|
||||
const [jobSalaryMin, setJobSalaryMin] = useState('');
|
||||
const [jobSalaryMax, setJobSalaryMax] = useState('');
|
||||
const [jobDescription, setJobDescription] = useState('');
|
||||
const [jobApplyUrl, setJobApplyUrl] = useState('');
|
||||
const [jobApplyEmail, setJobApplyEmail] = useState('');
|
||||
const [jobSubmitting, setJobSubmitting] = useState(false);
|
||||
const [jobMsg, setJobMsg] = useState('');
|
||||
|
||||
// Gig form
|
||||
const [showGigForm, setShowGigForm] = useState(false);
|
||||
const [gigTitle, setGigTitle] = useState('');
|
||||
const [gigCompany, setGigCompany] = useState('');
|
||||
const [gigLocation, setGigLocation] = useState('');
|
||||
const [gigType, setGigType] = useState('');
|
||||
const [gigRate, setGigRate] = useState('');
|
||||
const [gigStartDate, setGigStartDate] = useState('');
|
||||
const [gigEndDate, setGigEndDate] = useState('');
|
||||
const [gigDescription, setGigDescription] = useState('');
|
||||
const [gigContact, setGigContact] = useState('');
|
||||
const [gigSubmitting, setGigSubmitting] = useState(false);
|
||||
const [gigMsg, setGigMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.replace('/marketplace/join');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const fetchProfile = async () => {
|
||||
setProfileLoading(true);
|
||||
const { data: p } = await supabase
|
||||
.from('marketplace_profiles')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
setProfile(p);
|
||||
|
||||
if (p) {
|
||||
if (p.profile_type === 'crew') {
|
||||
const { data: d } = await supabase
|
||||
.from('marketplace_crew_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
setDetails(d);
|
||||
} else {
|
||||
const { data: d } = await supabase
|
||||
.from('marketplace_vendor_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
setDetails(d);
|
||||
}
|
||||
}
|
||||
setProfileLoading(false);
|
||||
};
|
||||
fetchProfile();
|
||||
}, [user, supabase]);
|
||||
|
||||
const hasActiveSubscription =
|
||||
profile?.subscription_tier === 'pro' || profile?.subscription_tier === 'featured';
|
||||
|
||||
const handlePostJob = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profile) return;
|
||||
setJobSubmitting(true);
|
||||
setJobMsg('');
|
||||
try {
|
||||
const { error } = await supabase.from('marketplace_jobs').insert({
|
||||
profile_id: profile.id,
|
||||
title: jobTitle.trim(),
|
||||
company: jobCompany.trim(),
|
||||
location: jobLocation.trim(),
|
||||
job_type: jobType,
|
||||
salary_min: jobSalaryMin ? parseInt(jobSalaryMin) : null,
|
||||
salary_max: jobSalaryMax ? parseInt(jobSalaryMax) : null,
|
||||
description: jobDescription.trim(),
|
||||
apply_url: jobApplyUrl.trim() || null,
|
||||
apply_email: jobApplyEmail.trim() || null,
|
||||
status: 'active',
|
||||
});
|
||||
if (error) throw error;
|
||||
setJobMsg('✅ Job posted successfully!');
|
||||
setShowJobForm(false);
|
||||
setJobTitle(''); setJobCompany(''); setJobLocation('');
|
||||
setJobDescription(''); setJobApplyUrl(''); setJobApplyEmail('');
|
||||
} catch (err: any) {
|
||||
setJobMsg(`❌ ${err.message}`);
|
||||
} finally {
|
||||
setJobSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostGig = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profile) return;
|
||||
setGigSubmitting(true);
|
||||
setGigMsg('');
|
||||
try {
|
||||
const { error } = await supabase.from('marketplace_gigs').insert({
|
||||
profile_id: profile.id,
|
||||
title: gigTitle.trim(),
|
||||
company: gigCompany.trim(),
|
||||
location: gigLocation.trim(),
|
||||
gig_type: gigType,
|
||||
rate: gigRate.trim() || null,
|
||||
start_date: gigStartDate || null,
|
||||
end_date: gigEndDate || null,
|
||||
description: gigDescription.trim(),
|
||||
contact_method: gigContact.trim() || null,
|
||||
status: 'active',
|
||||
});
|
||||
if (error) throw error;
|
||||
setGigMsg('✅ Gig posted successfully!');
|
||||
setShowGigForm(false);
|
||||
setGigTitle(''); setGigCompany(''); setGigLocation('');
|
||||
setGigDescription(''); setGigContact('');
|
||||
} catch (err: any) {
|
||||
setGigMsg(`❌ ${err.message}`);
|
||||
} finally {
|
||||
setGigSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || profileLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-4xl mx-auto px-4 py-24">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">
|
||||
Marketplace
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">My Account</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide mb-8">My Account</h1>
|
||||
|
||||
{/* No profile yet */}
|
||||
{!profile && (
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-8 text-center">
|
||||
<div className="text-4xl mb-4">👤</div>
|
||||
<h2 className="text-xl font-bold mb-3">No Profile Yet</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Create your marketplace profile to get discovered by clients and employers.
|
||||
</p>
|
||||
<Link
|
||||
href="/marketplace/join"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-6 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Create Profile
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile status card */}
|
||||
{profile && (
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-6">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Profile Status</div>
|
||||
<div
|
||||
className={`text-lg font-bold capitalize ${
|
||||
profile.status === 'active' ?'text-green-400'
|
||||
: profile.status === 'pending' ?'text-yellow-400' :'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{profile.status === 'active' ?'✓ Active'
|
||||
: profile.status === 'pending' ?'⏳ Pending Review' :'✗ Rejected'}
|
||||
</div>
|
||||
{profile.status === 'pending' && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Our team will review your profile within 24 hours.
|
||||
</p>
|
||||
)}
|
||||
{profile.status === 'rejected' && profile.rejection_reason && (
|
||||
<p className="text-xs text-red-400 mt-2">{profile.rejection_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-6">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Plan</div>
|
||||
<div className="text-lg font-bold capitalize text-white">
|
||||
{profile.subscription_tier === 'featured' ?'⭐ Featured'
|
||||
: profile.subscription_tier === 'pro' ?'🚀 Pro' :'🆓 Free'}
|
||||
</div>
|
||||
<Link
|
||||
href="/marketplace/account/billing"
|
||||
className="text-xs text-[#CC0000] hover:underline mt-2 block"
|
||||
>
|
||||
Manage billing →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-6">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Profile Type</div>
|
||||
<div className="text-lg font-bold capitalize text-white">
|
||||
{profile.profile_type === 'crew' ? '🎬 Crew Member' : '🏢 Vendor / Company'}
|
||||
</div>
|
||||
{profile.status === 'active' && (
|
||||
<Link
|
||||
href={`/marketplace/profile/${profile.slug}`}
|
||||
className="text-xs text-[#CC0000] hover:underline mt-2 block"
|
||||
>
|
||||
View public profile →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post Job / Gig section */}
|
||||
{profile && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold">Post Listings</h2>
|
||||
|
||||
{/* Subscription gate notice */}
|
||||
{!hasActiveSubscription && (
|
||||
<div className="bg-[#1a1a1a] border border-yellow-500/30 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-2xl">💳</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-400 mb-1">Pro Subscription Required</h3>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Posting jobs and gigs requires an active Pro or Featured subscription.
|
||||
Billing is coming soon — your profile is ready and waiting.
|
||||
</p>
|
||||
<Link
|
||||
href="/marketplace/account/billing"
|
||||
className="text-sm text-[#CC0000] hover:underline font-semibold"
|
||||
>
|
||||
View billing options →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job post form */}
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowJobForm((p) => !p)}
|
||||
disabled={!hasActiveSubscription}
|
||||
className="w-full flex items-center justify-between p-6 text-left disabled:opacity-50"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-lg">Post a Job</div>
|
||||
<div className="text-sm text-gray-400">Full-time, part-time, or contract</div>
|
||||
</div>
|
||||
<span className="text-gray-400">{showJobForm ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{showJobForm && (
|
||||
<form onSubmit={handlePostJob} className="px-6 pb-6 space-y-4 border-t border-white/10 pt-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-400 mb-1">Job Title *</label>
|
||||
<input
|
||||
value={jobTitle}
|
||||
onChange={(e) => setJobTitle(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="e.g. Senior Broadcast Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Company *</label>
|
||||
<input
|
||||
value={jobCompany}
|
||||
onChange={(e) => setJobCompany(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Location *</label>
|
||||
<input
|
||||
value={jobLocation}
|
||||
onChange={(e) => setJobLocation(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="City, State or Remote"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Job Type</label>
|
||||
<select
|
||||
value={jobType}
|
||||
onChange={(e) => setJobType(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
{JOB_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Salary Range</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={jobSalaryMin}
|
||||
onChange={(e) => setJobSalaryMin(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Min $"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={jobSalaryMax}
|
||||
onChange={(e) => setJobSalaryMax(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Max $"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-400 mb-1">Description *</label>
|
||||
<textarea
|
||||
value={jobDescription}
|
||||
onChange={(e) => setJobDescription(e.target.value)}
|
||||
required
|
||||
rows={5}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000] resize-none"
|
||||
placeholder="Job description, requirements, responsibilities..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Apply URL</label>
|
||||
<input
|
||||
value={jobApplyUrl}
|
||||
onChange={(e) => setJobApplyUrl(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Apply Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={jobApplyEmail}
|
||||
onChange={(e) => setJobApplyEmail(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="jobs@company.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{jobMsg && (
|
||||
<p
|
||||
className={`text-sm px-4 py-2 rounded-lg ${
|
||||
jobMsg.startsWith('✅')
|
||||
? 'bg-green-500/10 text-green-400' :'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{jobMsg}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={jobSubmitting}
|
||||
className="bg-[#CC0000] hover:bg-red-700 disabled:opacity-50 text-white font-bold px-6 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{jobSubmitting ? 'Posting...' : 'Post Job'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gig post form */}
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowGigForm((p) => !p)}
|
||||
disabled={!hasActiveSubscription}
|
||||
className="w-full flex items-center justify-between p-6 text-left disabled:opacity-50"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-lg">Post a Gig</div>
|
||||
<div className="text-sm text-gray-400">Short-term crew call or freelance request</div>
|
||||
</div>
|
||||
<span className="text-gray-400">{showGigForm ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{showGigForm && (
|
||||
<form onSubmit={handlePostGig} className="px-6 pb-6 space-y-4 border-t border-white/10 pt-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-400 mb-1">Gig Title *</label>
|
||||
<input
|
||||
value={gigTitle}
|
||||
onChange={(e) => setGigTitle(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="e.g. DP needed for 2-day commercial shoot"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Company / Production *</label>
|
||||
<input
|
||||
value={gigCompany}
|
||||
onChange={(e) => setGigCompany(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Location *</label>
|
||||
<input
|
||||
value={gigLocation}
|
||||
onChange={(e) => setGigLocation(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Project Type *</label>
|
||||
<select
|
||||
value={gigType}
|
||||
onChange={(e) => setGigType(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
{GIG_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Rate</label>
|
||||
<input
|
||||
value={gigRate}
|
||||
onChange={(e) => setGigRate(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="e.g. $750/day or negotiable"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={gigStartDate}
|
||||
onChange={(e) => setGigStartDate(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={gigEndDate}
|
||||
onChange={(e) => setGigEndDate(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-400 mb-1">Description *</label>
|
||||
<textarea
|
||||
value={gigDescription}
|
||||
onChange={(e) => setGigDescription(e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000] resize-none"
|
||||
placeholder="Project details, crew needed, requirements..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-400 mb-1">How to Respond</label>
|
||||
<input
|
||||
value={gigContact}
|
||||
onChange={(e) => setGigContact(e.target.value)}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Email address, phone, or instructions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{gigMsg && (
|
||||
<p
|
||||
className={`text-sm px-4 py-2 rounded-lg ${
|
||||
gigMsg.startsWith('✅')
|
||||
? 'bg-green-500/10 text-green-400' :'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{gigMsg}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={gigSubmitting}
|
||||
className="bg-[#CC0000] hover:bg-red-700 disabled:opacity-50 text-white font-bold px-6 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{gigSubmitting ? 'Posting...' : 'Post Gig'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
src/app/marketplace/admin/queue/page.tsx
Normal file
419
src/app/marketplace/admin/queue/page.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface PendingProfile {
|
||||
id: string;
|
||||
slug: string;
|
||||
profile_type: 'crew' | 'vendor';
|
||||
status: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
details: any;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
export default function AdminQueuePage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checkingAdmin, setCheckingAdmin] = useState(true);
|
||||
const [profiles, setProfiles] = useState<PendingProfile[]>([]);
|
||||
const [queueLoading, setQueueLoading] = useState(true);
|
||||
const [actionMsg, setActionMsg] = useState<Record<string, string>>({});
|
||||
const [rejectionReasons, setRejectionReasons] = useState<Record<string, string>>({});
|
||||
const [filter, setFilter] = useState<'pending' | 'active' | 'rejected' | 'all'>('pending');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.replace('/marketplace');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const checkAdmin = async () => {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
const meta = data.user?.user_metadata || {};
|
||||
const appMeta = data.user?.app_metadata || {};
|
||||
const adminCheck =
|
||||
meta.role === 'admin' ||
|
||||
appMeta.role === 'admin' ||
|
||||
// Also check user_profiles role column
|
||||
false;
|
||||
|
||||
// Also check user_profiles table
|
||||
const { data: profile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
setIsAdmin(adminCheck || profile?.role === 'admin' || profile?.role === 'super_admin');
|
||||
setCheckingAdmin(false);
|
||||
};
|
||||
checkAdmin();
|
||||
}, [user, supabase]);
|
||||
|
||||
const fetchQueue = useCallback(async () => {
|
||||
setQueueLoading(true);
|
||||
let query = supabase
|
||||
.from('marketplace_profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (filter !== 'all') {
|
||||
query = query.eq('status', filter);
|
||||
}
|
||||
|
||||
const { data: profilesData } = await query;
|
||||
if (!profilesData) {
|
||||
setProfiles([]);
|
||||
setQueueLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch details for each profile
|
||||
const enriched: PendingProfile[] = [];
|
||||
for (const p of profilesData) {
|
||||
let details = null;
|
||||
if (p.profile_type === 'crew') {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_crew_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
details = data;
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_vendor_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
details = data;
|
||||
}
|
||||
enriched.push({ ...p, details });
|
||||
}
|
||||
|
||||
setProfiles(enriched);
|
||||
setQueueLoading(false);
|
||||
}, [filter, supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) fetchQueue();
|
||||
}, [isAdmin, fetchQueue]);
|
||||
|
||||
const handleApprove = async (profile: PendingProfile) => {
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: 'Approving...' }));
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('marketplace_profiles')
|
||||
.update({ status: 'active' })
|
||||
.eq('id', profile.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Index in Typesense via API
|
||||
await fetch('/api/marketplace/typesense-index', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId: profile.id }),
|
||||
}).catch(() => {}); // Non-blocking
|
||||
|
||||
// Send approval email
|
||||
await fetch('/api/marketplace/admin/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId: profile.id, action: 'approve' }),
|
||||
}).catch(() => {});
|
||||
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: '✅ Approved' }));
|
||||
fetchQueue();
|
||||
} catch (err: any) {
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: `❌ ${err.message}` }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (profile: PendingProfile) => {
|
||||
const reason = rejectionReasons[profile.id]?.trim();
|
||||
if (!reason) {
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: '⚠️ Please enter a rejection reason' }));
|
||||
return;
|
||||
}
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: 'Rejecting...' }));
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('marketplace_profiles')
|
||||
.update({ status: 'rejected', rejection_reason: reason })
|
||||
.eq('id', profile.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Send rejection email
|
||||
await fetch('/api/marketplace/admin/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId: profile.id, action: 'reject', reason }),
|
||||
}).catch(() => {});
|
||||
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: '✅ Rejected' }));
|
||||
fetchQueue();
|
||||
} catch (err: any) {
|
||||
setActionMsg((prev) => ({ ...prev, [profile.id]: `❌ ${err.message}` }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || checkingAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">🔒</div>
|
||||
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-6">Admin access required.</p>
|
||||
<Link href="/marketplace" className="text-[#CC0000] hover:underline">
|
||||
Back to Marketplace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingCount = profiles.filter((p) => p.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-6xl mx-auto px-4 py-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">
|
||||
Marketplace
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">Admin Queue</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide">Profile Approval Queue</h1>
|
||||
{pendingCount > 0 && (
|
||||
<p className="text-yellow-400 text-sm mt-1">
|
||||
⚠️ {pendingCount} profile{pendingCount !== 1 ? 's' : ''} awaiting review
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchQueue}
|
||||
className="text-sm text-[#CC0000] hover:underline font-semibold"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{(['pending', 'active', 'rejected', 'all'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold capitalize transition-colors ${
|
||||
filter === f
|
||||
? 'bg-[#CC0000] text-white'
|
||||
: 'bg-[#1a1a1a] text-gray-400 hover:text-white border border-white/10'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{queueLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">✅</div>
|
||||
<p className="text-lg font-semibold">
|
||||
{filter === 'pending' ? 'No profiles pending review' : `No ${filter} profiles`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{profiles.map((profile) => {
|
||||
const name =
|
||||
profile.profile_type === 'crew'
|
||||
? profile.details?.name
|
||||
: profile.details?.company_name;
|
||||
const isPending = profile.status === 'pending';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`bg-[#1a1a1a] rounded-xl border overflow-hidden ${
|
||||
isPending ? 'border-yellow-500/30' : 'border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-14 h-14 rounded-full bg-[#CC0000]/20 flex-shrink-0 overflow-hidden flex items-center justify-center text-xl font-bold text-[#CC0000]">
|
||||
{(profile.details?.headshot_url || profile.details?.logo_url) ? (
|
||||
<img
|
||||
src={profile.details.headshot_url || profile.details.logo_url}
|
||||
alt={`${name} profile photo`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
name?.charAt(0)?.toUpperCase() || '?'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span className="font-bold text-lg">{name || 'Unknown'}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-bold ${
|
||||
profile.status === 'active' ?'bg-green-500/20 text-green-400'
|
||||
: profile.status === 'pending' ?'bg-yellow-500/20 text-yellow-400' :'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{profile.status}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-bold ${
|
||||
profile.profile_type === 'crew' ?'bg-blue-500/20 text-blue-400' :'bg-purple-500/20 text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{profile.profile_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
📍 {profile.details?.location || 'No location'}
|
||||
</div>
|
||||
{profile.profile_type === 'crew' && (
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
{profile.details?.skills?.slice(0, 3).join(', ') || 'No skills listed'}
|
||||
</div>
|
||||
)}
|
||||
{profile.profile_type === 'vendor' && (
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
{profile.details?.category || 'No category'}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Submitted{' '}
|
||||
{new Date(profile.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile details */}
|
||||
<div className="text-sm text-gray-400 space-y-1 min-w-0">
|
||||
{profile.details?.bio && (
|
||||
<p className="text-xs text-gray-500 max-w-xs line-clamp-3">
|
||||
{profile.details.bio}
|
||||
</p>
|
||||
)}
|
||||
{profile.details?.reel_url && (
|
||||
<a
|
||||
href={profile.details.reel_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[#CC0000] hover:underline block"
|
||||
>
|
||||
▶ View Reel
|
||||
</a>
|
||||
)}
|
||||
{profile.details?.website && (
|
||||
<a
|
||||
href={profile.details.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[#CC0000] hover:underline block"
|
||||
>
|
||||
🌐 Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
{isPending && (
|
||||
<div className="mt-6 pt-6 border-t border-white/10">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => handleApprove(profile)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
✓ Approve
|
||||
</button>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
value={rejectionReasons[profile.id] || ''}
|
||||
onChange={(e) =>
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
[profile.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="flex-1 bg-[#0d0d0d] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-red-500"
|
||||
placeholder="Rejection reason (required)..."
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReject(profile)}
|
||||
className="bg-red-700 hover:bg-red-800 text-white font-bold px-4 py-2.5 rounded-lg text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
✗ Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{actionMsg[profile.id] && (
|
||||
<p
|
||||
className={`text-sm mt-2 ${
|
||||
actionMsg[profile.id].startsWith('✅')
|
||||
? 'text-green-400'
|
||||
: actionMsg[profile.id].startsWith('⚠️')
|
||||
? 'text-yellow-400' :'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{actionMsg[profile.id]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && actionMsg[profile.id] && (
|
||||
<p className="text-sm mt-4 text-gray-400">{actionMsg[profile.id]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/marketplace/gigs/[id]/page.tsx
Normal file
107
src/app/marketplace/gigs/[id]/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function GigDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params?.id as string;
|
||||
const supabase = createClient();
|
||||
const [gig, setGig] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchGig = async () => {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_gigs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle();
|
||||
setGig(data);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchGig();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!gig) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">Gig Not Found</h1>
|
||||
<Link href="/marketplace/gigs" className="text-[#CC0000] hover:underline">Back to Gigs</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-3xl mx-auto px-4 py-24">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">Marketplace</Link>
|
||||
<span>/</span>
|
||||
<Link href="/marketplace/gigs" className="hover:text-[#CC0000]">Gigs</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white truncate">{gig.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] rounded-2xl border border-white/10 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-[#CC0000]/20 to-transparent p-8">
|
||||
<h1 className="text-2xl font-black mb-2">{gig.title}</h1>
|
||||
<div className="text-[#CC0000] font-bold text-lg mb-4">{gig.company}</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-300">
|
||||
<span>📍 {gig.location}</span>
|
||||
<span>🎬 {gig.gig_type}</span>
|
||||
{gig.rate && <span>💵 {gig.rate}</span>}
|
||||
{gig.start_date && (
|
||||
<span>📅 {new Date(gig.start_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
{gig.end_date && ` – ${new Date(gig.end_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Project Details</h2>
|
||||
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap">{gig.description}</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-white/10 pt-6">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-4">How to Respond</h2>
|
||||
{gig.contact_method ? (
|
||||
<div className="bg-[#0d0d0d] rounded-lg p-4 text-sm text-gray-300">{gig.contact_method}</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">Contact the poster directly through the platform.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 border-t border-white/10 pt-4">
|
||||
Posted {new Date(gig.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/marketplace/gigs" className="text-[#CC0000] hover:underline text-sm">← Back to Gig Board</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/app/marketplace/gigs/page.tsx
Normal file
112
src/app/marketplace/gigs/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function GigsPage() {
|
||||
const supabase = createClient();
|
||||
const [gigs, setGigs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGigs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_gigs')
|
||||
.select('*, marketplace_profiles!inner(slug, subscription_tier)')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
setGigs(data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchGigs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-5xl mx-auto px-4 py-24">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">Marketplace</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">Gigs</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide">Gig Board</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{loading ? '...' : `${gigs.length} open gig${gigs.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/marketplace/account"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-5 py-3 rounded-lg text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
+ Post a Gig
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] border border-yellow-500/30 rounded-lg p-4 text-sm text-yellow-400 mb-8">
|
||||
💳 <strong>Gig posting requires a Pro subscription.</strong> Billing is coming soon —
|
||||
create your free profile now and post gigs when payments launch.
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-[#1a1a1a] rounded-xl p-6 animate-pulse h-28" />
|
||||
))}
|
||||
</div>
|
||||
) : gigs.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">🎬</div>
|
||||
<p className="text-lg font-semibold mb-2">No gigs posted yet</p>
|
||||
<p className="text-sm">Be the first to post a crew call on Broadcast Beat Marketplace.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{gigs.map((gig) => (
|
||||
<Link
|
||||
key={gig.id}
|
||||
href={`/marketplace/gigs/${gig.id}`}
|
||||
className="block bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/30 rounded-xl p-6 transition-all"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h2 className="font-bold text-lg text-white">{gig.title}</h2>
|
||||
{gig.marketplace_profiles?.subscription_tier === 'featured' && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-2 py-0.5 rounded-full font-bold">Featured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[#CC0000] font-semibold text-sm mb-2">{gig.company}</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-400">
|
||||
<span>📍 {gig.location}</span>
|
||||
<span>🎬 {gig.gig_type}</span>
|
||||
{gig.rate && <span>💵 {gig.rate}</span>}
|
||||
{gig.start_date && (
|
||||
<span>📅 {new Date(gig.start_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
{gig.end_date && ` – ${new Date(gig.end_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{new Date(gig.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
{gig.description && (
|
||||
<p className="text-sm text-gray-500 mt-3 line-clamp-2">{gig.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/app/marketplace/jobs/[id]/page.tsx
Normal file
122
src/app/marketplace/jobs/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params?.id as string;
|
||||
const supabase = createClient();
|
||||
const [job, setJob] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchJob = async () => {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_jobs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle();
|
||||
setJob(data);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchJob();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">Job Not Found</h1>
|
||||
<Link href="/marketplace/jobs" className="text-[#CC0000] hover:underline">Back to Jobs</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-3xl mx-auto px-4 py-24">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">Marketplace</Link>
|
||||
<span>/</span>
|
||||
<Link href="/marketplace/jobs" className="hover:text-[#CC0000]">Jobs</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white truncate">{job.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] rounded-2xl border border-white/10 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-[#CC0000]/20 to-transparent p-8">
|
||||
<h1 className="text-2xl font-black mb-2">{job.title}</h1>
|
||||
<div className="text-[#CC0000] font-bold text-lg mb-4">{job.company}</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-300">
|
||||
<span>📍 {job.location}</span>
|
||||
<span className="capitalize">🕐 {job.job_type?.replace('_', ' ')}</span>
|
||||
{(job.salary_min || job.salary_max) && (
|
||||
<span>💵 {job.salary_min && job.salary_max
|
||||
? `$${job.salary_min.toLocaleString()} – $${job.salary_max.toLocaleString()}`
|
||||
: job.salary_min ? `From $${job.salary_min.toLocaleString()}` : `Up to $${job.salary_max?.toLocaleString()}`}
|
||||
</span>
|
||||
)}
|
||||
{job.expires_at && (
|
||||
<span>📅 Closes {new Date(job.expires_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Job Description</h2>
|
||||
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap">{job.description}</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-white/10 pt-6">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-4">How to Apply</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{job.apply_url && (
|
||||
<a href={job.apply_url} target="_blank" rel="noopener noreferrer"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-6 py-3 rounded-lg transition-colors">
|
||||
Apply Now →
|
||||
</a>
|
||||
)}
|
||||
{job.apply_email && (
|
||||
<a href={`mailto:${job.apply_email}?subject=Application: ${job.title}`}
|
||||
className="bg-white/10 hover:bg-white/20 border border-white/20 text-white font-bold px-6 py-3 rounded-lg transition-colors">
|
||||
✉ Email Application
|
||||
</a>
|
||||
)}
|
||||
{!job.apply_url && !job.apply_email && (
|
||||
<p className="text-gray-400 text-sm">Contact the employer directly for application instructions.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 border-t border-white/10 pt-4">
|
||||
Posted {new Date(job.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/marketplace/jobs" className="text-[#CC0000] hover:underline text-sm">← Back to Job Board</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/app/marketplace/jobs/page.tsx
Normal file
112
src/app/marketplace/jobs/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function JobsPage() {
|
||||
const supabase = createClient();
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJobs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('marketplace_jobs')
|
||||
.select('*, marketplace_profiles!inner(slug, subscription_tier)')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
setJobs(data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchJobs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-5xl mx-auto px-4 py-24">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">Marketplace</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">Jobs</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide">Job Board</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{loading ? '...' : `${jobs.length} open position${jobs.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/marketplace/account"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-5 py-3 rounded-lg text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
+ Post a Job
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] border border-yellow-500/30 rounded-lg p-4 text-sm text-yellow-400 mb-8">
|
||||
💳 <strong>Job posting requires a Pro subscription.</strong> Billing is coming soon —
|
||||
create your free profile now and post jobs when payments launch.
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-[#1a1a1a] rounded-xl p-6 animate-pulse h-28" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">📋</div>
|
||||
<p className="text-lg font-semibold mb-2">No jobs posted yet</p>
|
||||
<p className="text-sm">Be the first to post a job on Broadcast Beat Marketplace.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{jobs.map((job) => (
|
||||
<Link
|
||||
key={job.id}
|
||||
href={`/marketplace/jobs/${job.id}`}
|
||||
className="block bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/30 rounded-xl p-6 transition-all"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h2 className="font-bold text-lg text-white">{job.title}</h2>
|
||||
{job.marketplace_profiles?.subscription_tier === 'featured' && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-2 py-0.5 rounded-full font-bold">Featured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[#CC0000] font-semibold text-sm mb-2">{job.company}</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-400">
|
||||
<span>📍 {job.location}</span>
|
||||
<span className="capitalize">🕐 {job.job_type?.replace('_', ' ')}</span>
|
||||
{(job.salary_min || job.salary_max) && (
|
||||
<span>💵 {job.salary_min && job.salary_max
|
||||
? `$${job.salary_min.toLocaleString()} – $${job.salary_max.toLocaleString()}`
|
||||
: job.salary_min ? `From $${job.salary_min.toLocaleString()}` : `Up to $${job.salary_max?.toLocaleString()}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{new Date(job.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
{job.description && (
|
||||
<p className="text-sm text-gray-500 mt-3 line-clamp-2">{job.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
673
src/app/marketplace/join/page.tsx
Normal file
673
src/app/marketplace/join/page.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const SKILL_OPTIONS = [
|
||||
'Director of Photography',
|
||||
'Camera Operator',
|
||||
'1st AC / Focus Puller',
|
||||
'DIT',
|
||||
'Steadicam Operator',
|
||||
'Drone / UAV Operator',
|
||||
'Gaffer',
|
||||
'Grip / Key Grip',
|
||||
'Production Sound Mixer',
|
||||
'Boom Operator',
|
||||
'Sound Designer',
|
||||
'Audio Post / Mixing Engineer',
|
||||
'Director',
|
||||
'Producer',
|
||||
'Line Producer',
|
||||
'Production Manager',
|
||||
'Assistant Director',
|
||||
'Script Supervisor',
|
||||
'Editor (Offline)',
|
||||
'Online Editor / Finishing',
|
||||
'Colorist',
|
||||
'Motion Graphics Designer',
|
||||
'VFX Artist / Compositor',
|
||||
'Animator (2D/3D)',
|
||||
'Technical Director',
|
||||
'Broadcast Engineer',
|
||||
'Master Control Operator',
|
||||
'Graphics Operator',
|
||||
'Live Event Producer',
|
||||
'Livestream Producer',
|
||||
'Social Media Video Producer',
|
||||
'Photographer',
|
||||
];
|
||||
|
||||
const VENDOR_CATEGORIES = [
|
||||
'Full-Service Production Company',
|
||||
'Equipment Rental House',
|
||||
'Studio / Stage Rental',
|
||||
'Post Production Facility',
|
||||
'Animation Studio',
|
||||
'Drone Services Company',
|
||||
'Casting Agency',
|
||||
'Talent Agency',
|
||||
'Production Insurance',
|
||||
'Catering / Craft Services',
|
||||
'Transportation / Vehicles',
|
||||
'Other',
|
||||
];
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export default function JoinMarketplacePage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const supabase = createClient();
|
||||
|
||||
const [step, setStep] = useState<'type' | 'details' | 'submitting' | 'done'>('type');
|
||||
const [profileType, setProfileType] = useState<'crew' | 'vendor' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Auth state
|
||||
const [authMode, setAuthMode] = useState<'login' | 'signup'>('signup');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
// Crew fields
|
||||
const [crewName, setCrewName] = useState('');
|
||||
const [crewLocation, setCrewLocation] = useState('');
|
||||
const [crewUnion, setCrewUnion] = useState('non_union');
|
||||
const [crewRate, setCrewRate] = useState('');
|
||||
const [crewBio, setCrewBio] = useState('');
|
||||
const [crewSkills, setCrewSkills] = useState<string[]>([]);
|
||||
const [crewEquipment, setCrewEquipment] = useState('');
|
||||
const [crewReel, setCrewReel] = useState('');
|
||||
const [headshotFile, setHeadshotFile] = useState<File | null>(null);
|
||||
const [resumeFile, setResumeFile] = useState<File | null>(null);
|
||||
|
||||
// Vendor fields
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [vendorLocation, setVendorLocation] = useState('');
|
||||
const [vendorCategory, setVendorCategory] = useState('');
|
||||
const [vendorDescription, setVendorDescription] = useState('');
|
||||
const [vendorWebsite, setVendorWebsite] = useState('');
|
||||
const [vendorEmail, setVendorEmail] = useState('');
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
|
||||
const handleAuth = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAuthLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
if (authMode === 'signup') {
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) throw error;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkill = (skill: string) => {
|
||||
setCrewSkills((prev) =>
|
||||
prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]
|
||||
);
|
||||
};
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
bucket: string,
|
||||
userId: string
|
||||
): Promise<string | null> => {
|
||||
const ext = file.name.split('.').pop();
|
||||
const path = `${userId}/${Date.now()}.${ext}`;
|
||||
const { error } = await supabase.storage.from(bucket).upload(path, file, { upsert: true });
|
||||
if (error) return null;
|
||||
if (bucket === 'marketplace-resumes') return path;
|
||||
const { data } = supabase.storage.from(bucket).getPublicUrl(path);
|
||||
return data.publicUrl;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
setError('');
|
||||
setStep('submitting');
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
let headshotUrl: string | null = null;
|
||||
let logoUrl: string | null = null;
|
||||
let resumeUrl: string | null = null;
|
||||
|
||||
if (profileType === 'crew') {
|
||||
if (!crewName.trim() || !crewLocation.trim()) {
|
||||
setError('Name and location are required.');
|
||||
setStep('details');
|
||||
return;
|
||||
}
|
||||
if (headshotFile) {
|
||||
headshotUrl = await uploadFile(headshotFile, 'marketplace-headshots', user.id);
|
||||
}
|
||||
if (resumeFile) {
|
||||
resumeUrl = await uploadFile(resumeFile, 'marketplace-resumes', user.id);
|
||||
}
|
||||
} else {
|
||||
if (!vendorName.trim() || !vendorLocation.trim() || !vendorCategory) {
|
||||
setError('Company name, location, and category are required.');
|
||||
setStep('details');
|
||||
return;
|
||||
}
|
||||
if (logoFile) {
|
||||
logoUrl = await uploadFile(logoFile, 'marketplace-logos', user.id);
|
||||
}
|
||||
}
|
||||
|
||||
const baseSlug = slugify(
|
||||
profileType === 'crew' ? crewName : vendorName
|
||||
);
|
||||
const slug = `${baseSlug}-${Date.now().toString(36)}`;
|
||||
|
||||
// Create profile record
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('marketplace_profiles')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
profile_type: profileType,
|
||||
slug,
|
||||
status: 'pending',
|
||||
subscription_tier: 'free',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Create detail record
|
||||
if (profileType === 'crew') {
|
||||
const { error: detailError } = await supabase
|
||||
.from('marketplace_crew_details')
|
||||
.insert({
|
||||
profile_id: profile.id,
|
||||
name: crewName.trim(),
|
||||
headshot_url: headshotUrl,
|
||||
location: crewLocation.trim(),
|
||||
union_status: crewUnion,
|
||||
day_rate: crewRate ? parseInt(crewRate) : null,
|
||||
bio: crewBio.trim(),
|
||||
skills: crewSkills,
|
||||
equipment: crewEquipment
|
||||
? crewEquipment.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
reel_url: crewReel.trim() || null,
|
||||
resume_url: resumeUrl,
|
||||
});
|
||||
if (detailError) throw detailError;
|
||||
} else {
|
||||
const { error: detailError } = await supabase
|
||||
.from('marketplace_vendor_details')
|
||||
.insert({
|
||||
profile_id: profile.id,
|
||||
company_name: vendorName.trim(),
|
||||
logo_url: logoUrl,
|
||||
location: vendorLocation.trim(),
|
||||
category: vendorCategory,
|
||||
description: vendorDescription.trim(),
|
||||
website: vendorWebsite.trim() || null,
|
||||
contact_email: vendorEmail.trim() || user.email,
|
||||
});
|
||||
if (detailError) throw detailError;
|
||||
}
|
||||
|
||||
setStep('done');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Something went wrong. Please try again.');
|
||||
setStep('details');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-2xl mx-auto px-4 py-24">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<Link href="/marketplace" className="text-[#CC0000] text-sm hover:underline">
|
||||
← Back to Marketplace
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide mt-4 mb-2">
|
||||
Join the Marketplace
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
Create your free profile and get discovered by clients and employers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth gate */}
|
||||
{!user && (
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-xl p-8 mb-8">
|
||||
<h2 className="text-lg font-bold mb-6 text-center">
|
||||
{authMode === 'signup' ? 'Create an Account' : 'Sign In to Continue'}
|
||||
</h2>
|
||||
<form onSubmit={handleAuth} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full bg-[#0d0d0d] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{authError && (
|
||||
<p className="text-red-400 text-sm bg-red-400/10 rounded-lg px-4 py-2">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authLoading}
|
||||
className="w-full bg-[#CC0000] hover:bg-red-700 disabled:opacity-50 text-white font-bold py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{authLoading
|
||||
? 'Please wait...'
|
||||
: authMode === 'signup' ?'Create Account' :'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
{authMode === 'signup' ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
onClick={() => setAuthMode(authMode === 'signup' ? 'login' : 'signup')}
|
||||
className="text-[#CC0000] hover:underline"
|
||||
>
|
||||
{authMode === 'signup' ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Choose type */}
|
||||
{user && step === 'type' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-center mb-6">What type of profile?</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileType('crew');
|
||||
setStep('details');
|
||||
}}
|
||||
className="w-full bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/50 rounded-xl p-6 text-left transition-all group"
|
||||
>
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<div className="font-bold text-lg group-hover:text-[#CC0000] transition-colors">
|
||||
Crew Member / Freelancer
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
DP, Editor, Sound Mixer, Colorist, Engineer, and more
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileType('vendor');
|
||||
setStep('details');
|
||||
}}
|
||||
className="w-full bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/50 rounded-xl p-6 text-left transition-all group"
|
||||
>
|
||||
<div className="text-2xl mb-2">🏢</div>
|
||||
<div className="font-bold text-lg group-hover:text-[#CC0000] transition-colors">
|
||||
Vendor / Company
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
Production company, rental house, post facility, studio
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Crew details */}
|
||||
{user && step === 'details' && profileType === 'crew' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('type')}
|
||||
className="text-gray-400 hover:text-white text-sm"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">Crew Member Profile</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Full Name <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={crewName}
|
||||
onChange={(e) => setCrewName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Your professional name"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Location <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={crewLocation}
|
||||
onChange={(e) => setCrewLocation(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="City, State / Country"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Union Status</label>
|
||||
<select
|
||||
value={crewUnion}
|
||||
onChange={(e) => setCrewUnion(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
<option value="non_union">Non-Union</option>
|
||||
<option value="iatse">IATSE</option>
|
||||
<option value="sag_aftra">SAG-AFTRA</option>
|
||||
<option value="teamsters">Teamsters</option>
|
||||
<option value="nabet">NABET</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Day Rate (USD)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={crewRate}
|
||||
onChange={(e) => setCrewRate(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="e.g. 750"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Bio / About</label>
|
||||
<textarea
|
||||
value={crewBio}
|
||||
onChange={(e) => setCrewBio(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000] resize-none"
|
||||
placeholder="Brief professional bio (500 chars max)"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 text-right mt-1">{crewBio.length}/500</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Skills / Specialties</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SKILL_OPTIONS.map((skill) => (
|
||||
<button
|
||||
key={skill}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(skill)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
crewSkills.includes(skill)
|
||||
? 'bg-[#CC0000] border-[#CC0000] text-white'
|
||||
: 'bg-transparent border-white/20 text-gray-400 hover:border-white/40'
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Equipment Owned</label>
|
||||
<input
|
||||
value={crewEquipment}
|
||||
onChange={(e) => setCrewEquipment(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="e.g. Sony FX9, DJI Ronin, Sennheiser MKH416 (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Reel / Demo URL</label>
|
||||
<input
|
||||
value={crewReel}
|
||||
onChange={(e) => setCrewReel(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="https://vimeo.com/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Headshot / Photo</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
onChange={(e) => setHeadshotFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-[#CC0000] file:text-white file:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Resume / CV (PDF)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.doc,.docx"
|
||||
onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-[#1a1a1a] file:border file:border-white/20 file:text-white file:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-400/10 rounded-lg px-4 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="w-full bg-[#CC0000] hover:bg-red-700 disabled:opacity-50 text-white font-bold py-4 rounded-lg text-lg transition-colors"
|
||||
>
|
||||
{uploading ? 'Submitting...' : 'Submit Profile for Review'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
All profiles are reviewed by our team before going live. Typically within 24 hours.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step: Vendor details */}
|
||||
{user && step === 'details' && profileType === 'vendor' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('type')}
|
||||
className="text-gray-400 hover:text-white text-sm"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">Vendor / Company Profile</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Company Name <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={vendorName}
|
||||
onChange={(e) => setVendorName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Your company name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Location <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={vendorLocation}
|
||||
onChange={(e) => setVendorLocation(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="City, State / Country"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Category <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={vendorCategory}
|
||||
onChange={(e) => setVendorCategory(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
{VENDOR_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Company Description</label>
|
||||
<textarea
|
||||
value={vendorDescription}
|
||||
onChange={(e) => setVendorDescription(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000] resize-none"
|
||||
placeholder="Describe your services, specialties, and what makes you stand out"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Website</label>
|
||||
<input
|
||||
value={vendorWebsite}
|
||||
onChange={(e) => setVendorWebsite(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="https://yourcompany.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Contact Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={vendorEmail}
|
||||
onChange={(e) => setVendorEmail(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="contact@yourcompany.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Company Logo</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/svg+xml"
|
||||
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-[#CC0000] file:text-white file:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-400/10 rounded-lg px-4 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="w-full bg-[#CC0000] hover:bg-red-700 disabled:opacity-50 text-white font-bold py-4 rounded-lg text-lg transition-colors"
|
||||
>
|
||||
{uploading ? 'Submitting...' : 'Submit Profile for Review'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
All profiles are reviewed by our team before going live. Typically within 24 hours.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Submitting */}
|
||||
{step === 'submitting' && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-12 h-12 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400">Uploading your profile...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done */}
|
||||
{step === 'done' && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6 text-3xl">
|
||||
✓
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3">Profile Submitted!</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-md mx-auto">
|
||||
Your profile is now in our review queue. Our team will review it within 24 hours.
|
||||
You'll receive an email once it's approved and live.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/marketplace/account"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-6 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
View My Account
|
||||
</Link>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="bg-white/10 hover:bg-white/20 text-white font-bold px-6 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Back to Marketplace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
src/app/marketplace/page.tsx
Normal file
260
src/app/marketplace/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Production Crew & Vendor Marketplace | Broadcast Beat',
|
||||
description:
|
||||
'Find broadcast and production crew, vendors, jobs, and gig opportunities. The professional marketplace built for the broadcast industry.',
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ icon: '🎥', label: 'Camera & Lighting', count: 'DPs, Operators, Gaffers' },
|
||||
{ icon: '🎙️', label: 'Audio', count: 'Mixers, Boom Ops, Sound Designers' },
|
||||
{ icon: '🎬', label: 'Directing & Production', count: 'Directors, Producers, ADs' },
|
||||
{ icon: '✂️', label: 'Post Production', count: 'Editors, Colorists, VFX' },
|
||||
{ icon: '📡', label: 'Broadcast & Live', count: 'TDs, Engineers, MCOs' },
|
||||
{ icon: '🏢', label: 'Vendors & Companies', count: 'Rental Houses, Post Facilities' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ value: 'Phase 1', label: 'MVP Launch' },
|
||||
{ value: 'Free', label: 'To Join' },
|
||||
{ value: 'NAB', label: 'Official Media Partner' },
|
||||
{ value: '2025', label: 'Built for Today' },
|
||||
];
|
||||
|
||||
export default function MarketplacePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
{/* Hero */}
|
||||
<section className="relative pt-24 pb-20 px-4 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#CC0000]/10 to-transparent pointer-events-none" />
|
||||
<div className="max-w-6xl mx-auto text-center relative z-10">
|
||||
<div className="inline-flex items-center gap-2 bg-[#CC0000]/20 border border-[#CC0000]/40 rounded-full px-4 py-1.5 text-sm text-[#CC0000] font-semibold mb-6">
|
||||
<span className="w-2 h-2 bg-[#CC0000] rounded-full animate-pulse" />
|
||||
Now Live — Phase 1 MVP
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-black uppercase tracking-tight mb-6 leading-none">
|
||||
The Broadcast Industry's
|
||||
<br />
|
||||
<span className="text-[#CC0000]">Production Marketplace</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto mb-10 leading-relaxed">
|
||||
Find crew, hire vendors, post jobs, and land gigs — all in one place built by
|
||||
broadcast professionals, for broadcast professionals.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/marketplace/search"
|
||||
className="bg-[#CC0000] hover:bg-red-700 text-white font-bold px-8 py-4 rounded-lg text-lg transition-colors"
|
||||
>
|
||||
Find Crew & Vendors
|
||||
</Link>
|
||||
<Link
|
||||
href="/marketplace/join"
|
||||
className="bg-white/10 hover:bg-white/20 border border-white/20 text-white font-bold px-8 py-4 rounded-lg text-lg transition-colors"
|
||||
>
|
||||
Create Your Profile
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Stats */}
|
||||
<section className="border-y border-white/10 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{STATS?.map((s) => (
|
||||
<div key={s?.label} className="text-center">
|
||||
<div className="text-3xl font-black text-[#CC0000]">{s?.value}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{s?.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Categories */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-2xl font-black uppercase tracking-wide text-center mb-10">
|
||||
Browse by Category
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{CATEGORIES?.map((cat) => (
|
||||
<Link
|
||||
key={cat?.label}
|
||||
href={`/marketplace/search?category=${encodeURIComponent(cat?.label)}`}
|
||||
className="bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/40 rounded-xl p-6 transition-all group"
|
||||
>
|
||||
<div className="text-3xl mb-3">{cat?.icon}</div>
|
||||
<div className="font-bold text-white group-hover:text-[#CC0000] transition-colors">
|
||||
{cat?.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{cat?.count}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Three Columns: Find / Post / Join */}
|
||||
<section className="py-16 px-4 bg-[#111]">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-8">
|
||||
<div className="w-16 h-16 bg-[#CC0000]/20 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">
|
||||
🔍
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-3">Find Talent</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed mb-6">
|
||||
Search our directory of verified broadcast professionals and production vendors.
|
||||
Filter by location, specialty, rate, and union status.
|
||||
</p>
|
||||
<Link
|
||||
href="/marketplace/search"
|
||||
className="text-[#CC0000] font-semibold hover:underline text-sm"
|
||||
>
|
||||
Search Directory →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-8 border-x border-white/10">
|
||||
<div className="w-16 h-16 bg-[#CC0000]/20 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">
|
||||
📋
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-3">Post Jobs & Gigs</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed mb-6">
|
||||
Post full-time positions or short-term crew calls. Reach thousands of qualified
|
||||
broadcast professionals instantly.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/marketplace/jobs"
|
||||
className="text-[#CC0000] font-semibold hover:underline text-sm"
|
||||
>
|
||||
Browse Jobs →
|
||||
</Link>
|
||||
<Link
|
||||
href="/marketplace/gigs"
|
||||
className="text-[#CC0000] font-semibold hover:underline text-sm"
|
||||
>
|
||||
Browse Gigs →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-8">
|
||||
<div className="w-16 h-16 bg-[#CC0000]/20 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">
|
||||
⭐
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-3">Get Discovered</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed mb-6">
|
||||
Create your free profile, get verified by our team, and start receiving leads from
|
||||
clients and employers across the industry.
|
||||
</p>
|
||||
<Link
|
||||
href="/marketplace/join"
|
||||
className="text-[#CC0000] font-semibold hover:underline text-sm"
|
||||
>
|
||||
Create Profile →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Pricing teaser */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-2xl font-black uppercase tracking-wide mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-10">
|
||||
Start free. Upgrade when you're ready to grow.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'Free',
|
||||
price: '$0',
|
||||
period: 'forever',
|
||||
features: ['Basic profile', 'Appear in search', '3 gig responses/month'],
|
||||
cta: 'Get Started',
|
||||
href: '/marketplace/join',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$9.99',
|
||||
period: '/month',
|
||||
features: [
|
||||
'Priority placement',
|
||||
'Unlimited gig responses',
|
||||
'Post jobs & gigs',
|
||||
'Profile analytics',
|
||||
],
|
||||
cta: 'Go Pro',
|
||||
href: '/marketplace/join',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Studio / Vendor',
|
||||
price: '$29.99',
|
||||
period: '/month',
|
||||
features: [
|
||||
'Company profile',
|
||||
'Up to 5 staff profiles',
|
||||
'Featured placement',
|
||||
'Priority lead routing',
|
||||
],
|
||||
cta: 'Get Started',
|
||||
href: '/marketplace/join',
|
||||
highlight: false,
|
||||
},
|
||||
]?.map((plan) => (
|
||||
<div
|
||||
key={plan?.name}
|
||||
className={`rounded-xl p-6 border ${
|
||||
plan?.highlight
|
||||
? 'bg-[#CC0000]/10 border-[#CC0000] relative'
|
||||
: 'bg-[#1a1a1a] border-white/10'
|
||||
}`}
|
||||
>
|
||||
{plan?.highlight && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#CC0000] text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-lg mb-1">{plan?.name}</div>
|
||||
<div className="text-3xl font-black text-[#CC0000]">
|
||||
{plan?.price}
|
||||
<span className="text-sm text-gray-400 font-normal">{plan?.period}</span>
|
||||
</div>
|
||||
<ul className="mt-4 mb-6 space-y-2">
|
||||
{plan?.features?.map((f) => (
|
||||
<li key={f} className="text-sm text-gray-300 flex items-center gap-2">
|
||||
<span className="text-[#CC0000]">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={plan?.href}
|
||||
className={`block text-center py-2.5 rounded-lg font-bold text-sm transition-colors ${
|
||||
plan?.highlight
|
||||
? 'bg-[#CC0000] hover:bg-red-700 text-white'
|
||||
: 'bg-white/10 hover:bg-white/20 text-white'
|
||||
}`}
|
||||
>
|
||||
{plan?.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stripe coming soon notice */}
|
||||
<div className="mt-8 bg-[#1a1a1a] border border-yellow-500/30 rounded-lg p-4 text-sm text-yellow-400">
|
||||
💳 <strong>Billing coming soon</strong> — Pro and Studio plans are available now.
|
||||
Payment processing will be enabled shortly. Create your free profile today and upgrade
|
||||
when billing launches.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/app/marketplace/profile/[slug]/page.tsx
Normal file
193
src/app/marketplace/profile/[slug]/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const params = useParams();
|
||||
const slug = params?.slug as string;
|
||||
const supabase = createClient();
|
||||
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [details, setDetails] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFoundState, setNotFoundState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const fetchProfile = async () => {
|
||||
const { data: p } = await supabase
|
||||
.from('marketplace_profiles')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle();
|
||||
|
||||
if (!p) { setNotFoundState(true); setLoading(false); return; }
|
||||
setProfile(p);
|
||||
|
||||
if (p.profile_type === 'crew') {
|
||||
const { data: d } = await supabase
|
||||
.from('marketplace_crew_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
setDetails(d);
|
||||
} else {
|
||||
const { data: d } = await supabase
|
||||
.from('marketplace_vendor_details')
|
||||
.select('*')
|
||||
.eq('profile_id', p.id)
|
||||
.maybeSingle();
|
||||
setDetails(d);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchProfile();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFoundState || !profile || !details) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">👤</div>
|
||||
<h1 className="text-2xl font-bold mb-2">Profile Not Found</h1>
|
||||
<p className="text-gray-400 mb-6">This profile may be pending review or does not exist.</p>
|
||||
<Link href="/marketplace/search" className="text-[#CC0000] hover:underline">Browse Directory</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCrew = profile.profile_type === 'crew';
|
||||
const name = isCrew ? details.name : details.company_name;
|
||||
const photoUrl = isCrew ? details.headshot_url : details.logo_url;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-4xl mx-auto px-4 py-24">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">Marketplace</Link>
|
||||
<span>/</span>
|
||||
<Link href="/marketplace/search" className="hover:text-[#CC0000]">Search</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1a1a] rounded-2xl overflow-hidden border border-white/10">
|
||||
<div className="bg-gradient-to-r from-[#CC0000]/20 to-transparent p-8">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-6">
|
||||
<div className="w-24 h-24 rounded-full bg-[#CC0000]/20 flex-shrink-0 overflow-hidden flex items-center justify-center text-3xl font-black text-[#CC0000] border-2 border-[#CC0000]/30">
|
||||
{photoUrl ? (
|
||||
<img src={photoUrl} alt={`${name} profile photo`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
name?.charAt(0)?.toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h1 className="text-2xl font-black">{name}</h1>
|
||||
{profile.subscription_tier === 'featured' && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-2 py-1 rounded-full font-bold">⭐ Featured</span>
|
||||
)}
|
||||
<span className="text-xs bg-green-500/20 text-green-400 border border-green-500/30 px-2 py-1 rounded-full font-bold">✓ Verified</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-bold ${isCrew ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}`}>
|
||||
{isCrew ? 'Crew Member' : 'Vendor / Company'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-400">
|
||||
<span>📍 {details.location}</span>
|
||||
{isCrew && details.day_rate && <span>💵 ${details.day_rate.toLocaleString()}/day</span>}
|
||||
{isCrew && details.union_status && details.union_status !== 'non_union' && (
|
||||
<span className="uppercase text-yellow-400">🎭 {details.union_status.replace('_', '-')}</span>
|
||||
)}
|
||||
{!isCrew && details.category && <span>🏢 {details.category}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{(details.bio || details.description) && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">About</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{details.bio || details.description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCrew && details.skills && details.skills.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Skills & Specialties</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.skills.map((skill: string) => (
|
||||
<span key={skill} className="text-sm bg-[#CC0000]/10 border border-[#CC0000]/30 text-[#CC0000] px-3 py-1 rounded-full">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCrew && details.equipment && details.equipment.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Equipment Owned</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.equipment.map((item: string) => (
|
||||
<span key={item} className="text-sm bg-white/5 border border-white/10 text-gray-300 px-3 py-1 rounded-full">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCrew && details.reel_url && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Demo Reel</h2>
|
||||
<a href={details.reel_url} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 bg-[#CC0000]/10 hover:bg-[#CC0000]/20 border border-[#CC0000]/30 text-[#CC0000] px-4 py-2 rounded-lg text-sm font-semibold transition-colors">
|
||||
▶ Watch Reel
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isCrew && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-400 mb-3">Contact & Links</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{details.website && (
|
||||
<a href={details.website} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-white px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
🌐 Website
|
||||
</a>
|
||||
)}
|
||||
{details.contact_email && (
|
||||
<a href={`mailto:${details.contact_email}`}
|
||||
className="inline-flex items-center gap-2 bg-[#CC0000]/10 hover:bg-[#CC0000]/20 border border-[#CC0000]/30 text-[#CC0000] px-4 py-2 rounded-lg text-sm font-semibold transition-colors">
|
||||
✉ Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/10 pt-6 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Member since {new Date(profile.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</span>
|
||||
<Link href="/marketplace/search" className="text-[#CC0000] hover:underline">← Back to Search</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
src/app/marketplace/search/page.tsx
Normal file
533
src/app/marketplace/search/page.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { searchProfiles, SearchFilters } from '@/lib/marketplace/typesenseClient';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const SKILL_OPTIONS = [
|
||||
'Director of Photography',
|
||||
'Camera Operator',
|
||||
'DIT',
|
||||
'Gaffer',
|
||||
'Grip',
|
||||
'Production Sound Mixer',
|
||||
'Boom Operator',
|
||||
'Sound Designer',
|
||||
'Director',
|
||||
'Producer',
|
||||
'Editor',
|
||||
'Colorist',
|
||||
'VFX Artist',
|
||||
'Technical Director',
|
||||
'Broadcast Engineer',
|
||||
'Livestream Producer',
|
||||
];
|
||||
|
||||
const VENDOR_CATEGORIES = [
|
||||
'Full-Service Production Company',
|
||||
'Equipment Rental House',
|
||||
'Studio / Stage Rental',
|
||||
'Post Production Facility',
|
||||
'Animation Studio',
|
||||
'Drone Services Company',
|
||||
];
|
||||
|
||||
interface ProfileCard {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: 'crew' | 'vendor';
|
||||
name: string;
|
||||
location: string;
|
||||
category?: string;
|
||||
skills?: string[];
|
||||
day_rate?: number;
|
||||
bio?: string;
|
||||
headshot_url?: string;
|
||||
logo_url?: string;
|
||||
featured: boolean;
|
||||
union_status?: string;
|
||||
}
|
||||
|
||||
export default function MarketplaceSearchPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-[#0d0d0d] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-[#CC0000] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const [filterType, setFilterType] = useState<'all' | 'crew' | 'vendor'>(
|
||||
(searchParams.get('type') as any) || 'all'
|
||||
);
|
||||
const [filterLocation, setFilterLocation] = useState(searchParams.get('location') || '');
|
||||
const [filterCategory, setFilterCategory] = useState(searchParams.get('category') || '');
|
||||
const [filterUnion, setFilterUnion] = useState('');
|
||||
const [filterRateMin, setFilterRateMin] = useState('');
|
||||
const [filterRateMax, setFilterRateMax] = useState('');
|
||||
const [featuredOnly, setFeaturedOnly] = useState(false);
|
||||
|
||||
const [results, setResults] = useState<ProfileCard[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [typesenseAvailable, setTypesenseAvailable] = useState(true);
|
||||
|
||||
const fetchFromSupabase = useCallback(async (filters: SearchFilters) => {
|
||||
// Fallback: query Supabase directly when Typesense is unavailable
|
||||
let crewQuery = supabase
|
||||
.from('marketplace_crew_details')
|
||||
.select(`
|
||||
*,
|
||||
marketplace_profiles!inner(id, slug, status, subscription_tier)
|
||||
`)
|
||||
.eq('marketplace_profiles.status', 'active');
|
||||
|
||||
if (filters.query && filters.query !== '*') {
|
||||
crewQuery = crewQuery.ilike('name', `%${filters.query}%`);
|
||||
}
|
||||
if (filters.location) {
|
||||
crewQuery = crewQuery.ilike('location', `%${filters.location}%`);
|
||||
}
|
||||
|
||||
let vendorQuery = supabase
|
||||
.from('marketplace_vendor_details')
|
||||
.select(`
|
||||
*,
|
||||
marketplace_profiles!inner(id, slug, status, subscription_tier)
|
||||
`)
|
||||
.eq('marketplace_profiles.status', 'active');
|
||||
|
||||
if (filters.query && filters.query !== '*') {
|
||||
vendorQuery = vendorQuery.ilike('company_name', `%${filters.query}%`);
|
||||
}
|
||||
if (filters.category) {
|
||||
vendorQuery = vendorQuery.ilike('category', `%${filters.category}%`);
|
||||
}
|
||||
|
||||
const cards: ProfileCard[] = [];
|
||||
|
||||
if (filters.type === 'all' || filters.type === 'crew') {
|
||||
const { data: crewData } = await crewQuery.limit(20);
|
||||
(crewData || []).forEach((row: any) => {
|
||||
cards.push({
|
||||
id: row.marketplace_profiles?.id,
|
||||
slug: row.marketplace_profiles?.slug,
|
||||
type: 'crew',
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
skills: row.skills || [],
|
||||
day_rate: row.day_rate,
|
||||
bio: row.bio,
|
||||
headshot_url: row.headshot_url,
|
||||
featured: row.marketplace_profiles?.subscription_tier === 'featured',
|
||||
union_status: row.union_status,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.type === 'all' || filters.type === 'vendor') {
|
||||
const { data: vendorData } = await vendorQuery.limit(20);
|
||||
(vendorData || []).forEach((row: any) => {
|
||||
cards.push({
|
||||
id: row.marketplace_profiles?.id,
|
||||
slug: row.marketplace_profiles?.slug,
|
||||
type: 'vendor',
|
||||
name: row.company_name,
|
||||
location: row.location,
|
||||
category: row.category,
|
||||
bio: row.description,
|
||||
logo_url: row.logo_url,
|
||||
featured: row.marketplace_profiles?.subscription_tier === 'featured',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
}, [supabase]);
|
||||
|
||||
const doSearch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const filters: SearchFilters = {
|
||||
query: query || '*',
|
||||
type: filterType,
|
||||
location: filterLocation || undefined,
|
||||
category: filterCategory || undefined,
|
||||
union_status: filterUnion || undefined,
|
||||
day_rate_min: filterRateMin ? parseInt(filterRateMin) : undefined,
|
||||
day_rate_max: filterRateMax ? parseInt(filterRateMax) : undefined,
|
||||
featured_only: featuredOnly,
|
||||
page,
|
||||
per_page: 20,
|
||||
};
|
||||
|
||||
try {
|
||||
const { crew, vendors, error: tsError } = await searchProfiles(filters);
|
||||
|
||||
if (tsError || (!crew && !vendors)) {
|
||||
// Typesense unavailable — fall back to Supabase
|
||||
setTypesenseAvailable(false);
|
||||
const cards = await fetchFromSupabase(filters);
|
||||
setResults(cards);
|
||||
setTotal(cards.length);
|
||||
} else {
|
||||
setTypesenseAvailable(true);
|
||||
const cards: ProfileCard[] = [];
|
||||
|
||||
(crew?.hits || []).forEach((hit: any) => {
|
||||
const doc = hit.document;
|
||||
cards.push({
|
||||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
type: 'crew',
|
||||
name: doc.name,
|
||||
location: doc.location,
|
||||
skills: doc.skills || [],
|
||||
day_rate: doc.day_rate,
|
||||
bio: doc.bio,
|
||||
headshot_url: doc.headshot_url,
|
||||
featured: doc.featured,
|
||||
union_status: doc.union_status,
|
||||
});
|
||||
});
|
||||
|
||||
(vendors?.hits || []).forEach((hit: any) => {
|
||||
const doc = hit.document;
|
||||
cards.push({
|
||||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
type: 'vendor',
|
||||
name: doc.company_name,
|
||||
location: doc.location,
|
||||
category: doc.category,
|
||||
bio: doc.description,
|
||||
logo_url: doc.logo_url,
|
||||
featured: doc.featured,
|
||||
});
|
||||
});
|
||||
|
||||
setResults(cards);
|
||||
setTotal((crew?.found || 0) + (vendors?.found || 0));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query, filterType, filterLocation, filterCategory, filterUnion, filterRateMin, filterRateMax, featuredOnly, page, fetchFromSupabase]);
|
||||
|
||||
useEffect(() => {
|
||||
doSearch();
|
||||
}, [doSearch]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
doSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0d0d] text-white">
|
||||
<Header />
|
||||
<div className="max-w-7xl mx-auto px-4 py-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||
<Link href="/marketplace" className="hover:text-[#CC0000]">
|
||||
Marketplace
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">Search</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-black uppercase tracking-wide mb-8">
|
||||
Find Crew & Vendors
|
||||
</h1>
|
||||
|
||||
{!typesenseAvailable && (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-4 py-3 text-sm text-yellow-400 mb-6">
|
||||
⚡ Search is running in basic mode. Connect Typesense for full-text search and advanced
|
||||
filtering.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="lg:w-64 flex-shrink-0">
|
||||
<form onSubmit={handleSearch} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Name, skill, company..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'crew', 'vendor'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setFilterType(t)}
|
||||
className={`flex-1 py-2 text-xs font-bold rounded-lg border transition-colors capitalize ${
|
||||
filterType === t
|
||||
? 'bg-[#CC0000] border-[#CC0000] text-white'
|
||||
: 'bg-transparent border-white/20 text-gray-400 hover:border-white/40'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
value={filterLocation}
|
||||
onChange={(e) => setFilterLocation(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="City, State..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(filterType === 'all' || filterType === 'crew') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Union Status
|
||||
</label>
|
||||
<select
|
||||
value={filterUnion}
|
||||
onChange={(e) => setFilterUnion(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="non_union">Non-Union</option>
|
||||
<option value="iatse">IATSE</option>
|
||||
<option value="sag_aftra">SAG-AFTRA</option>
|
||||
<option value="teamsters">Teamsters</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Day Rate Range
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={filterRateMin}
|
||||
onChange={(e) => setFilterRateMin(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Min $"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={filterRateMax}
|
||||
onChange={(e) => setFilterRateMax(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
placeholder="Max $"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(filterType === 'all' || filterType === 'vendor') && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wide mb-2">
|
||||
Vendor Category
|
||||
</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/20 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#CC0000]"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{VENDOR_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
checked={featuredOnly}
|
||||
onChange={(e) => setFeaturedOnly(e.target.checked)}
|
||||
className="accent-[#CC0000]"
|
||||
/>
|
||||
<label htmlFor="featured" className="text-sm text-gray-400 cursor-pointer">
|
||||
Featured only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#CC0000] hover:bg-red-700 text-white font-bold py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{loading ? 'Searching...' : `${total} result${total !== 1 ? 's' : ''} found`}
|
||||
</p>
|
||||
<Link
|
||||
href="/marketplace/join"
|
||||
className="text-xs text-[#CC0000] hover:underline font-semibold"
|
||||
>
|
||||
+ Add Your Profile
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-[#1a1a1a] rounded-xl p-5 animate-pulse h-40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">🔍</div>
|
||||
<p className="text-lg font-semibold mb-2">No results found</p>
|
||||
<p className="text-sm">
|
||||
Try adjusting your filters or{' '}
|
||||
<Link href="/marketplace/join" className="text-[#CC0000] hover:underline">
|
||||
add your profile
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{results.map((profile) => (
|
||||
<Link
|
||||
key={profile.id}
|
||||
href={`/marketplace/profile/${profile.slug}`}
|
||||
className="bg-[#1a1a1a] hover:bg-[#222] border border-white/10 hover:border-[#CC0000]/30 rounded-xl p-5 transition-all block"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-14 h-14 rounded-full bg-[#CC0000]/20 flex-shrink-0 overflow-hidden flex items-center justify-center text-xl font-bold text-[#CC0000]">
|
||||
{profile.headshot_url || profile.logo_url ? (
|
||||
<img
|
||||
src={profile.headshot_url || profile.logo_url}
|
||||
alt={`${profile.name} profile photo`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
profile.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-white truncate">{profile.name}</span>
|
||||
{profile.featured && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-2 py-0.5 rounded-full font-semibold">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
||||
profile.type === 'crew' ?'bg-blue-500/20 text-blue-400' :'bg-purple-500/20 text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{profile.type === 'crew' ? 'Crew' : 'Vendor'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-0.5">
|
||||
{profile.category ||
|
||||
profile.skills?.slice(0, 2).join(', ') ||
|
||||
'Broadcast Professional'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-3">
|
||||
<span>📍 {profile.location}</span>
|
||||
{profile.day_rate && (
|
||||
<span>💵 ${profile.day_rate.toLocaleString()}/day</span>
|
||||
)}
|
||||
{profile.union_status && profile.union_status !== 'non_union' && (
|
||||
<span className="uppercase">{profile.union_status}</span>
|
||||
)}
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{profile.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-[#1a1a1a] border border-white/20 rounded-lg text-sm disabled:opacity-40 hover:border-white/40 transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm text-gray-400">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={results.length < 20}
|
||||
className="px-4 py-2 bg-[#1a1a1a] border border-white/20 rounded-lg text-sm disabled:opacity-40 hover:border-white/40 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user