← back to Stayclaim
src/app/promote/[slug]/PromoteFormClient.tsx
187 lines
'use client';
import { useEffect, useRef, useState } from 'react';
export function PromoteFormClient({
listingId,
defaultHeadline,
}: {
listingId: string;
defaultHeadline: string;
}) {
const [status, setStatus] = useState<'idle' | 'submitting' | 'ok' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [submissionId, setSubmissionId] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('submitting');
setError(null);
const fd = new FormData(e.currentTarget);
const s = (k: string) => String(fd.get(k) ?? '').trim();
const body = {
listing_id: listingId,
source: s('source'),
external_url: s('external_url'),
headline: s('headline'),
price_label: s('price_label') || undefined,
contact_email: s('contact_email'),
affiliation: s('affiliation'),
permit_number: s('permit_number') || undefined,
};
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch('/api/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: ctrl.signal,
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
setError(typeof j?.error === 'string' ? j.error : `HTTP ${res.status}`);
setStatus('error');
return;
}
const j = await res.json().catch(() => ({}));
if (typeof j?.id !== 'string' || !j.id) {
setError('Bad server response');
setStatus('error');
return;
}
setSubmissionId(j.id);
setStatus('ok');
} catch (err) {
if ((err as { name?: string })?.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Network error');
setStatus('error');
}
}
if (status === 'ok' && submissionId) {
return (
<div className="mt-10 border border-moss/30 bg-moss/5 p-6">
<p className="font-display text-2xl text-ink">Submission received.</p>
<p className="mt-2 text-sm text-ink/70 leading-relaxed">
Confirmation #{' '}
<span className="font-mono text-xs">{submissionId.slice(0, 8)}</span>
. We’ll verify your affiliation within one business day and email you when your placement is approved.
</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="mt-10 grid gap-5">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Listing source
<select
name="source"
required
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
>
<option value="">Select…</option>
<option value="zillow">Zillow</option>
<option value="redfin">Redfin</option>
<option value="airbnb">Airbnb</option>
<option value="other">Other</option>
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Listing URL
<input
required
type="url"
name="external_url"
placeholder="https://www.zillow.com/homedetails/…"
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Headline (max 200 chars)
<input
type="text"
name="headline"
required
maxLength={200}
defaultValue={defaultHeadline}
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Price label (optional)
<input
type="text"
name="price_label"
maxLength={80}
placeholder="$4,250,000 · Open Saturday 1–4pm"
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Your email
<input
type="email"
name="contact_email"
required
placeholder="agent@brokerage.com"
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
How are you affiliated with this address? (min 10 chars)
<textarea
required
name="affiliation"
rows={3}
minLength={10}
placeholder="Listing agent, owner, or representative. We verify before the placement goes live."
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Permit # (Beverly Hills / Santa Monica / etc., if required by your jurisdiction)
<input
type="text"
name="permit_number"
placeholder="Optional — self-attested"
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<button
type="submit"
disabled={status === 'submitting'}
className="bg-coral text-ink px-5 py-3 text-xs uppercase tracking-wider hover:bg-ink hover:text-sand transition self-start disabled:opacity-50"
>
{status === 'submitting' ? 'Submitting…' : 'Submit for review'}
</button>
{error && (
<p className="text-xs text-coral" role="alert">
{error}
</p>
)}
<p className="text-[11px] text-ink/45 max-w-prose leading-relaxed">
We’ll verify the affiliation and load your placement within 1 business day. Stripe checkout sent
after approval. We do not write your listing into the historical ledger.
</p>
</form>
);
}