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:
parent
dd437693ab
commit
81b5fd0fab
11 changed files with 479 additions and 34 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
215
src/components/LabProfilePreview.tsx
Normal file
215
src/components/LabProfilePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue