diff --git a/docs/play-premium.md b/docs/play-premium.md new file mode 100644 index 0000000..122f495 --- /dev/null +++ b/docs/play-premium.md @@ -0,0 +1,108 @@ +# Google Play premium unlock + +PolyTalk sells a **one-time** extra-content unlock. It is not a subscription. + +- Product ID: `polytalk_premium` +- Android package: `me.polytalk.twa` +- Unlocks extra words, numbers, and sentences in the learning lists +- Purchases run through Google Play Billing in the Android Trusted Web Activity (TWA) +- The website and desktop builds do **not** charge a card or fake a Play payment + +## Create the product in Play Console + +Sondre must create the product and set the price. High-level steps: + +1. Open [Google Play Console](https://play.google.com/console) and select the PolyTalk app (`me.polytalk.twa`). +2. Open **Monetize** (in-app products / one-time products). Official names in Console change; look for one-time in-app products, not subscriptions. +3. Create a product with ID **`polytalk_premium`**. The ID must match exactly. +4. Add a title and description that explain the extra words, numbers, and phrases pack. +5. **Set the price.** This repository does not choose or store a price. Play is the source of truth, including regional prices. +6. Activate the product so license testers and later production users can buy it. +7. Link a payments profile if Play asks for one. A merchant / payments setup is required before paid products can go live. + +Do not treat this as a click-by-click Console walkthrough. Use Play’s current help for one-time products if a screen name differs. + +## How the Android TWA charges + +The Bubblewrap TWA enables Play Billing in `src-android/twa-manifest.json`: + +```json +"features": { + "playBilling": { "enabled": true } +}, +"alphaDependencies": { + "enabled": true +} +``` + +The generated Android project also: + +- declares `com.android.vending.BILLING` +- registers `DigitalGoodsRequestHandler` on `DelegationService` +- hosts Play’s Digital Goods payment activity/service +- depends on `com.google.androidbrowserhelper:billing:1.1.0` (Play Billing Library 7.x) + +On a device where the TWA is opened in Chrome, the web app uses: + +1. **Digital Goods API** (`getDigitalGoodsService('https://play.google.com/billing')`) to read the SKU price and existing purchases +2. **Payment Request API** with method `https://play.google.com/billing` and `sku: polytalk_premium` to start checkout + +After a successful purchase or restore, the app stores `polytalk-premium=1` in `localStorage` so the user is not asked again. The extra pack is **not** consumed, so Play can keep the entitlement. + +Digital Goods is only available inside the Play-installed TWA (typically Chrome). It is not available on a normal desktop browser. + +## Restore + +The Settings and About pages have **Restore purchase**. + +- In the Android TWA: the app calls Digital Goods `listPurchases()` on launch and when the user taps Restore. +- On web/desktop: Restore re-reads the local unlock flag. It cannot invent a Play charge. If nothing is stored, the UI tells the user to restore in the Android app. + +## Web and desktop fallback + +If Digital Goods is missing: + +- Show the premium pitch +- Show **Buy on Google Play** (Play Store listing for `me.polytalk.twa`) +- Show **Restore purchase** +- Do not open a fake checkout + +## Development unlock + +For local development only, this is **not** a real payment: + +```js +localStorage.setItem('polytalk-premium', '1'); +location.reload(); +``` + +To lock again: + +```js +localStorage.removeItem('polytalk-premium'); +location.reload(); +``` + +The Settings card mentions this key only in Angular development mode. + +## License testers + +To try a real Play checkout without charging the production price: + +1. In Play Console, add your Google account as a [license tester](https://support.google.com/googleplay/android-developer/answer/6062777). +2. Install a Play build (internal / closed / open testing, or production) signed with the upload key that Play already knows. +3. Sign in on the device with that tester account. +4. Buy `polytalk_premium`. Play should offer a test purchase flow for license testers. + +License testing still requires the Play product to exist and the app to be uploaded to a track. A local `ng serve` page in a desktop browser cannot run Play Billing. + +## After you change Android billing files + +`npm run android:update` runs `bubblewrap update` and then re-applies the signing patch. If you regenerate the Android project, confirm Play Billing is still enabled: + +- `src-android/twa-manifest.json` still has `features.playBilling.enabled` +- `DelegationService` still registers `DigitalGoodsRequestHandler` +- `app/build.gradle` still includes `com.google.androidbrowserhelper:billing` +- `AndroidManifest.xml` still has the billing permission and payment activity/service + +Do not bump `appVersionCode` for this feature unless you are uploading a new Android package to Play. diff --git a/e2e/polytalk.spec.ts b/e2e/polytalk.spec.ts index 87abd54..dc01a30 100644 --- a/e2e/polytalk.spec.ts +++ b/e2e/polytalk.spec.ts @@ -81,10 +81,26 @@ test.describe('Learning page', () => { await expect(page.locator('.tabs button')).toHaveCount(3); await expect(page.locator('.tabs button.active')).toHaveText('Words'); - const items = page.locator('.item'); - expect(await items.count()).toBeGreaterThan(3); + const items = page.locator('.phrase-list .item'); + expect(await items.count()).toBe(22); await expect(items.first().locator('.native span')).toHaveText('water'); await expect(items.first().locator('.translation span')).toHaveText('agua'); + await expect(page.locator('.premium-teaser')).toBeVisible(); + await expect(page.locator('.item.locked').first()).toBeVisible(); + }); + + test('unlocks the extra pack with the development localStorage key', async ({ + page, + }) => { + await page.addInitScript(() => { + localStorage.setItem('polytalk-premium', '1'); + }); + await page.reload(); + + const items = page.locator('.phrase-list .item'); + await expect(items).toHaveCount(49); + await expect(page.locator('.premium-teaser')).toHaveCount(0); + await expect(items.last().locator('.native span')).toHaveText('to pay'); }); test('switches category through the tabs', async ({ page }) => { @@ -233,4 +249,12 @@ test.describe('Settings', () => { await expect(page.locator('#playback-speed')).toBeVisible(); await expect(page.locator('.actions button')).toHaveCount(2); }); + + test('shows the web premium fallback without charging', async ({ page }) => { + await page.goto('/settings'); + + await expect(page.locator('app-premium-card')).toBeVisible(); + await expect(page.getByRole('link', { name: /Buy on Google Play/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Restore purchase/i })).toBeVisible(); + }); }); diff --git a/src-android/app/build.gradle b/src-android/app/build.gradle index e7110f6..3748676 100644 --- a/src-android/app/build.gradle +++ b/src-android/app/build.gradle @@ -229,5 +229,6 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2' + implementation 'com.google.androidbrowserhelper:billing:1.1.0' } diff --git a/src-android/app/src/main/AndroidManifest.xml b/src-android/app/src/main/AndroidManifest.xml index cdd506d..1646acb 100644 --- a/src-android/app/src/main/AndroidManifest.xml +++ b/src-android/app/src/main/AndroidManifest.xml @@ -24,6 +24,7 @@ + @@ -176,8 +177,8 @@ + android:enabled="true" + android:exported="true"> - + + + + + + + + + + + + + diff --git a/src-android/app/src/main/java/me/polytalk/twa/DelegationService.java b/src-android/app/src/main/java/me/polytalk/twa/DelegationService.java index f67c0e8..14a1bc5 100644 --- a/src-android/app/src/main/java/me/polytalk/twa/DelegationService.java +++ b/src-android/app/src/main/java/me/polytalk/twa/DelegationService.java @@ -1,14 +1,13 @@ package me.polytalk.twa; - +import com.google.androidbrowserhelper.playbilling.digitalgoods.DigitalGoodsRequestHandler; public class DelegationService extends com.google.androidbrowserhelper.trusted.DelegationService { @Override public void onCreate() { super.onCreate(); - - + registerExtraCommandHandler(new DigitalGoodsRequestHandler(getApplicationContext())); } } diff --git a/src-android/twa-manifest.json b/src-android/twa-manifest.json index e740da7..143de61 100644 --- a/src-android/twa-manifest.json +++ b/src-android/twa-manifest.json @@ -26,8 +26,14 @@ "generatorApp": "bubblewrap-cli", "webManifestUrl": "https://polytalk.me/manifest.webmanifest", "fallbackType": "customtabs", - "features": {}, - "alphaDependencies": {}, + "features": { + "playBilling": { + "enabled": true + } + }, + "alphaDependencies": { + "enabled": true + }, "enableSiteSettingsShortcut": true, "isChromeOSOnly": false, "isMetaQuest": false, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1e5179b..06ffbd9 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,6 +5,7 @@ import { NavbarComponent } from './components/navbar/navbar.component'; import { UpdateService } from './services/update.service'; import { FooterComponent } from './components/footer/footer.component'; import { ThemeService } from './services/theme.service'; +import { PremiumService } from './services/premium.service'; import { TranslatePipe } from './pipes/translate.pipe'; @Component({ @@ -96,6 +97,7 @@ import { TranslatePipe } from './pipes/translate.pipe'; export class AppComponent { readonly updateService = inject(UpdateService); private readonly themeService = inject(ThemeService); + private readonly premiumService = inject(PremiumService); readonly showInstallPrompt = signal(false); private deferredPrompt: any; diff --git a/src/app/components/premium-card/premium-card.component.ts b/src/app/components/premium-card/premium-card.component.ts new file mode 100644 index 0000000..4c32363 --- /dev/null +++ b/src/app/components/premium-card/premium-card.component.ts @@ -0,0 +1,147 @@ +import { Component, inject, input } from '@angular/core'; + +import { TranslatePipe } from '../../pipes/translate.pipe'; +import { + PLAY_STORE_LISTING_URL, + PremiumService, +} from '../../services/premium.service'; + +@Component({ + selector: 'app-premium-card', + standalone: true, + imports: [TranslatePipe], + template: ` +
+
+

