Add owner Edit + Share to Lab provider profiles

The offer → publish loop had two dead ends: a provider's own page only offered
Delete (no Edit), and there was no way to share a listing (the whole point of
announcing a service).

- Owner "Manage" card gains an "Edit profile" button deep-linking to
  /become/<craft> (prefills the existing profile). Admins still get only
  take-down, not a misleading self-edit link.
- A Share button on every provider page opens ShareProfileDialog (QR + copy +
  native share) with the service URL /coach/<pubkey>/<identifier>.
- ShareProfileDialog accepts an optional `path` to override the default
  /<npub> share URL.
- main.tsx clean-link shim now also rewrites /coach/<pubkey>/<d> to the hash
  route, so shared /coach/... links resolve on the live site.

Follow-ups (not done): live preview before publishing, draft/autosave for the
long forms, a multi-step/progress layout, and a "your Lab profiles" manage
strip on /lab.
This commit is contained in:
SkateSpots 2026-10-04 11:16:54 +07:00
parent 681eab9592
commit 9d1a6999ea
5 changed files with 39 additions and 5 deletions

View file

@ -13,6 +13,9 @@ interface ShareProfileDialogProps {
npub: string;
/** Display name, used in the share sheet + QR caption. */
displayName: string;
/** Optional path (e.g. `/coach/<pubkey>/<d>`) appended to the canonical origin
* instead of the default `/<npub>` — for sharing a specific Lab profile. */
path?: string;
}
/**
@ -21,7 +24,7 @@ interface ShareProfileDialogProps {
* than a raw npub. Falls back gracefully to copy-link when the native share
* sheet isn't available.
*/
export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: ShareProfileDialogProps) {
export function ShareProfileDialog({ open, onOpenChange, npub, displayName, path }: ShareProfileDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [qr, setQr] = useState<string | null>(null);
@ -31,7 +34,7 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
// real domain — never the preview/sandbox iframe host that window.location
// would give us inside the editor.
const CANONICAL_ORIGIN = 'https://skate-spots.org';
const shareUrl = `${CANONICAL_ORIGIN}/${npub}`;
const shareUrl = `${CANONICAL_ORIGIN}${path ?? `/${npub}`}`;
useEffect(() => {
if (!open) return;

View file

@ -2597,6 +2597,7 @@ labUi: {
portfolioDomain: 'Portfolio · {{domain}}',
manage: 'Manage',
admin: 'Admin',
editProfile: 'Edit profile',
manageRemove: 'Remove your {{noun}} profile from The Lab.',
adminTakeDown: 'Take down this {{noun}} profile. It will be hidden across SkateSpots.',
entityLabel: '{{noun}} profile',

View file

@ -2597,6 +2597,7 @@ labUi: {
portfolioDomain: 'Portafolio · {{domain}}',
manage: 'Gestionar',
admin: 'Admin',
editProfile: 'Editar perfil',
manageRemove: 'Elimina tu perfil de {{noun}} de The Lab.',
adminTakeDown: 'Retira este perfil de {{noun}}. Quedará oculto en todo SkateSpots.',
entityLabel: 'perfil de {{noun}}',

View file

@ -16,10 +16,11 @@ if (typeof window !== 'undefined') {
}
}
// Clean share links: a bare /verify/<naddr> or /card/<npub> 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> or /coach/<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)\/(.+)$/);
const m = window.location.pathname.match(/^\/(verify|card|coach)\/(.+)$/);
if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`);
}

View file

@ -1,4 +1,5 @@
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { PageError } from '@/components/ErrorState';
@ -41,6 +42,7 @@ import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, coach
import { parseLabExtras } from '@/lib/labExtras';
import { CRAFT_DETAIL_FIELDS } from '@/lib/craftFields';
import { LabExtrasSection } from '@/components/LabExtrasSection';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge';
@ -71,6 +73,8 @@ import {
Tent,
Sparkles,
ShieldCheck,
Share2,
Pencil,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -110,6 +114,7 @@ export default function CoachDetailPage() {
const author = useAuthor(profile?.pubkey);
const { user } = useCurrentUser();
const navigate = useNavigate();
const [shareOpen, setShareOpen] = useState(false);
// Gigs — only relevant for musicians. The hook is disabled for other types.
const { data: gigs = [] } = useGigs(
@ -360,6 +365,14 @@ export default function CoachDetailPage() {
featuredUntil={profile.featuredUntil}
entityCoord={`${profile.event.kind}:${profile.pubkey}:${profile.identifier}`}
/>
<Button
variant="outline"
size="sm"
onClick={() => setShareOpen(true)}
className={`ml-auto h-7 gap-1.5 px-2.5 ${accent.border} ${accent.text} hover:bg-white/5`}
>
<Share2 className="h-3.5 w-3.5" /> {t('common.share')}
</Button>
</div>
<p className="text-gray-400 flex items-center gap-2">
<Calendar className="h-4 w-4" />
@ -878,6 +891,13 @@ export default function CoachDetailPage() {
? t('labUi.detail.manageRemove', { noun: typeNoun })
: t('labUi.detail.adminTakeDown', { noun: typeNoun })}
</p>
{user.pubkey === profile.pubkey && (
<Link to={`/become/${profile.profileType}`}>
<Button variant="outline" className={`w-full justify-center gap-2 ${accent.border} ${accent.text} hover:bg-white/5`}>
<Pencil className="h-4 w-4" /> {t('labUi.detail.editProfile')}
</Button>
</Link>
)}
<DeleteEntityDialog
event={profile.event}
entityLabel={t('labUi.detail.entityLabel', { noun: typeNoun })}
@ -936,6 +956,14 @@ export default function CoachDetailPage() {
</div>
</div>
</div>
<ShareProfileDialog
open={shareOpen}
onOpenChange={setShareOpen}
npub={nip19.npubEncode(profile.pubkey)}
displayName={profile.title}
path={`/coach/${profile.pubkey}/${profile.identifier}`}
/>
</>
);
}