Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/src/app/core/services/wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export class SolanaWalletService {
this.account.set(null);
}

HasWallet(): boolean {
return this.wallet() !== null;
}

GetAddress(): string {
const connectedAccount = this.account();
if (!connectedAccount) return '';
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/app/features/checkout/checkout.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,39 @@
</div>
}
</div>
} @else { @if (NeedsMobileWalletHandoff()) {
<section
class="mobile-wallet-handoff"
aria-labelledby="mobile-wallet-title"
>
<div class="mobile-wallet-handoff-icon">
<img src="/assets/icons/account_balance_wallet.svg" alt="" />
</div>
<h1 id="mobile-wallet-title" class="mobile-wallet-handoff-title">
Open in a wallet
</h1>
<p class="mobile-wallet-handoff-copy">
Choose a Solana wallet to continue this checkout securely in its
mobile browser.
</p>
<div class="checkout-method-box mobile-wallet-options">
@for (walletOption of MobileWalletOptions(); track walletOption.name)
{
<a
class="mobile-wallet-option"
[href]="walletOption.url"
[attr.aria-label]="'Open checkout in ' + walletOption.name"
>
<span>{{ walletOption.name }}</span>
<img src="/assets/icons/arrow_forward.svg" alt="" />
</a>
}
</div>
<p class="mobile-wallet-handoff-help">
After your wallet opens, review the checkout details and approve the
USDC payment.
</p>
</section>
} @else {
<div class="form-section">
<div class="checkout-section-label">Contact details</div>
Expand Down Expand Up @@ -431,7 +464,7 @@
<a>Privacy</a>
}.
</div>
}
} }

<div class="checkout-payment-footer">
<span>Powered by <b>Zoneless</b></span>
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/app/features/checkout/checkout.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,90 @@
flex-direction: column;
}

.mobile-wallet-handoff {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}

.mobile-wallet-handoff-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: $spacing;
border-radius: 50%;
background-color: $darker-background-color;

img {
width: 22px;
height: 22px;
}
}

.mobile-wallet-handoff-title {
margin: 0;
font-family: $title-font;
font-size: $font-size-large;
font-weight: $title-weight;
color: $checkout-heading;
}

.mobile-wallet-handoff-copy {
max-width: 360px;
margin: $spacing-small 0 $spacing-large;
color: $checkout-label;
font-size: $font-size;
line-height: 1.5;
}

.mobile-wallet-options {
width: 100%;
}

.mobile-wallet-option {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $spacing;
border-bottom: 1px solid $checkout-muted-border;
color: $checkout-heading;
font-family: $title-font;
font-size: $font-size;
font-weight: $medium-weight;
text-decoration: none;
transition: background-color $transition-fast;

&:last-child {
border-bottom: none;
}

&:hover {
background-color: $darker-background-color;
}

&:focus-visible {
outline: 2px solid $base-color;
outline-offset: -2px;
}

img {
width: 14px;
height: 14px;
}
}

.mobile-wallet-handoff-help {
margin: $spacing 0 0;
color: $checkout-label;
font-size: $font-size-small;
line-height: 1.5;
}

.form-section {
margin-bottom: $spacing-medium;
}
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/app/features/checkout/checkout.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';

import { MetaService, SolanaWalletService } from '../../core';
import { CheckoutSessionService } from '../../data/services/checkout-session.service';
import { CheckoutComponent } from './checkout.component';

describe('CheckoutComponent mobile wallet handoff', () => {
const walletService = {
HasWallet: jest.fn(),
};
let component: CheckoutComponent;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => null } } },
},
{ provide: CheckoutSessionService, useValue: {} },
{ provide: MetaService, useValue: {} },
{ provide: SolanaWalletService, useValue: walletService },
],
});
component = TestBed.runInInjectionContext(() => new CheckoutComponent());
});

afterEach(() => {
jest.restoreAllMocks();
walletService.HasWallet.mockReset();
});

it('requests a wallet-browser handoff on mobile without a wallet', () => {
jest
.spyOn(navigator, 'userAgent', 'get')
.mockReturnValue('Mozilla/5.0 (Linux; Android 16) Chrome/140 Mobile');
walletService.HasWallet.mockReturnValue(false);

expect(component.NeedsMobileWalletHandoff()).toBe(true);
});

it('keeps the checkout form when Mobile Wallet Adapter is available', () => {
jest
.spyOn(navigator, 'userAgent', 'get')
.mockReturnValue('Mozilla/5.0 (Linux; Android 16) Chrome/140 Mobile');
walletService.HasWallet.mockReturnValue(true);

expect(component.NeedsMobileWalletHandoff()).toBe(false);
});

it('keeps the existing no-wallet behavior on desktop', () => {
jest
.spyOn(navigator, 'userAgent', 'get')
.mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/140');
walletService.HasWallet.mockReturnValue(false);

expect(component.NeedsMobileWalletHandoff()).toBe(false);
});
});
21 changes: 21 additions & 0 deletions apps/web/src/app/features/checkout/checkout.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import {
FormatUsdcAmount,
GetCheckoutSubmitLabel,
} from './util/checkout-format';
import {
BuildMobileWalletOptions,
IsMobileBrowser,
MobileWalletOption,
} from './util/mobile-wallet';