{{ 'premium.title' | translate }}

+ @if (premium.unlocked()) { +

{{ 'premium.statusUnlocked' | translate }}

+ } @else { +

{{ compact() ? ('premium.teaserTitle' | translate) : ('premium.statusLocked' | translate) }}

+

{{ compact() ? ('premium.teaserBody' | translate) : ('premium.pitch' | translate) }}

+ } + @if (premium.priceLabel(); as price) { +

{{ price }}

+ } + @if (!premium.playBillingAvailable() && !premium.unlocked()) { +

{{ 'premium.webOnly' | translate }}

+ } + @if (premium.isDevUnlockHintVisible()) { +

{{ 'premium.devHint' | translate }}

+ } +
+ +
+ @if (premium.unlocked()) { + + } @else if (premium.playBillingAvailable()) { + + } @else { + + {{ 'premium.buyOnPlay' | translate }} + + } + + +
+ + @if (premium.message(); as messageKey) { +

+ {{ messageKey | translate }} +

+ } +
+ `, + styles: [ + ` + :host { + display: block; + } + .premium-card { + display: flex; + flex-direction: column; + gap: 1rem; + } + .eyebrow { + margin: 0; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary-color); + } + h2 { + margin: 0.25rem 0 0.5rem; + font-size: 1.2rem; + } + p { + margin: 0; + color: var(--text-light); + } + .price { + color: var(--primary-color); + font-weight: 700; + } + .hint { + font-size: 0.85rem; + } + .premium-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + } + .store-link { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + } + .secondary { + background: var(--surface-muted); + } + .status { + font-size: 0.9rem; + font-weight: 500; + } + .success { + color: var(--secondary-dark); + } + .error { + color: var(--accent-color); + } + .info { + color: var(--text-light); + } + .compact h2 { + font-size: 1.05rem; + } + `, + ], +}) +export class PremiumCardComponent { + readonly premium = inject(PremiumService); + readonly compact = input(false); + readonly playStoreUrl = PLAY_STORE_LISTING_URL; +} diff --git a/src/app/pages/about/about.component.spec.ts b/src/app/pages/about/about.component.spec.ts index 8d87160..0d4bcaf 100644 --- a/src/app/pages/about/about.component.spec.ts +++ b/src/app/pages/about/about.component.spec.ts @@ -36,8 +36,16 @@ describe('AboutComponent', () => { expect(features.length).toBeGreaterThan(0); }); + it('should render premium status', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('app-premium-card')).toBeTruthy(); + }); + it('should render contact section', () => { const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h2:last-of-type')?.textContent).toContain('Contact'); + const headings = Array.from(compiled.querySelectorAll('h2')).map( + (heading) => heading.textContent ?? '' + ); + expect(headings.some((text) => text.includes('Contact'))).toBeTrue(); }); }); diff --git a/src/app/pages/about/about.component.ts b/src/app/pages/about/about.component.ts index 4753372..0cf1306 100644 --- a/src/app/pages/about/about.component.ts +++ b/src/app/pages/about/about.component.ts @@ -1,10 +1,11 @@ import { Component } from '@angular/core'; import { TranslatePipe } from '../../pipes/translate.pipe'; +import { PremiumCardComponent } from '../../components/premium-card/premium-card.component'; @Component({ selector: 'app-about', standalone: true, - imports: [TranslatePipe], + imports: [TranslatePipe, PremiumCardComponent], template: `

