SkateSpots/src/components/LabProfilePreview.tsx
SkateSpots 81b5fd0fab 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.
2026-10-04 11:25:58 +07:00

215 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}