Give Lab profiles a per-craft URL: /lab/<craft>/<pubkey>/<id>

Every provider lived at /coach/<pubkey>/<id> regardless of craft (a barber got
a "coach" URL). New src/lib/labRoutes.ts maps craft kind → segment and builds
the canonical path; the router adds /lab/:craft/:pubkey/:identifier (the craft
segment is cosmetic — the page still resolves by pubkey + identifier) and keeps
/coach/... working for old links. All generated links updated: offer-form
redirects, Lab cards + "Your Lab profiles", cross-links, spot/event/admin links,
the CVM client URL, the share path, and the SPA clean-link shim.
This commit is contained in:
SkateSpots 2026-10-04 11:32:15 +07:00
parent 81b5fd0fab
commit c794d5fd18
18 changed files with 78 additions and 20 deletions

View file

@ -127,6 +127,8 @@ export function AppRouter() {
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />
<Route path="/crew/:pubkey/:identifier" element={<CrewDetailPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
{/* Canonical per-craft provider URL (the craft segment is cosmetic). */}
<Route path="/lab/:craft/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />
<Route path="/marketplace" element={<MarketplaceHubPage />} />

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -47,7 +48,7 @@ export function CollectiveJoinRequestCard({
const { toast } = useToast();
const coord = parseAddrCoord(request.collectiveCoord);
const collectiveHref = coord ? `/coach/${coord.pubkey}/${coord.identifier}` : '/lab';
const collectiveHref = coord ? labProfilePathFromKind(coord.kind, coord.pubkey, coord.identifier) : '/lab';
// Resolve the collective so we can add the member in one click.
const { data: profile, isLoading: profileLoading } = useLabProfile(

View file

@ -25,6 +25,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { isValidNoffer, type EntityType } from '@/lib/types';
import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -310,7 +311,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
await createEvent({ kind, content: bio.trim(), tags });
toast({ title: t(existingProfile ? 'labUi.craftForm.updated' : 'labUi.craftForm.created'), description: t('labUi.become.profilePublished') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePathFromKind(kind, user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}

View file

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Users, GraduationCap, ChevronRight, Link2, BadgeCheck } from 'lucide-react';
@ -96,7 +97,7 @@ function LabRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
return (
<Link
to={`/coach/${parsed.pubkey}/${parsed.identifier}`}
to={labProfilePathFromKind(parsed.kind, parsed.pubkey, parsed.identifier)}
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/5">

View file

@ -8,6 +8,7 @@
*/
import { useCallback } from 'react';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { useNostr } from '@nostrify/react';
import { verifyEvent } from 'nostr-tools';
@ -621,7 +622,7 @@ export function useCVMClient() {
experience: getTag(ev, 'experience'),
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
naddr: buildNaddr(ev),
url: `https://skate-spots.org/coach/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
url: `https://skate-spots.org${labProfilePathFromKind(ev.kind, ev.pubkey, getTag(ev, 'd') ?? '')}`,
};
});

39
src/lib/labRoutes.ts Normal file
View file

@ -0,0 +1,39 @@
import {
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND,
SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND,
SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
} from '@/lib/types';
/** Lab craft kind → canonical URL segment (matches `becomeRoles` slugs). */
export const LAB_TYPE_BY_KIND: Record<number, string> = {
[SKATE_COACH_KIND]: 'coach',
[SKATE_FILMER_KIND]: 'filmer',
[SKATE_EDITOR_KIND]: 'editor',
[SKATE_PHOTOGRAPHER_KIND]: 'photographer',
[SKATE_BUILDER_KIND]: 'builder',
[SKATE_ARTIST_KIND]: 'artist',
[SKATE_GRAFFITI_KIND]: 'graffiti',
[SKATE_TATTOO_KIND]: 'tattoo',
[SKATE_TRAINER_KIND]: 'trainer',
[SKATE_MUSICIAN_KIND]: 'musician',
[SKATE_JUDGE_KIND]: 'judge',
[SKATE_ORGANIZER_KIND]: 'organizer',
[SKATE_BARBER_KIND]: 'barber',
};
/**
* Canonical path for a Lab provider profile: `/lab/<craft>/<pubkey>/<identifier>`.
*
* The craft segment is cosmetic (the detail page resolves by pubkey + identifier
* across all kinds) but gives each craft a meaningful, shareable URL instead of
* every provider living under `/coach/…`.
*/
export function labProfilePath(profileType: string, pubkey: string, identifier: string): string {
return `/lab/${profileType}/${pubkey}/${identifier}`;
}
/** Same as {@link labProfilePath} but from a kind number (e.g. a coord). */
export function labProfilePathFromKind(kind: number, pubkey: string, identifier: string): string {
return labProfilePath(LAB_TYPE_BY_KIND[kind] ?? 'coach', pubkey, identifier);
}

View file

@ -16,12 +16,14 @@ if (typeof window !== 'undefined') {
}
}
// Clean share links: a bare /verify/<naddr>, /card/<npub> or /coach/<pubkey>/<d>
// hits the Netlify SPA fallback (index.html), but HashRouter needs the route in
// the hash — rewrite it.
// Clean share links: a bare /verify/<naddr>, /card/<npub>, /coach/<pubkey>/<d>
// or /lab/<craft>/<pubkey>/<d> hits the Netlify SPA fallback (index.html), but
// HashRouter needs the route in the hash — rewrite it.
if (typeof window !== 'undefined' && !window.location.hash) {
const m = window.location.pathname.match(/^\/(verify|card|coach)\/(.+)$/);
const labProfile = window.location.pathname.match(/^\/lab\/[^/]+\/[^/]+\/[^/]+$/);
if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`);
else if (labProfile) window.location.replace(`/#${window.location.pathname}${window.location.search}`);
}
// Outfit font for body text

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { labProfilePath } from '@/lib/labRoutes';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
@ -644,7 +645,7 @@ function LabQueue() {
image: p.image,
imageCount: p.image ? 1 : 0,
hasLocation: !!p.geohash,
href: `/coach/${p.pubkey}/${p.identifier}`,
href: labProfilePath(p.profileType, p.pubkey, p.identifier),
}));
return (
<QueueList

View file

@ -48,6 +48,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
@ -326,7 +327,7 @@ export default function BecomeBuilderPage() {
});
toast({ title: existingProfile ? t('labUi.builderForm.updated') : t('labUi.builderForm.created'), description: t('labUi.become.profilePublished') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePath('builder', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}

View file

@ -33,6 +33,7 @@ import { cn } from '@/lib/utils';
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 { labProfilePath } from '@/lib/labRoutes';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
@ -413,7 +414,7 @@ export default function BecomeCoachPage() {
});
toast({ title: existingProfile ? t('labUi.coachForm.updated') : t('labUi.coachForm.created'), description: t('labUi.become.profilePublished') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePath('coach', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}

View file

@ -39,6 +39,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
@ -251,7 +252,7 @@ export default function BecomeEditorPage() {
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
toast({ title: existingProfile ? t('labUi.editorForm.updated') : t('labUi.editorForm.created'), description: t('labUi.editorForm.published') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePath('editor', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (err) {
toast({ title: t('labUi.become.failedToPublish'), description: err instanceof Error ? err.message : t('common.error'), variant: 'destructive' });
}

View file

@ -33,6 +33,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
@ -283,7 +284,7 @@ export default function BecomeFilmerPage() {
});
toast({ title: existingProfile ? t('labUi.filmerForm.updated') : t('labUi.filmerForm.created'), description: t('labUi.become.profilePublished') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePath('filmer', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}

View file

@ -53,6 +53,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
const SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
@ -350,7 +351,7 @@ export default function BecomePhotographerPage() {
});
toast({ title: existingProfile ? t('labUi.photoForm.updated') : t('labUi.photoForm.created'), description: t('labUi.become.profilePublished') });
navigate(`/coach/${user?.pubkey}/${identifier}${existingProfile ? '' : '?published=1'}`);
navigate(`${labProfilePath('photographer', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}

View file

@ -41,6 +41,7 @@ import {
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
import { parseLabExtras } from '@/lib/labExtras';
import { CRAFT_DETAIL_FIELDS } from '@/lib/craftFields';
import { labProfilePath } from '@/lib/labRoutes';
import { LabExtrasSection } from '@/components/LabExtrasSection';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
@ -991,7 +992,7 @@ export default function CoachDetailPage() {
onOpenChange={setShareOpen}
npub={nip19.npubEncode(profile.pubkey)}
displayName={profile.title}
path={`/coach/${profile.pubkey}/${profile.identifier}`}
path={labProfilePath(profile.profileType, profile.pubkey, profile.identifier)}
/>
</>
);

View file

@ -1,4 +1,5 @@
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { labProfilePath } from '@/lib/labRoutes';
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
@ -416,7 +417,7 @@ export default function EventDetailPage() {
)}
{musicAct && (
<Link
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
to={labProfilePath('musician', musicAct.pubkey, musicAct.identifier)}
className="inline-flex items-center gap-1.5 rounded-full border border-fuchsia-400/50 bg-fuchsia-500/20 px-3 py-1 text-sm font-bold text-fuchsia-200 backdrop-blur transition-colors hover:bg-fuchsia-500/30"
>
<Music className="h-3.5 w-3.5" />

View file

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { labProfilePath } from '@/lib/labRoutes';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
@ -427,7 +428,7 @@ function LabProfileCard({ profile }: { profile: LabProfile }) {
const accentColor = isCoach ? '#ff0040' : '#ff6600';
return (
<Link to={`/coach/${profile.pubkey}/${profile.identifier}`}>
<Link to={labProfilePath(profile.profileType, profile.pubkey, profile.identifier)}>
<Card className="bg-black/90 border-2 hover:shadow-2xl transition-all h-full backdrop-blur" style={{
borderColor: `${accentColor}40`,
}}>

View file

@ -7,6 +7,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { COACH_FACETS, coachOptionKey, emptyCoachFilters, type CoachFacetKey } from '@/lib/coachOptions';
import { parseLabExtras, type LabExtras } from '@/lib/labExtras';
import { labProfilePath } from '@/lib/labRoutes';
import { type SortOption } from '@/components/SortDropdown';
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
@ -1081,7 +1082,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType, t);
return (
<Link to={`/coach/${profile.pubkey}/${profile.identifier}`}>
<Link to={labProfilePath(profile.profileType, profile.pubkey, profile.identifier)}>
<div className={cn(
'group relative flex gap-3 p-4 rounded-2xl bg-white/3 border border-white/8',
'hover:bg-white/5 hover:border-white/15 hover:shadow-xl',
@ -1642,7 +1643,7 @@ export default function LabPage() {
{ownProfiles.map((p) => (
<Link
key={p.id}
to={`/coach/${p.pubkey}/${p.identifier}`}
to={labProfilePath(p.profileType, p.pubkey, p.identifier)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-bold text-gray-200 transition-colors hover:bg-white/10"
>
<Pencil className="h-3 w-3 text-[#ccff00]" />

View file

@ -1,4 +1,5 @@
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { labProfilePath } from '@/lib/labRoutes';
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom';
@ -889,7 +890,7 @@ export default function SpotDetailPage() {
{spotServices.coaches.map((coach) => (
<Link
key={coach.id}
to={`/coach/${coach.pubkey}/${coach.identifier}`}
to={labProfilePath('coach', coach.pubkey, coach.identifier)}
className="block"
>
<div className="flex items-center gap-4 p-4 rounded-lg bg-lime-500/5 border border-lime-500/20 hover:bg-lime-500/10 hover:border-lime-500/40 transition-colors">
@ -932,7 +933,7 @@ export default function SpotDetailPage() {
{spotServices.filmers.map((filmer) => (
<Link
key={filmer.id}
to={`/coach/${filmer.pubkey}/${filmer.identifier}`}
to={labProfilePath('filmer', filmer.pubkey, filmer.identifier)}
className="block"
>
<div className="flex items-center gap-4 p-4 rounded-lg bg-purple-500/5 border border-purple-500/20 hover:bg-purple-500/10 hover:border-purple-500/40 transition-colors">