Add a live profile preview to every Lab offer form

Filling the long form was blind until publish. LabProfilePreview renders a
faithful profile card from the current draft — title, bio, image, location,
skills, structured rate, languages/availability/payments and gallery — with a
not-published-yet note.

- Wired into CraftProfileForm (8 crafts) and the coach + filmer/editor/
  photographer/builder pages via a Preview button next to Submit.
- parseLabExtras now accepts any { tags } so a draft tag list can be previewed
  without a published event.
- i18n for the preview copy (EN/ES).

Still open: draft/autosave and a multi-step layout.
This commit is contained in:
SkateSpots 2026-10-04 11:25:58 +07:00
parent dd437693ab
commit 81b5fd0fab
11 changed files with 479 additions and 34 deletions

View file

@ -116,6 +116,19 @@ session with **no other memory of this project**. So:
(`CRAFT_DETAIL_FIELDS`) as well as the form. A small per-craft set is in place
(deliverables/turnaround, deposit/minimum, travel, lead_time, …); toggle tags
store `"true"` and render as a localized "Yes".
- **Lab offer/publish UX (2026-10-04):** the create→post loop got its missing
pieces. **`components/LabProfilePreview.tsx`** is a live "how it will look"
preview fed a draft from the form (title/bio/image/location/experience/rate/
skills/contact + the shared enrichment tags); wired into `CraftProfileForm`
and all 4 bespoke pages + coach via a **Preview** button next to Submit.
`parseLabExtras` now accepts any `{ tags }` (not just a full event) so drafts
preview. On a **first** publish the form redirects with `?published=1`, and
the profile shows a one-time "You're live — share it" banner. The owner
"Manage" card has an **Edit profile** link (`/become/<craft>`), every profile
has a **Share** button (`ShareProfileDialog` with a `path` override), and
`/lab` shows a **"Your Lab profiles"** strip. `main.tsx`'s clean-link shim now
also rewrites `/coach/<pubkey>/<d>`. Still open: draft/autosave and a
multi-step layout.
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
image input — **drag & drop + multi-select**, emits `File[]` to its caller
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and

View file