type PaymentPhase = 'idle' | 'awaiting_wallet' | 'processing' | 'complete';

Expand Down Expand Up @@ -253,6 +258,22 @@ export class CheckoutComponent implements OnInit {
return this.paymentPhase() === 'complete';
}

NeedsMobileWalletHandoff(): boolean {
if (typeof navigator === 'undefined') return false;
return (
IsMobileBrowser(navigator.userAgent, navigator.maxTouchPoints) &&
!this.solanaWalletService.HasWallet()
);
}

MobileWalletOptions(): MobileWalletOption[] {
if (typeof window === 'undefined') return [];
return BuildMobileWalletOptions(
window.location.href,
window.location.origin
);
}

async Pay(): Promise<void> {
const session = this.checkoutSession();
if (!session || this.paymentPhase() !== 'idle') return;
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/app/features/checkout/util/mobile-wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { BuildMobileWalletOptions, IsMobileBrowser } from './mobile-wallet';

describe('mobile wallet checkout helpers', () => {
describe('IsMobileBrowser', () => {
it.each([
'Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 Chrome/140 Mobile',
'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)',
'Mozilla/5.0 (iPad; CPU OS 19_0 like Mac OS X)',
])('detects a mobile user agent', (userAgent) => {
expect(IsMobileBrowser(userAgent)).toBe(true);
});

it('detects an iPad using its desktop user agent', () => {
expect(
IsMobileBrowser(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15',
5
)
).toBe(true);
});

it('does not classify a desktop browser as mobile', () => {
expect(
IsMobileBrowser(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/140',
0
)
).toBe(false);
});
});

describe('BuildMobileWalletOptions', () => {
it('builds encoded browse links for supported wallet browsers', () => {
const currentUrl =
'https://checkout.example.com/c/test-session?prefilled=true#pay';
const origin = 'https://checkout.example.com';
const encodedUrl = encodeURIComponent(currentUrl);
const encodedOrigin = encodeURIComponent(origin);

expect(BuildMobileWalletOptions(currentUrl, origin)).toEqual([
{
name: 'Phantom',
url: `https://phantom.app/ul/browse/${encodedUrl}?ref=${encodedOrigin}`,
},
{
name: 'Solflare',
url: `https://solflare.com/ul/v1/browse/${encodedUrl}?ref=${encodedOrigin}`,
},
{
name: 'Backpack',
url: `https://backpack.app/ul/v1/browse/${encodedUrl}?ref=${encodedOrigin}`,
},
]);
});
});
});
33 changes: 33 additions & 0 deletions apps/web/src/app/features/checkout/util/mobile-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type MobileWalletOption = {
name: string;
url: string;
};

const MOBILE_WALLET_BROWSE_URLS = [
{ name: 'Phantom', baseUrl: 'https://phantom.app/ul/browse/' },
{ name: 'Solflare', baseUrl: 'https://solflare.com/ul/v1/browse/' },
{ name: 'Backpack', baseUrl: 'https://backpack.app/ul/v1/browse/' },
] as const;

export function IsMobileBrowser(
userAgent: string,
maxTouchPoints = 0
): boolean {
return (
/Android|iPhone|iPad|iPod/i.test(userAgent) ||
(/Macintosh/i.test(userAgent) && maxTouchPoints > 1)
);
}

export function BuildMobileWalletOptions(
currentUrl: string,
origin: string
): MobileWalletOption[] {
const encodedUrl = encodeURIComponent(currentUrl);
const encodedOrigin = encodeURIComponent(origin);

return MOBILE_WALLET_BROWSE_URLS.map(({ name, baseUrl }) => ({
name,
url: `${baseUrl}${encodedUrl}?ref=${encodedOrigin}`,
}));
}
36 changes: 33 additions & 3 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
async function RegisterMobileWalletAdapter(): Promise<void> {
if (!/Android/i.test(navigator.userAgent)) return;

const {
createDefaultAuthorizationCache,
createDefaultChainSelector,
createDefaultWalletNotFoundHandler,
registerMwa,
} = await import('@solana-mobile/wallet-standard-mobile');

registerMwa({
appIdentity: {
name: 'Zoneless',
uri: window.location.origin,
icon: 'assets/favicon/favicon-32x32.png',
},
authorizationCache: createDefaultAuthorizationCache(),
chains: ['solana:devnet', 'solana:mainnet'],
chainSelector: createDefaultChainSelector(),
onWalletNotFound: createDefaultWalletNotFoundHandler(),
});
}

async function Bootstrap(): Promise<void> {
try {
await RegisterMobileWalletAdapter();
} catch (error) {
console.warn('Mobile Wallet Adapter registration failed', error);
}
await bootstrapApplication(AppComponent, appConfig);
}

Bootstrap().catch((error) => console.error(error));
Loading
Loading