fix: Amber Capacitor APK + mobile UI improvements

Amber login (critical):
- Replace window.location.href with window.open(_system) in Capacitor WebViews
- Capacitor's WebView throws "malformed nostrsigner request" on href navigation
- New openAmberUrl() helper detects Capacitor via window.Capacitor.isNativePlatform()
- Add openAmberForLoginNoCallback() for PWA/Capacitor clipboard flow
- Fix all 6 Amber URL opens in amber.ts + LoginDialog.tsx

Mobile nav:
- Newsletter and Sasquatch AI (/pyramid) added to hamburger menu
- Sasquatch floating guide now shows on mobile (removed hidden md:block)

Lab page mobile:
- Action buttons (Offer Coaching/Filming/Editing/Get Card) now 2-col grid on mobile
- Service type filter (Coaches/Filmers/Editors) now 3-col grid, full width
- Reduced top padding on mobile (py-6 → py-12 on md+)
- Text truncates cleanly, icons don't overflow

Wallet page mobile:
- Reduced padding (py-6 md:py-8)
- Tab triggers use h-auto + py-2.5 to avoid overflow
- Smaller header text on mobile

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 20:48:11 +07:00
parent 576df73a6b
commit a677e8687d
5 changed files with 105 additions and 68 deletions

View file

@ -45,6 +45,8 @@ import {
Zap, Zap,
Heart, Heart,
CreditCard, CreditCard,
Radio,
Bot,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -680,6 +682,20 @@ export function Layout({ children }: LayoutProps) {
isActive={isActive('/supporters')} isActive={isActive('/supporters')}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
<NavLink
href="/newsletter"
label="Newsletter"
icon={Radio}
isActive={isActive('/newsletter')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/pyramid"
label="Sasquatch AI"
icon={Bot}
isActive={isActive('/pyramid')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink <NavLink
href="/settings" href="/settings"
label={t('nav.settings')} label={t('nav.settings')}
@ -811,13 +827,11 @@ export function Layout({ children }: LayoutProps) {
t={t} t={t}
/> />
{/* Sasquatch Guide (Desktop only) */} {/* Sasquatch Guide (all screens) */}
<div className="hidden md:block"> <SasquatchGuide
<SasquatchGuide message={currentMessage}
message={currentMessage} onDismiss={dismissCurrentMessage}
onDismiss={dismissCurrentMessage} />
/>
</div>
{/* Set Status Dialog */} {/* Set Status Dialog */}
<SetStatusDialog <SetStatusDialog

View file

@ -11,7 +11,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { subscribeToAmberReturn } from '@/lib/amber'; import { subscribeToAmberReturn, openAmberForLogin, openAmberForLoginNoCallback } from '@/lib/amber';
interface LoginDialogProps { interface LoginDialogProps {
isOpen: boolean; isOpen: boolean;
@ -96,16 +96,13 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|| ('standalone' in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true); || ('standalone' in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true);
if (isPWA) { if (isPWA) {
// ── PWA mode ────────────────────────────────────────────────────────── // ── PWA / Capacitor mode ───────────────────────────────────────────────
// When installed to home screen, the app is single-tab. Opening // When installed to home screen or running as a Capacitor APK, the app
// nostrsigner: with a callbackUrl sends the callback to the browser, // is single-tab. Use openAmberForLogin() which handles Capacitor WebViews
// not back to the PWA — so the PWA never sees the result. // correctly via window.open(_system) instead of window.location.href.
// // Amber copies the pubkey to clipboard when there's no callback.
// Instead: open Amber WITHOUT a callbackUrl. Amber copies the pubkey // When the user comes back, visibilitychange fires and we read the clipboard.
// to clipboard when there's no callback. When the user comes back, openAmberForLoginNoCallback();
// the visibilitychange fires and we auto-read the clipboard.
const amberUrl = 'nostrsigner:?compressionType=none&returnType=signature&type=get_public_key';
window.location.href = amberUrl;
// When user returns to the PWA after approving in Amber, auto-read clipboard // When user returns to the PWA after approving in Amber, auto-read clipboard
unsubAmberRef.current = subscribeToAmberReturn(async () => { unsubAmberRef.current = subscribeToAmberReturn(async () => {
@ -166,9 +163,8 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
unsubAmberRef.current = null; unsubAmberRef.current = null;
}); });
const callbackUrl = `${window.location.origin}/amber-callback?event=`; // Browser (tab) mode — use the callback URL flow
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`; openAmberForLogin();
window.location.href = amberUrl;
}; };
const handleBunkerLogin = async () => { const handleBunkerLogin = async () => {

View file

@ -246,6 +246,32 @@ function buildCallbackUrl(): string {
return `${window.location.origin}/amber-callback?event=`; return `${window.location.origin}/amber-callback?event=`;
} }
/**
* Navigate to an Amber nostrsigner: URL.
* In Capacitor WebViews, window.location.href causes a "malformed request"
* error because the WebView tries to navigate instead of firing an intent.
* Use window.open() with _system target to hand off to Android properly.
*/
function openAmberUrl(url: string): void {
// Capacitor / Android WebView: use _system to fire the intent
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
if (isCapacitor) {
window.open(url, '_system');
} else {
window.location.href = url;
}
}
/**
* Open Amber for get_public_key WITHOUT a callback URL.
* Used in PWA/Capacitor mode: Amber copies pubkey to clipboard,
* the app reads it on visibilitychange.
*/
export function openAmberForLoginNoCallback(): void {
const amberUrl = 'nostrsigner:?compressionType=none&returnType=signature&type=get_public_key';
openAmberUrl(amberUrl);
}
/** /**
* Open Amber for get_public_key operation. * Open Amber for get_public_key operation.
* Uses a dedicated /amber-callback route so the app state is preserved * Uses a dedicated /amber-callback route so the app state is preserved
@ -267,7 +293,7 @@ export function openAmberForLogin(): void {
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
console.log('[Amber] Opening Amber for login, callback:', callbackUrl); console.log('[Amber] Opening Amber for login, callback:', callbackUrl);
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**
@ -309,7 +335,7 @@ export function openAmberForSignEvent(event: Omit<NostrEvent, 'id' | 'sig'>): vo
const encodedEvent = encodeURIComponent(eventJson); const encodedEvent = encodeURIComponent(eventJson);
const amberUrl = `nostrsigner:${encodedEvent}?compressionType=none&returnType=event&type=sign_event&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:${encodedEvent}?compressionType=none&returnType=event&type=sign_event&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**
@ -330,7 +356,7 @@ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): voi
const encodedText = encodeURIComponent(plaintext); const encodedText = encodeURIComponent(plaintext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**
@ -351,7 +377,7 @@ export function openAmberForNip04Decrypt(pubkey: string, ciphertext: string): vo
const encodedText = encodeURIComponent(ciphertext); const encodedText = encodeURIComponent(ciphertext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**
@ -372,7 +398,7 @@ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): voi
const encodedText = encodeURIComponent(plaintext); const encodedText = encodeURIComponent(plaintext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**
@ -393,7 +419,7 @@ export function openAmberForNip44Decrypt(pubkey: string, ciphertext: string): vo
const encodedText = encodeURIComponent(ciphertext); const encodedText = encodeURIComponent(ciphertext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`; const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl; openAmberUrl(amberUrl);
} }
/** /**

View file

@ -102,8 +102,8 @@ export default function LabPage() {
return ( return (
<Layout> <Layout>
<div className="container px-4 py-12"> <div className="container px-4 py-6 md:py-12">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6 md:mb-8">
<div> <div>
<h1 className="text-4xl font-black uppercase mb-2 neon-header"> <h1 className="text-4xl font-black uppercase mb-2 neon-header">
<span className="neon-red">THE</span> <span className="neon-orange">LAB</span> <span className="neon-red">THE</span> <span className="neon-orange">LAB</span>
@ -111,29 +111,29 @@ export default function LabPage() {
<p className="text-gray-400">{t('labPage.subtitle')}</p> <p className="text-gray-400">{t('labPage.subtitle')}</p>
</div> </div>
{user ? ( {user ? (
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2 w-full md:w-auto">
<Link to="/become-coach"> <Link to="/become-coach" className="col-span-1">
<Button className="bg-red-500 hover:bg-red-600 gap-2 uppercase font-black"> <Button className="w-full bg-red-500 hover:bg-red-600 gap-1.5 uppercase font-black text-xs sm:text-sm">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5 flex-shrink-0" />
{t('labPage.offerCoaching')} <span className="truncate">{t('labPage.offerCoaching')}</span>
</Button> </Button>
</Link> </Link>
<Link to="/become-filmer"> <Link to="/become-filmer" className="col-span-1">
<Button className="bg-orange-500 hover:bg-orange-600 gap-2 uppercase font-black"> <Button className="w-full bg-orange-500 hover:bg-orange-600 gap-1.5 uppercase font-black text-xs sm:text-sm">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5 flex-shrink-0" />
{t('labPage.offerFilming')} <span className="truncate">{t('labPage.offerFilming')}</span>
</Button> </Button>
</Link> </Link>
<Link to="/become-editor"> <Link to="/become-editor" className="col-span-1">
<Button className="bg-purple-600 hover:bg-purple-700 gap-2 uppercase font-black"> <Button className="w-full bg-purple-600 hover:bg-purple-700 gap-1.5 uppercase font-black text-xs sm:text-sm">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5 flex-shrink-0" />
Offer Editing <span className="truncate">Offer Editing</span>
</Button> </Button>
</Link> </Link>
<Link to="/card"> <Link to="/card" className="col-span-1">
<Button variant="outline" className="gap-2 uppercase font-black border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"> <Button variant="outline" className="w-full gap-1.5 uppercase font-black border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs sm:text-sm">
<CreditCard className="h-4 w-4" /> <CreditCard className="h-3.5 w-3.5 flex-shrink-0" />
Get Your Card <span className="truncate">Get Your Card</span>
</Button> </Button>
</Link> </Link>
</div> </div>
@ -145,7 +145,7 @@ export default function LabPage() {
</div> </div>
{/* Service Type Filter */} {/* Service Type Filter */}
<div className="flex gap-2 mb-6"> <div className="grid grid-cols-3 gap-2 mb-6">
<Button <Button
onClick={() => { onClick={() => {
setServiceType('coaching'); setServiceType('coaching');
@ -153,12 +153,12 @@ export default function LabPage() {
}} }}
variant={serviceType === 'coaching' ? 'default' : 'outline'} variant={serviceType === 'coaching' ? 'default' : 'outline'}
className={cn( className={cn(
'uppercase font-bold gap-2', 'uppercase font-bold gap-1.5 text-xs sm:text-sm',
serviceType === 'coaching' ? 'bg-red-500 hover:bg-red-600' : 'border-gray-700' serviceType === 'coaching' ? 'bg-red-500 hover:bg-red-600' : 'border-gray-700'
)} )}
> >
<GraduationCap className="h-4 w-4" /> <GraduationCap className="h-4 w-4 flex-shrink-0" />
{t('labPage.coachesTab')} <span className="truncate">{t('labPage.coachesTab')}</span>
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@ -167,12 +167,12 @@ export default function LabPage() {
}} }}
variant={serviceType === 'filming' ? 'default' : 'outline'} variant={serviceType === 'filming' ? 'default' : 'outline'}
className={cn( className={cn(
'uppercase font-bold gap-2', 'uppercase font-bold gap-1.5 text-xs sm:text-sm',
serviceType === 'filming' ? 'bg-orange-500 hover:bg-orange-600' : 'border-gray-700' serviceType === 'filming' ? 'bg-orange-500 hover:bg-orange-600' : 'border-gray-700'
)} )}
> >
<Video className="h-4 w-4" /> <Video className="h-4 w-4 flex-shrink-0" />
{t('labPage.filmersTab')} <span className="truncate">{t('labPage.filmersTab')}</span>
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@ -181,12 +181,13 @@ export default function LabPage() {
}} }}
variant={serviceType === 'editing' ? 'default' : 'outline'} variant={serviceType === 'editing' ? 'default' : 'outline'}
className={cn( className={cn(
'uppercase font-bold gap-2', 'uppercase font-bold gap-1.5 text-xs sm:text-sm',
serviceType === 'editing' ? 'bg-purple-600 hover:bg-purple-700' : 'border-gray-700' serviceType === 'editing' ? 'bg-purple-600 hover:bg-purple-700' : 'border-gray-700'
)} )}
> >
<Scissors className="h-4 w-4" /> <Scissors className="h-4 w-4 flex-shrink-0" />
Video Editors <span className="truncate hidden sm:inline">Video Editors</span>
<span className="truncate sm:hidden">Editors</span>
</Button> </Button>
</div> </div>

View file

@ -295,29 +295,29 @@ export default function WalletPage() {
return ( return (
<Layout> <Layout>
<div className="container px-4 py-8"> <div className="container px-4 py-6 md:py-8">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-6 md:mb-8">
<h1 className="text-3xl font-bold">Wallet</h1> <h1 className="text-2xl md:text-3xl font-bold">Wallet</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm md:text-base">
Manage your Bitcoin and send zaps to skaters and creators Manage your Bitcoin and send zaps to skaters and creators
</p> </p>
</div> </div>
{user ? ( {user ? (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Wallet Comparison Guide */} {/* Wallet Comparison Guide */}
<WalletComparisonGuide /> <WalletComparisonGuide />
<Tabs defaultValue="cashu" className="w-full"> <Tabs defaultValue="cashu" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className="grid w-full grid-cols-2 mb-4 md:mb-6 h-auto">
<TabsTrigger value="cashu" className="gap-2"> <TabsTrigger value="cashu" className="gap-1.5 py-2.5 text-sm">
<Nut className="h-4 w-4" /> <Nut className="h-4 w-4 flex-shrink-0" />
Cashu Wallet <span>Cashu Wallet</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="lightning" className="gap-2"> <TabsTrigger value="lightning" className="gap-1.5 py-2.5 text-sm">
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4 flex-shrink-0" />
Lightning <span>Lightning</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>