{{ 'about.title' | translate }}

@@ -22,6 +23,9 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
  • {{ 'about.feature4' | translate }}
  • +

    {{ 'premium.title' | translate }}

    + +

    {{ 'about.reviewTitle' | translate }}

    {{ 'about.reviewText' | translate }}

    }
    + + @if (!premium.unlocked() && premiumTeasers().length) { + + }
    @@ -578,6 +599,36 @@ export interface LearningItem { cursor: not-allowed; } + .premium-teaser { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + } + + .locked-preview { + display: flex; + flex-direction: column; + } + + .item.locked { + opacity: 0.72; + pointer-events: none; + } + + .lock-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-pill); + background: var(--primary-soft); + color: var(--primary-color); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + /* ---------- Transport bar ---------- */ .transport-spacer { height: 84px; @@ -993,6 +1044,7 @@ export class LearningComponent implements OnInit, OnDestroy { private readonly languageService = inject(LanguageService); private readonly translationService = inject(TranslationService); readonly audioService = inject(AudioService); + readonly premium = inject(PremiumService); private readonly SETTINGS_KEY = 'polytalk-settings'; private readonly FROM_LANGUAGE_KEY = 'polytalk-from-language'; @@ -1003,6 +1055,7 @@ export class LearningComponent implements OnInit, OnDestroy { // Route / content state readonly category = signal('words'); readonly currentItems = signal([]); + readonly premiumTeasers = signal([]); readonly availableLanguages = signal([]); readonly fromLanguageCode = signal('en'); readonly toLanguageCode = signal(''); @@ -1053,6 +1106,13 @@ export class LearningComponent implements OnInit, OnDestroy { window.addEventListener('online', this.onOnline); window.addEventListener('offline', this.onOffline); + effect(() => { + this.premium.unlocked(); + if (this.toLanguageCode()) { + this.loadItems(); + } + }); + effect(() => { const file = this.audioService.currentFile(); if (!file) { @@ -1107,9 +1167,14 @@ export class LearningComponent implements OnInit, OnDestroy { loadItems(): void { if (!this.toLanguageCode()) return; - const toContent = this.languageService.getContent(this.toLanguageCode()); + const includePremium = this.premium.unlocked(); + const toContent = this.languageService.getContent( + this.toLanguageCode(), + includePremium + ); const fromContent = this.languageService.getContent( - this.fromLanguageCode() + this.fromLanguageCode(), + includePremium ); if (!toContent || !fromContent) return; @@ -1127,6 +1192,30 @@ export class LearningComponent implements OnInit, OnDestroy { key, })) ); + + if (includePremium) { + this.premiumTeasers.set([]); + return; + } + + const toPremium = this.languageService.getPremiumContent( + this.toLanguageCode() + ); + const fromPremium = this.languageService.getPremiumContent( + this.fromLanguageCode() + ); + const extraTo = toPremium?.[category] ?? {}; + const extraFrom = fromPremium?.[category] ?? {}; + + this.premiumTeasers.set( + Object.entries(extraTo) + .slice(0, 3) + .map(([key, toTranslation]) => ({ + native: extraFrom[key] ?? key, + translation: toTranslation, + key, + })) + ); } isItemOffline(item: LearningItem): boolean { @@ -1412,10 +1501,15 @@ export class LearningComponent implements OnInit, OnDestroy { const audioFiles: string[] = []; const allCategories = ['words', 'numbers', 'sentences']; + const includePremium = this.premium.unlocked(); const fromContent = this.languageService.getContent( - this.fromLanguageCode() + this.fromLanguageCode(), + includePremium + ); + const toContent = this.languageService.getContent( + this.toLanguageCode(), + includePremium ); - const toContent = this.languageService.getContent(this.toLanguageCode()); allCategories.forEach((category) => { const fromItems = fromContent diff --git a/src/app/pages/settings/settings.component.spec.ts b/src/app/pages/settings/settings.component.spec.ts index bc3b914..b9fc657 100644 --- a/src/app/pages/settings/settings.component.spec.ts +++ b/src/app/pages/settings/settings.component.spec.ts @@ -57,6 +57,12 @@ describe('SettingsComponent', () => { expect(themeService.getSavedTheme()).toBe(newTheme); }); + it('should render the premium status card', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('app-premium-card')).toBeTruthy(); + expect(compiled.textContent).toContain('Premium'); + }); + it('should reset settings', () => { spyOn(settingsService, 'resetSettings'); component.settingsService.resetSettings(); diff --git a/src/app/pages/settings/settings.component.ts b/src/app/pages/settings/settings.component.ts index abe8e1d..42fdf0b 100644 --- a/src/app/pages/settings/settings.component.ts +++ b/src/app/pages/settings/settings.component.ts @@ -6,11 +6,12 @@ import { AudioService } from '../../services/audio.service'; import { ThemeService } from '../../services/theme.service'; import { TranslationService } from '../../services/translation.service'; import { TranslatePipe } from '../../pipes/translate.pipe'; +import { PremiumCardComponent } from '../../components/premium-card/premium-card.component'; @Component({ selector: 'app-settings', standalone: true, - imports: [CommonModule, FormsModule, TranslatePipe], + imports: [CommonModule, FormsModule, TranslatePipe, PremiumCardComponent], template: `

    {{ 'settings.title' | translate }}

    @@ -39,6 +40,10 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
    +
    + +
    +