SkateSpots/src/main.tsx
SkateSpots 9d1a6999ea 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.
2026-10-04 11:16:54 +07:00

133 lines
5 KiB
TypeScript

import { createRoot } from 'react-dom/client';
// Import polyfills first
import './lib/polyfills.ts';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import App from './App.tsx';
import './index.css';
// Hide Capacitor splash screen once JS is ready
// Uses the global Capacitor object to avoid a hard dependency on the package
if (typeof window !== 'undefined') {
const cap = (window as unknown as { Capacitor?: { Plugins?: { SplashScreen?: { hide: () => Promise<void> } } } }).Capacitor;
if (cap?.Plugins?.SplashScreen) {
cap.Plugins.SplashScreen.hide().catch(() => {});
}
}
// 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|coach)\/(.+)$/);
if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`);
}
// Outfit font for body text
import '@fontsource-variable/outfit';
// Bebas Neue — bold condensed display font for headlines
import '@fontsource/bebas-neue';
// Barlow Condensed — tight condensed font for subheadings and UI labels
import '@fontsource/barlow-condensed/400.css';
import '@fontsource/barlow-condensed/600.css';
import '@fontsource/barlow-condensed/700.css';
// Leaflet CSS is loaded via CDN in index.html
/**
* AMBER CALLBACK PRE-CHECK + REDIRECT
*
* Amber opens the callbackUrl in a browser tab. Because we use HashRouter,
* the callbackUrl has no # fragment — it's a plain path:
* https://skate-spots.org/amber-callback?event=<pubkey>
*
* This IIFE runs before React mounts and:
* 1. Captures ?event= from the URL into sessionStorage
* 2. If we're on /amber-callback (non-hash path), redirects to /#/amber-callback
* so HashRouter can match the route and render AmberCallbackPage
*/
(function captureAmberCallback() {
// Try normal query string first (Amber callback: /amber-callback?event=XXX)
let eventParam = new URLSearchParams(window.location.search).get('event');
// Try hash fragment (Capacitor deep link: #/amber-callback?event=XXX)
if (!eventParam) {
const hash = window.location.hash;
const qIdx = hash.indexOf('?');
if (qIdx !== -1) {
eventParam = new URLSearchParams(hash.slice(qIdx)).get('event');
}
}
if (eventParam) {
console.log('[Amber Pre-Check] Detected Amber callback, event:', eventParam.substring(0, 16) + '...');
try {
sessionStorage.setItem('amber_callback_event', eventParam);
sessionStorage.setItem('amber_callback_url', window.location.href);
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
} catch (e) {
console.error('[Amber Pre-Check] Failed to store callback:', e);
}
// If we landed on /amber-callback (non-hash path), redirect into HashRouter
// so React Router can match the route and render AmberCallbackPage.
// The event is already saved in sessionStorage above so it survives the redirect.
const isHashRoute = window.location.hash.startsWith('#/');
if (!isHashRoute && window.location.pathname.includes('amber-callback')) {
console.log('[Amber Pre-Check] Redirecting to HashRouter route: /#/amber-callback');
window.location.replace(window.location.origin + '/#/amber-callback');
// Stop — the page will reload at the correct hash route
return;
}
}
})();
/**
* STALE-DEPLOY SELF-HEAL
*
* Deploys replace hashed chunk filenames and delete the old ones. A tab that
* was already open still holds the previous manifest, so navigating to a lazy
* route loads a chunk that no longer exists (404) and the error boundary shows
* "THIS PAGE SLAMMED". Vite emits `vite:preloadError` for exactly this case.
*
* Reload once to pick up the new assets. The timestamp guard prevents a reload
* loop if a build is genuinely broken (the error boundary will show instead).
*/
(function recoverFromStaleChunks() {
const KEY = 'skatespots:chunk-reload-at';
const reload = () => {
try {
const last = Number(sessionStorage.getItem(KEY) || 0);
if (Date.now() - last < 15000) return; // already reloaded recently — don't loop
sessionStorage.setItem(KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable — fall through and reload anyway */
}
window.location.reload();
};
window.addEventListener('vite:preloadError', reload);
window.addEventListener('unhandledrejection', (e) => {
const msg = String((e as PromiseRejectionEvent).reason?.message ?? '');
if (/dynamically imported module|Importing a module script failed|Loading chunk/i.test(msg)) {
reload();
}
});
})();
createRoot(document.getElementById("root")!).render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);
// Tell the boot crash guard (in index.html) that the app mounted successfully,
// so its "did not render" timeout doesn't fire.
try {
(window as unknown as { __SKATESPOTS_BOOTED__?: () => void }).__SKATESPOTS_BOOTED__?.();
} catch {
/* no-op */
}