@ -17,6 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -27,7 +28,7 @@ import type { LabOption } from '@/lib/labExtras';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
import { Upload, X, Loader2, ArrowLeft, MapPin, Eye } from 'lucide-react';
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
export interface SkillSection {
@ -133,6 +134,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
// Load + prefill an existing profile so this is an EDIT, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(kind, identifier);
@ -334,6 +336,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const hasNip05 = metadata?.nip05;
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
const previewSkills = skillSections.flatMap((s) =>
s.entries
.filter(([k]) => selected.includes(k))
.map(([k, label]) => ({ label: s.keyMap?.[k] ? t(s.keyMap[k]) : label })),
);
return (
<>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
@ -594,16 +602,47 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{/* Pro upgrade (optional) */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
{/* Submit */}
<Button
type="submit"
disabled={isPending}
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
</Button>
{/* Preview + Submit */}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="h-12 flex-shrink-0 gap-2 rounded-xl border-white/15 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
disabled={isPending}
className={cn('flex-1 h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
</Button>
</div>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={heading}
noun={noun}
accentText={accentText}
accentChip={accentChip}
icon={Icon}
draft={{
title: titleInput.trim() || getDisplayName(),
bio,
image,
location,
experience,
rate,
skills: previewSkills,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}

View file

@ -0,0 +1,215 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useTranslation } from '@/contexts/language';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
parseLabExtras,
labOptionLabel,
PAYMENT_METHODS,
AVAILABILITY_OPTIONS,
RATE_CURRENCIES,
ALL_RATE_UNITS,
} from '@/lib/labExtras';
import { Banknote, Eye, Languages as LanguagesIcon, MapPin } from 'lucide-react';
export interface LabPreviewSkill {
label: string;
description?: string;
}
export interface LabPreviewDraft {
title: string;
bio: string;
image?: string | null;
location?: string;
experience?: string;
rate?: string;
skills?: LabPreviewSkill[];
contact?: {
phone?: string;
email?: string;
website?: string;
instagram?: string;
portfolio?: string;
social?: string;
noffer?: string;
};
/** The form's shared enrichment tags (languages/availability/rate/payments/photo). */
extraTags?: string[][];
}
interface LabProfilePreviewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
draft: LabPreviewDraft;
heading: string;
noun: string;
accentText: string;
accentChip: string;
icon: React.ComponentType<{ className?: string }>;
}
/**
* A "how it will look" preview of a Lab provider profile, built from the form's
* current draft — the create flow is long and otherwise blind until publish.
* Deliberately a faithful *card*, not the full detail page (which fetches the
* published event).
*/
export function LabProfilePreview({
open,
onOpenChange,
draft,
heading,
noun,
accentText,
accentChip,
icon: Icon,
}: LabProfilePreviewProps) {
const { t } = useTranslation();
const extras = parseLabExtras({ tags: draft.extraTags ?? [] });
const rateAmount = extras.rateAmount;
const rateDisplay = rateAmount
? `${rateAmount} ${labOptionLabel(RATE_CURRENCIES, extras.rateCurrency ?? 'sats', t)}`
: draft.rate;
const unitKey = extras.rateUnit ? ALL_RATE_UNITS.find((u) => u.value === extras.rateUnit)?.key : undefined;
const contact = draft.contact ?? {};
const skills = (draft.skills ?? []).slice(0, 8);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-lg overflow-y-auto bg-black border-white/10">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 uppercase font-black">
<Eye className={`h-5 w-5 ${accentText}`} /> {t('labUi.preview.title')}
</DialogTitle>
<DialogDescription className="text-gray-400">
{t('labUi.preview.desc', { noun })}
</DialogDescription>
</DialogHeader>
<div className="rounded-2xl border-2 border-white/10 bg-white/[0.03] p-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="h-16 w-16 flex-shrink-0 overflow-hidden rounded-2xl bg-white/5">
{draft.image ? (
<ImageWithFallback src={draft.image} alt={draft.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<Icon className={`h-7 w-7 ${accentText}`} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className={`text-xl font-black uppercase ${accentText}`} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{draft.title || t('labUi.preview.untitled')}
</h3>
<Badge variant="outline" className={`gap-1 ${accentText}`}>
<Icon className="h-3 w-3" /> {heading}
</Badge>
</div>
{draft.location && (
<p className="mt-1 flex items-center gap-1 text-sm text-gray-400">
<MapPin className="h-3.5 w-3.5" /> {draft.location}
</p>
)}
</div>
</div>
{/* Bio */}
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-300">
{draft.bio || <span className="text-gray-600">{t('labUi.preview.noBio')}</span>}
</p>
{/* Skills */}
{skills.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{skills.map((s) => (
<span key={s.label} className={`rounded-full border px-2.5 py-1 text-xs font-bold ${accentChip}`}>
{s.label}
</span>
))}
</div>
)}
{/* Rate + experience */}
{(rateDisplay || draft.experience) && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
{rateDisplay && (
<span className="font-bold text-orange-400">
💰 {rateDisplay}{unitKey ? ` · ${t(unitKey)}` : ''}
</span>
)}
{draft.experience && (
<span className="text-gray-400">{t('labUi.preview.years', { count: draft.experience })}</span>
)}
</div>
)}
{/* Languages / availability / payments */}
{(extras.languages.length > 0 || extras.availability.length > 0 || extras.paymentMethods.length > 0 || extras.payWhatYouCan) && (
<div className="mt-3 space-y-2">
{extras.languages.length > 0 && (
<p className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<LanguagesIcon className="h-3.5 w-3.5" /> {extras.languages.join(', ')}
</p>
)}
{extras.availability.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{extras.availability.map((v) => (
<span key={v} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-gray-400">
{labOptionLabel(AVAILABILITY_OPTIONS, v, t)}
</span>
))}
</div>
)}
{(extras.paymentMethods.length > 0 || extras.payWhatYouCan) && (
<div className="flex flex-wrap items-center gap-1.5 text-xs text-gray-400">
<Banknote className="h-3.5 w-3.5" />
{extras.paymentMethods.map((m) => (
<span key={m} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5">{labOptionLabel(PAYMENT_METHODS, m, t)}</span>
))}
{extras.payWhatYouCan && (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5">{t('labUi.detail.payWhatYouCan')}</span>
)}
</div>
)}
</div>
)}
{/* Gallery preview */}
{extras.photos.length > 0 && (
<div className="mt-3 grid grid-cols-4 gap-1.5">
{extras.photos.slice(0, 8).map((url) => (
<div key={url} className="aspect-square overflow-hidden rounded-lg bg-white/5">
<ImageWithFallback src={url} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
)}
{/* Contact */}
{(sanitizeUrl(contact.website) || sanitizeUrl(contact.portfolio) || sanitizeUrl(contact.social) || contact.email || contact.phone || contact.instagram) && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-400">
{contact.phone && <span>📞 {contact.phone}</span>}
{contact.email && <span>✉️ {contact.email}</span>}
{sanitizeUrl(contact.website) && <span>🔗 {t('labUi.become.website')}</span>}
{contact.instagram && <span>📷 {contact.instagram}</span>}
{sanitizeUrl(contact.portfolio) && <span>🎨 {t('labUi.preview.portfolio')}</span>}
</div>
)}
<p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-gray-500">{t('labUi.preview.note')}</p>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1995,6 +1995,16 @@ labUi: {
offerPhotography: 'Offer Photography',
offerServices: 'Offer Services',
yourProfiles: 'Your Lab profiles',
preview: {
button: 'Preview',
title: 'Live preview',
desc: 'This is how your {{noun}} profile will look once published.',
untitled: 'Your name',
noBio: 'No bio yet — add a line about what you offer.',
years: '{{count}} years experience',
portfolio: 'Portfolio',
note: 'Preview only — nothing is published yet.',
},
offerTattoo: 'Offer Tattoo',
offerTraining: 'Offer Training / Physio',
organizeEvents: 'Organize Events',

View file

@ -1995,6 +1995,16 @@ labUi: {
offerPhotography: 'Ofrecer fotografía',
offerServices: 'Ofrecer servicios',
yourProfiles: 'Tus perfiles del Lab',
preview: {
button: 'Vista previa',
title: 'Vista previa en vivo',
desc: 'Así se verá tu perfil de {{noun}} cuando lo publiques.',
untitled: 'Tu nombre',
noBio: 'Aún no hay descripción — añade una línea sobre lo que ofreces.',
years: '{{count}} años de experiencia',
portfolio: 'Portafolio',
note: 'Solo vista previa — aún no se ha publicado nada.',
},
offerTattoo: 'Ofrecer tatuajes',
offerTraining: 'Ofrecer entrenamiento / fisio',
organizeEvents: 'Organizar eventos',

View file

@ -12,7 +12,6 @@
* Craft-specific *context* stays in each form's `skillSections` + `extraFields`;
* this is the craft-agnostic enrichment (how to reach them, how to pay, media).
*/
import type { NostrEvent } from '@nostrify/nostrify';
import { COACH_PAYMENT_METHODS } from '@/lib/coachOptions';
/** A stored value + its i18n key. */
@ -92,8 +91,9 @@ export interface LabExtras {
certNote?: string;
}
/** Read the shared extras off any Lab provider event (craft-agnostic). */
export function parseLabExtras(event: NostrEvent): LabExtras {
/** Read the shared extras off any Lab provider event (craft-agnostic). Accepts
* anything with a `tags` array, so draft/preview tag lists work too. */
export function parseLabExtras(event: { tags: string[][] }): LabExtras {
const tag = (n: string) => event.tags.find(([t]) => t === n)?.[1];
const vals = (n: string, lower = true): string[] => [
...new Set(

View file

@ -42,11 +42,12 @@ import {
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer, Eye } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
@ -132,6 +133,7 @@ export default function BecomeBuilderPage() {
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [lead_time, setLeadTime] = useState('');
const [warranty, setWarranty] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
@ -652,6 +654,15 @@ export default function BecomeBuilderPage() {
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-amber-400" entityCoord={user ? `${SKATE_BUILDER_KIND}:${user.pubkey}:builder-profile` : undefined} />
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="w-full h-10 gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
className="w-full bg-amber-500 hover:bg-amber-600 text-black uppercase font-black"
@ -667,6 +678,22 @@ export default function BecomeBuilderPage() {
</Card>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={t('labUi.detail.noun.builder')}
noun={t('labUi.detail.noun.builder')}
accentText="text-amber-400"
accentChip="bg-amber-500 text-black border-amber-500"
icon={HardHat}
draft={{
title: titleInput.trim() || getDisplayName(),
bio, image, location, experience, rate,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}

View file

@ -30,8 +30,9 @@ import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Check } from 'lucide-react';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Check, Eye } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
@ -154,6 +155,7 @@ export default function BecomeCoachPage() {
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
@ -298,6 +300,24 @@ export default function BecomeCoachPage() {
? t('labUi.become.invalidWebsite')
: '';
// Draft enrichment tags for the live preview (same tags the form publishes).
const previewExtraTags: string[][] = [];
for (const v of languages) previewExtraTags.push(['language', v]);
for (const v of availability) previewExtraTags.push(['availability', v]);
if (rateAmount.trim()) {
previewExtraTags.push(['rate_amount', rateAmount.trim()], ['rate_currency', rateCurrency], ['rate_unit', rateUnit]);
}
for (const m of paymentMethods) previewExtraTags.push(['payment_method', m]);
if (paymentNote.trim()) previewExtraTags.push(['payment_note', paymentNote.trim()]);
if (bookingFee.trim()) previewExtraTags.push(['booking_fee', bookingFee.trim()], ['booking_fee_currency', bookingFeeCurrency]);
if (payWhatYouCan) previewExtraTags.push(['pay_what_you_can', 'true']);
for (const p of photos) previewExtraTags.push(['photo', p]);
if (video.trim()) previewExtraTags.push(['video', video.trim()]);
if (booking.trim()) previewExtraTags.push(['booking', booking.trim()]);
const previewSkills = SKILL_ENTRIES
.filter(([skill]) => selectedSkills.includes(skill))
.map(([skill, label]) => ({ label: COACH_SKILL_KEYS[skill] ? t(COACH_SKILL_KEYS[skill]) : label }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -913,27 +933,58 @@ export default function BecomeCoachPage() {
{/* Optional Pro upgrade — free to list without it */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
<Button
type="submit"
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('labUi.become.publishing')}
</>
) : (
<>
<GraduationCap className="h-4 w-4 mr-2" />
{existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
</>
)}
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="h-10 flex-shrink-0 gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
className="flex-1 bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('labUi.become.publishing')}
</>
) : (
<>
<GraduationCap className="h-4 w-4 mr-2" />
{existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={t('labUi.coachForm.profileTitle')}
noun={t('labUi.detail.noun.coach')}
accentText="text-red-400"
accentChip="bg-red-500 text-white border-red-500"
icon={GraduationCap}
draft={{
title: titleInput.trim() || getDisplayName(),
bio,
image,
location,
experience,
rate,
skills: previewSkills,
extraTags: previewExtraTags,
contact: { phone, email, website, instagram, noffer },
}}
/>
</>
);
}

View file

@ -33,11 +33,12 @@ import { CrewLinkField } from '@/components/CrewLinkField';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Eye } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
@ -88,6 +89,7 @@ export default function BecomeEditorPage() {
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [turnaround, setTurnaround] = useState('');
const [deliverables, setDeliverables] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
@ -550,6 +552,14 @@ export default function BecomeEditorPage() {
<div className="mt-6 flex gap-3">
<Link to="/lab" className="flex-1">
<Button type="button" variant="outline" className="w-full border-gray-700">{t('common.cancel')}</Button>
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
</Link>
<Button
type="submit"
@ -565,6 +575,22 @@ export default function BecomeEditorPage() {
</div>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={t('labUi.detail.noun.editor')}
noun={t('labUi.detail.noun.editor')}
accentText="text-purple-400"
accentChip="bg-purple-600 text-white border-purple-600"
icon={Scissors}
draft={{
title: titleInput.trim() || getDisplayName(),
bio, image, location, experience, rate,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}

View file

@ -27,11 +27,12 @@ import { FILMING_SERVICE_KEYS, FILMING_SERVICE_DESC_KEYS, FILMING_SKILL_KEYS, FI
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Eye } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
@ -83,6 +84,7 @@ export default function BecomeFilmerPage() {
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [turnaround, setTurnaround] = useState('');
const [deliverables, setDeliverables] = useState('');
const [drone, setDrone] = useState('');
@ -622,6 +624,15 @@ export default function BecomeFilmerPage() {
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_FILMER_KIND}:${user.pubkey}:filming-profile` : undefined} />
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="w-full h-10 gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
@ -643,6 +654,22 @@ export default function BecomeFilmerPage() {
</Card>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={t('labUi.detail.noun.filmer')}
noun={t('labUi.detail.noun.filmer')}
accentText="text-red-400"
accentChip="bg-red-500 text-white border-red-500"
icon={Video}
draft={{
title: titleInput.trim() || getDisplayName(),
bio, image, location, experience, rate,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}

View file

@ -47,11 +47,12 @@ import {
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture, Eye } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
@ -142,6 +143,7 @@ export default function BecomePhotographerPage() {
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [turnaround, setTurnaround] = useState('');
const [deliverables, setDeliverables] = useState('');
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
@ -681,6 +683,15 @@ export default function BecomePhotographerPage() {
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-indigo-400" entityCoord={user ? `${SKATE_PHOTOGRAPHER_KIND}:${user.pubkey}:photography-profile` : undefined} />
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="w-full h-10 gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 uppercase font-black"
@ -696,6 +707,22 @@ export default function BecomePhotographerPage() {
</Card>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={t('labUi.detail.noun.photographer')}
noun={t('labUi.detail.noun.photographer')}
accentText="text-indigo-400"
accentChip="bg-indigo-500 text-white border-indigo-500"
icon={Camera}
draft={{
title: titleInput.trim() || getDisplayName(),
bio, image, location, experience, rate,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}