diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 054ff74..664bc05 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -127,6 +127,8 @@ export function AppRouter() { } /> } /> } /> + {/* Canonical per-craft provider URL (the craft segment is cosmetic). */} + } /> } /> } /> } /> diff --git a/src/components/CollectiveJoinRequestCard.tsx b/src/components/CollectiveJoinRequestCard.tsx index 5e35dfb..635439d 100644 --- a/src/components/CollectiveJoinRequestCard.tsx +++ b/src/components/CollectiveJoinRequestCard.tsx @@ -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( diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index 39631bd..57704e2 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -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' }); } diff --git a/src/components/CrossLinkCards.tsx b/src/components/CrossLinkCards.tsx index 9d1a3f3..e25df83 100644 --- a/src/components/CrossLinkCards.tsx +++ b/src/components/CrossLinkCards.tsx @@ -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 (
diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index e582bde..012012b 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -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') ?? '')}`, }; }); diff --git a/src/lib/labRoutes.ts b/src/lib/labRoutes.ts new file mode 100644 index 0000000..fdc1e83 --- /dev/null +++ b/src/lib/labRoutes.ts @@ -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 = { + [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///`. + * + * 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); +} diff --git a/src/main.tsx b/src/main.tsx index c1f5324..bc27b52 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,12 +16,14 @@ if (typeof window !== 'undefined') { } } -// Clean share links: a bare /verify/, /card/ or /coach// -// hits the Netlify SPA fallback (index.html), but HashRouter needs the route in -// the hash — rewrite it. +// Clean share links: a bare /verify/, /card/, /coach// +// or /lab/// 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 diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index 67a52e6..5678c26 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -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 ( ); diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 9b06718..91a4f17 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -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 && ( diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index d04035c..635bdbd 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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 ( - + diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index e5bec29..d4eb928 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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 ( - +
( diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index db9eba0..d6de63e 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -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) => (
@@ -932,7 +933,7 @@ export default function SpotDetailPage() { {spotServices.filmers.map((filmer) => (