diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 17649c3..081b6dd 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
-import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { HashRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
+import { DeepLinkHandler } from "./components/DeepLinkHandler";
// Eagerly load only the most-visited pages
import Index from "./pages/Index";
@@ -61,7 +62,8 @@ function PageLoader() {
export function AppRouter() {
return (
-
+
+
}>
@@ -122,7 +124,7 @@ export function AppRouter() {
} />
-
+
);
}
export default AppRouter;
diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx
new file mode 100644
index 0000000..f97c751
--- /dev/null
+++ b/src/components/DeepLinkHandler.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+/**
+ * Forwards OS-level deep-link opens into the React Router navigation stack.
+ *
+ * When the OS launches the app via a URL (Amber NIP-55 callback, custom scheme,
+ * or universal link), Capacitor fires an `appUrlOpen` event. This component
+ * listens for it and navigates to the correct in-app route.
+ *
+ * Uses HashRouter compatible path extraction — strips the origin/scheme so
+ * only the pathname+search+hash is passed to navigate().
+ *
+ * Must be rendered inside a Router (so `useNavigate` works).
+ * Safe to render unconditionally — on web it is a no-op.
+ *
+ * Amber deep-link example:
+ * org.skatespots.app://amber-callback?event=
+ * → navigates to /amber-callback?event=
+ */
+export function DeepLinkHandler() {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ // Only run in Capacitor native context
+ const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
+ if (!cap?.isNativePlatform?.()) return;
+
+ let removeListener: (() => void) | undefined;
+
+ async function setup() {
+ try {
+ const { App } = await import('@capacitor/app');
+
+ const listener = await App.addListener('appUrlOpen', (event) => {
+ try {
+ const url = new URL(event.url);
+ // Extract path + query + fragment from the deep link URL
+ const path = url.pathname + url.search + url.hash;
+ if (path && path !== '/') {
+ navigate(path, { replace: true });
+ }
+ } catch {
+ // Invalid URL — ignore
+ }
+ });
+
+ removeListener = () => listener.remove();
+ } catch {
+ // @capacitor/app not available (web build without Capacitor installed)
+ }
+ }
+
+ setup();
+
+ return () => {
+ removeListener?.();
+ };
+ }, [navigate]);
+
+ return null;
+}
diff --git a/src/lib/amber.ts b/src/lib/amber.ts
index d3d682e..1648b9c 100644
--- a/src/lib/amber.ts
+++ b/src/lib/amber.ts
@@ -178,12 +178,34 @@ export function clearAmberLogin(): void {
localStorage.removeItem(AMBER_LOGIN_KEY);
}
+/**
+ * Extract search params from both the real query string AND the hash fragment.
+ *
+ * With HashRouter the URL looks like:
+ * https://localhost/#/amber-callback?event=XXXX
+ * In that case `window.location.search` is empty but the params are embedded
+ * inside `window.location.hash` after the path segment.
+ */
+function getSearchParams(): URLSearchParams {
+ // Normal query string (PWA / browser)
+ if (window.location.search) {
+ return new URLSearchParams(window.location.search);
+ }
+ // HashRouter: extract the query string portion from the hash
+ const hash = window.location.hash; // e.g. "#/amber-callback?event=XXX"
+ const qIndex = hash.indexOf('?');
+ if (qIndex !== -1) {
+ return new URLSearchParams(hash.slice(qIndex));
+ }
+ return new URLSearchParams();
+}
+
/**
* Check if the current page was opened as an Amber callback.
* Also checks pre-captured data from main.tsx for PWA resilience.
*/
export function isAmberCallback(): boolean {
- const params = new URLSearchParams(window.location.search);
+ const params = getSearchParams();
if (params.has('event')) return true;
// Check pre-captured data (PWAs might lose URL params during hydration)
@@ -201,7 +223,7 @@ export function isAmberCallback(): boolean {
* Falls back to pre-captured data from main.tsx.
*/
export function getAmberCallbackResult(): string | null {
- const params = new URLSearchParams(window.location.search);
+ const params = getSearchParams();
const fromUrl = params.get('event');
if (fromUrl) {
@@ -225,10 +247,17 @@ export function getAmberCallbackResult(): string | null {
/**
* Clean the URL after processing Amber callback.
+ * With HashRouter we replace the hash to remove the query params.
*/
export function cleanAmberCallbackUrl(): void {
- const cleanUrl = window.location.origin + window.location.pathname;
- window.history.replaceState({}, '', cleanUrl);
+ if (window.location.hash.includes('?')) {
+ // HashRouter: strip query from hash fragment
+ const hashPath = window.location.hash.split('?')[0];
+ window.history.replaceState({}, '', window.location.pathname + hashPath);
+ } else {
+ const cleanUrl = window.location.origin + window.location.pathname;
+ window.history.replaceState({}, '', cleanUrl);
+ }
}
/**
@@ -240,9 +269,22 @@ function generateOperationId(): string {
/**
* Build the dedicated Amber callback URL.
- * Using a fixed /amber-callback path so Amber always returns to the same place.
+ *
+ * In the Capacitor APK we must use the app's custom URI scheme so Android fires
+ * an intent back into the app (not a browser navigation).
+ * Web/PWA builds use the normal https origin.
+ *
+ * The app ID is `org.skatespots.app` — Capacitor registers this as a URI scheme
+ * automatically via the `appId` in capacitor.config.ts.
*/
function buildCallbackUrl(): string {
+ const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
+ if (isCapacitor) {
+ // Use the app's custom scheme so Amber fires the intent back into the APK.
+ // DeepLinkHandler in AppRouter.tsx catches appUrlOpen and navigates to the
+ // correct route inside the HashRouter.
+ return 'org.skatespots.app://amber-callback?event=';
+ }
return `${window.location.origin}/amber-callback?event=`;
}
diff --git a/src/main.tsx b/src/main.tsx
index 80698ca..fbf0e86 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -29,46 +29,40 @@ import '@fontsource/barlow-condensed/700.css';
/**
* AMBER CALLBACK PRE-CHECK
- *
+ *
* PWAs can sometimes lose URL parameters during React hydration.
* This runs BEFORE React renders to capture Amber callback data.
* We store it in sessionStorage so the AmberCallbackHandler can retrieve it.
+ *
+ * Handles both:
+ * - Normal query string: ?event=XXX (PWA / browser)
+ * - HashRouter fragment: #/amber-callback?event=XXX (Capacitor APK)
*/
(function captureAmberCallback() {
- const params = new URLSearchParams(window.location.search);
- const event = params.get('event');
-
- if (event) {
+ // Try normal query string first
+ let eventParam = new URLSearchParams(window.location.search).get('event');
+
+ // Try hash fragment (HashRouter + Capacitor deep link)
+ 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 in URL');
- console.log('[Amber Pre-Check] Full URL:', window.location.href);
- console.log('[Amber Pre-Check] Event param:', event);
-
- // Store the callback data immediately before React can interfere
try {
- sessionStorage.setItem('amber_callback_event', event);
+ sessionStorage.setItem('amber_callback_event', eventParam);
sessionStorage.setItem('amber_callback_url', window.location.href);
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
- console.log('[Amber Pre-Check] Stored callback data in sessionStorage');
} catch (e) {
console.error('[Amber Pre-Check] Failed to store callback:', e);
}
}
})();
-// Global error trap — shows error on screen in the APK so we can debug
-// without needing USB/adb. Remove once white screen is fixed.
-window.onerror = (msg, src, line, col, err) => {
- const div = document.createElement('div');
- div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:#000;color:#ff0040;padding:16px;font:14px monospace;z-index:99999;overflow:auto;white-space:pre-wrap;word-break:break-all;';
- div.textContent = `ERROR:\n${msg}\n\n${src}:${line}:${col}\n\n${err?.stack || ''}`;
- document.body.appendChild(div);
-};
-window.onunhandledrejection = (e) => {
- const div = document.createElement('div');
- div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:#000;color:#ff6600;padding:16px;font:14px monospace;z-index:99999;overflow:auto;white-space:pre-wrap;word-break:break-all;';
- div.textContent = `UNHANDLED REJECTION:\n${e.reason?.stack || e.reason || 'unknown'}`;
- document.body.appendChild(div);
-};
createRoot(document.getElementById("root")!).render(