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

View file

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

View file

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

View file

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