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) => (