Skip to content
Open
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
149 changes: 123 additions & 26 deletions apps/mobile/src/components/kilo-pass/kilo-pass-native-iap-owner.tsx

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions apps/mobile/src/components/kilo-pass/kilo-pass-play-manage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { toast } from 'sonner-native';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { openPlaySubscriptionManagement } from './kilo-pass-play-manage';

vi.mock('expo-iap', () => ({
deepLinkToSubscriptions: vi.fn(),
}));

vi.mock('sonner-native', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() },
}));

const { deepLinkToSubscriptions } = await import('expo-iap');

describe('openPlaySubscriptionManagement', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('deep-links to Play subscription management and invalidates after', async () => {
const invalidateAfter = vi.fn().mockResolvedValue(undefined);
vi.mocked(deepLinkToSubscriptions).mockResolvedValue(undefined);

await openPlaySubscriptionManagement({
skuAndroid: 'kilopass_tier19',
invalidateAfter,
});

expect(deepLinkToSubscriptions).toHaveBeenCalledWith({
skuAndroid: 'kilopass_tier19',
packageNameAndroid: 'com.kilocode.kiloapp',
});
expect(invalidateAfter).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(2000);
expect(invalidateAfter).toHaveBeenCalledTimes(2);
});

it('shows the Play management failure toast when the deeplink fails', async () => {
vi.mocked(deepLinkToSubscriptions).mockRejectedValue(new Error('deeplink failed'));

await openPlaySubscriptionManagement({
skuAndroid: 'kilopass_tier19',
invalidateAfter: vi.fn(),
});

expect(toast.error).toHaveBeenCalledWith('deeplink failed');
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/components/kilo-pass/kilo-pass-play-manage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { deepLinkToSubscriptions } from 'expo-iap';
import { toast } from 'sonner-native';

import { i18n } from '@/i18n';

/**
* Android-only Google Play subscription management helper. Loaded lazily from
* an Android branch so iOS never reaches for the Play deeplink.
*/
export async function openPlaySubscriptionManagement(params: {
skuAndroid: string;
invalidateAfter: () => Promise<void> | void;
}): Promise<void> {
try {
await deepLinkToSubscriptions({
skuAndroid: params.skuAndroid,
packageNameAndroid: 'com.kilocode.kiloapp',
});
await params.invalidateAfter();
setTimeout(() => {
void params.invalidateAfter();
}, 2000);
} catch (error) {
toast.error(error instanceof Error ? error.message : i18n.t('kiloPass.manageFailedPlay'));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Href, useRouter } from 'expo-router';
import * as Haptics from 'expo-haptics';
import { Linking, Platform, Pressable, View } from 'react-native';
import { useEffect, useRef } from 'react';
import { AppState, Linking, Platform, Pressable, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';

Expand All @@ -15,6 +16,12 @@ import {
getKiloPassSubscriptionCardContentState,
} from '@/lib/kilo-pass/subscription-card-state';

const GOOGLE_PRODUCT_ID_BY_TIER = {
tier_19: 'kilopass_tier19',
tier_49: 'kilopass_tier49',
tier_199: 'kilopass_tier199',
} as const;

export function KiloPassSubscriptionCard() {
const colors = useThemeColors();
const router = useRouter();
Expand All @@ -28,6 +35,7 @@ export function KiloPassSubscriptionCard() {
platform,
storefront,
product: 'kilo_pass',
supportsNativePlayKiloPass: true,
})
);
const stateQuery = useQuery(trpc.kiloPass.getState.queryOptions());
Expand Down Expand Up @@ -60,6 +68,31 @@ export function KiloPassSubscriptionCard() {
queryClient.invalidateQueries(trpc.kiloPass.getCreditHistory.pathFilter()),
]);
};

// Returning to the app may follow a store-management trip (App Store or Play);
// refetch both the presentation and state so the card reflects any change.
const refetchRef = useRef({
presentation: presentationQuery.refetch,
state: stateQuery.refetch,
});
useEffect(() => {
refetchRef.current = {
presentation: presentationQuery.refetch,
state: stateQuery.refetch,
};
}, [presentationQuery.refetch, stateQuery.refetch]);
useEffect(() => {
const appStateSubscription = AppState.addEventListener('change', state => {
if (state === 'active') {
void refetchRef.current.presentation();
void refetchRef.current.state();
}
});
return () => {
appStateSubscription.remove();
};
}, []);

const handlePress = () => {
if (contentState.kind !== 'card') {
return;
Expand All @@ -79,7 +112,19 @@ export function KiloPassSubscriptionCard() {
return;
}
if (cardState.action === 'open-store-management') {
if (Platform.OS !== 'ios') {
if (Platform.OS === 'android') {
const tier = subscription?.tier;
const skuAndroid =
(tier != null
? GOOGLE_PRODUCT_ID_BY_TIER[tier as keyof typeof GOOGLE_PRODUCT_ID_BY_TIER]
: undefined) ?? 'kilopass_tier19';
void (async () => {
const { openPlaySubscriptionManagement } = await import('./kilo-pass-play-manage');
await openPlaySubscriptionManagement({
skuAndroid,
invalidateAfter: invalidateKiloPassState,
});
})();
return;
}
void (async () => {
Expand Down Expand Up @@ -176,10 +221,12 @@ export function KiloPassSubscriptionCard() {
{contentState.kind === 'card' && contentState.state.action !== 'none' ? (
<Pressable
accessibilityHint={
getKiloPassSubscriptionCardAccessibility(contentState.state).accessibilityHint
getKiloPassSubscriptionCardAccessibility(contentState.state, Platform.OS)
.accessibilityHint
}
accessibilityLabel={
getKiloPassSubscriptionCardAccessibility(contentState.state).accessibilityLabel
getKiloPassSubscriptionCardAccessibility(contentState.state, Platform.OS)
.accessibilityLabel
}
accessibilityRole="button"
className="rounded-lg border border-border bg-card p-3 active:opacity-80"
Expand Down
Loading