diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..6c65ba6ae --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "--cwd", "packages/docs", "dev"], + "port": 5173 + } + ] +} diff --git a/.claude/skills/add-showcase-app/SKILL.md b/.claude/skills/add-showcase-app/SKILL.md new file mode 100644 index 000000000..67668cd20 --- /dev/null +++ b/.claude/skills/add-showcase-app/SKILL.md @@ -0,0 +1,26 @@ +--- +name: add-showcase-app +description: Add an app to the OpenIAP "Who uses OpenIAP?" showcase — download and mask its icon, append the showcase-apps.json entry, refresh the review-count ordering metrics, and verify the docs build. Use when someone submits an app through issue #280, a showcase pull request, X, or email, or when the user asks to add or update an app on openiap.dev/showcase. +--- + +# Add Showcase App (Claude Code) + +The canonical procedure lives in `.codex/skills/add-showcase-app/SKILL.md`. +Read it and follow every section — collecting the submission, masking the icon, +appending the JSON entry, refreshing metrics, verifying, and closing the loop +are agent-agnostic and apply as written. + +## Claude Code Notes + +- Fetch submissions with the GitHub MCP tools or `gh` (for example + `gh api repos/hyodotdev/openiap/issues/280/comments`) instead of asking the + user to paste them. +- To verify rendering, start the docs dev server through `preview_start` + (`.claude/launch.json` defines the `docs` configuration) and check the + showcase section in the browser pane. The home page section sits far down the + page — scroll to the `Who uses OpenIAP?` heading, or open `/showcase` + directly, which renders the full list near the top. +- If browser screenshots come back blank, fall back to headless Chrome against + the dev server and crop the region with Pillow. +- Attach the rendered section back to the user with `SendUserFile` so they can + approve the card before anything is committed. diff --git a/.codex/skills/add-showcase-app/SKILL.md b/.codex/skills/add-showcase-app/SKILL.md new file mode 100644 index 000000000..712b82109 --- /dev/null +++ b/.codex/skills/add-showcase-app/SKILL.md @@ -0,0 +1,149 @@ +--- +name: add-showcase-app +description: Add an app to the OpenIAP "Who uses OpenIAP?" showcase — download and mask its icon, append the showcase-apps.json entry, refresh the review-count ordering metrics, and verify the docs build. Use when someone submits an app through issue #280, a showcase pull request, X, or email, or when the user asks to add or update an app on openiap.dev/showcase. +--- + +# Add Showcase App + +Turn an app submission into a rendered card on the home page and `/showcase`. + +Everything lives in `packages/docs`: + +| Path | Role | +| ------------------------------------------- | ---------------------------------------- | +| `showcase-apps.json` | The list (SSOT for what renders) | +| `public/showcase/.webp` | Masked 256×256 app icon | +| `scripts/refresh-showcase-metrics.mjs` | Fills `ratings` / `installs` for ordering | +| `src/lib/showcase.ts` | Sorting + featured slice | +| `src/components/ShowcaseCards.tsx` | Card markup | +| `SHOWCASE.md` | Public submission guide | + +## 1. Collect the submission + +Required from the submitter: + +- **App name** and a one-line description (keep the tagline under ~70 chars so + cards stay even) +- **App icon** — square, 512×512 PNG (a store icon URL works too) +- **Store links** — App Store and/or Google Play; a website link is optional +- **Library** — one of `expo-iap`, `react-native-iap`, `flutter_inapp_purchase`, + `kmp-iap`, `maui-iap`, `godot-iap` + +Only list an app when the submitter asked for it. A comment on +[issue #280](https://github.com/hyodotdev/openiap/issues/280), a showcase PR, an +email, or a public reply to the announcement all count as permission; a mention +of the library somewhere else does not. + +If the icon is missing, pull it from the stores rather than asking again: + +```bash +# App Store artwork + metadata +curl -s "https://itunes.apple.com/lookup?id=" | python3 -m json.tool | grep artworkUrl512 + +# Google Play icon +curl -s "https://play.google.com/store/apps/details?id=" \ + | grep -o 'https://play-lh.googleusercontent.com/[A-Za-z0-9_=-]\{20,\}' | head -1 +``` + +## 2. Add the icon + +Icons are stored pre-masked so store artwork with baked-in rounded corners and +plain square artwork render identically. Append `=s512` to a Play icon URL for +the full-size original. + +```bash +cd packages/docs && python3 - <<'PY' +import urllib.request, io +from PIL import Image, ImageDraw + +SLUG = "your-app" # kebab-case, matches the logo path in the JSON +URL = "https://..." # 512px source icon + +SIZE, SS, RATIO = 256, 4, 0.2237 # 0.2237 ≈ the Apple icon corner radius +mask = Image.new("L", (SIZE*SS, SIZE*SS), 0) +ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, SIZE*SS-1, SIZE*SS-1), radius=int(SIZE*SS*RATIO), fill=255 +) +mask = mask.resize((SIZE, SIZE), Image.LANCZOS) + +req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"}) +raw = urllib.request.urlopen(req, timeout=30).read() +img = Image.open(io.BytesIO(raw)).convert("RGBA").resize((SIZE, SIZE), Image.LANCZOS) +out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) +out.paste(img, (0, 0), mask) +out.save(f"public/showcase/{SLUG}.webp", "WEBP", quality=90, method=6) +print("saved", SLUG) +PY +``` + +`sips` cannot write WebP on macOS — use the Pillow snippet above. + +## 3. Append the entry + +Add to the end of the `apps` array in `packages/docs/showcase-apps.json`. +Ordering is computed at render time, so position in the file does not matter. + +```json +{ + "name": "Your App", + "tagline": "One line about what the app does", + "logo": "/showcase/your-app.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/your-app/id0000000000", + "android": "https://play.google.com/store/apps/details?id=com.example.yourapp" +} +``` + +`ios`, `android`, and `web` are each optional, but an entry with none of them is +dropped at render time. Leave `ratings` and `installs` out — step 4 writes them. + +## 4. Refresh the ordering metrics + +```bash +cd packages/docs && bun run showcase:metrics +``` + +The script fills every entry: + +- `ratings` — App Store `userRatingCount` **summed across every storefront** + plus the Google Play review count. **Primary sort key, descending.** +- `installs` — the Play install floor (`"1K+"` → `1000`). **Fallback** when + review counts tie, which is common for new apps. + +Apple reports ratings per storefront and publishes no global total, so a US-only +lookup reads zero for an app reviewed mainly in Korea or Japan. The sweep covers +~170 storefronts and takes about a minute; Apple throttles bursts, so the script +retries failures in later rounds and **keeps the previous numbers rather than +writing a partial sweep**. If output says `kept existing ratings`, rerun it. + +Neither store publishes download totals: Apple exposes no install data in any +public API, and Play reports only a coarse bucket. Do not add a `downloads` +field or invent numbers — review count is the one verifiable signal both stores +share. If a submitter reports their own install figures, keep them out of the +JSON. + +The scraper depends on Play's HTML, whose class names are obfuscated and change. +If `ratings` comes back unexpectedly `0` for an app that clearly has reviews, +re-check the regexes in `scripts/refresh-showcase-metrics.mjs` rather than +hand-editing the JSON. + +## 5. Verify + +```bash +cd packages/docs && bun run typecheck && bun run build +``` + +Then confirm the card renders and the icon actually loads — a broken `logo` path +fails silently as a missing image, not a build error. The home page shows the +top `FEATURED_SHOWCASE_LIMIT` (5) apps plus the submit card and a "See all" +link; `/showcase` lists everything. + +## 6. Close the loop + +- Reply to the submission thread (issue #280 comment, PR, or email) confirming + the app is listed, and note that updates or removal are available anytime. +- Public GitHub replies must be in English — see + `knowledge/internal/06-git-deployment.md`. +- Commit with a lowercase subject after the tag, e.g. + `docs: add recallai to showcase`. Do not commit, push, or open a PR unless the + user already authorized it. diff --git a/.husky/pre-commit b/.husky/pre-commit index bb259cb10..592ca18e7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -176,6 +176,7 @@ if git diff --cached --name-only --diff-filter=ACMR \ bun install --frozen-lockfile bun run --filter @hyodotdev/openiap-docs typecheck bun test "$REPO_ROOT/scripts/audit-docs.test.ts" + bun run --filter @hyodotdev/openiap-docs showcase:metrics:test bun run audit:docs ( cd packages/docs && bunx prettier --check "src/**/*.{ts,tsx,css}" diff --git a/bun.lock b/bun.lock index 3ae7b8629..c52daf15d 100644 --- a/bun.lock +++ b/bun.lock @@ -12,14 +12,14 @@ }, "packages/apple": { "name": "@hyodotdev/openiap-ios", - "version": "2.4.2", + "version": "3.0.1", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "2.5.0", + "version": "3.0.1", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -57,14 +57,14 @@ }, "packages/google": { "name": "@hyodotdev/openiap-android", - "version": "2.5.0", + "version": "3.0.1", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "2.5.0", + "version": "3.0.1", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", diff --git a/packages/docs/SHOWCASE.md b/packages/docs/SHOWCASE.md new file mode 100644 index 000000000..fdd610045 --- /dev/null +++ b/packages/docs/SHOWCASE.md @@ -0,0 +1,78 @@ +# Submit your app to the OpenIAP showcase + +Shipped an app with `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, +`kmp-iap`, `maui-iap`, or `godot-iap`? Add it to the +**[Who uses OpenIAP?](https://www.openiap.dev)** section on the home page. + +It's one entry in [`showcase-apps.json`](./showcase-apps.json). + +## Open a pull request + +1. Fork [hyodotdev/openiap](https://github.com/hyodotdev/openiap) and create a branch. +2. Add your app to the end of the `apps` array in `packages/docs/showcase-apps.json`: + + ```json + { + "name": "Your App", + "tagline": "One line about what your app does", + "logo": "/showcase/your-app.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/your-app/id0000000000", + "android": "https://play.google.com/store/apps/details?id=com.example.yourapp" + } + ``` + +3. Add your icon to `packages/docs/public/showcase/` as a **square 512×512 PNG** + or a 256×256 `.webp`. Don't pre-round the corners — we apply the same rounded + mask to every icon so the row stays consistent. +4. Open the PR with the title `docs: add to showcase`. + +That's it. No build step or code change is needed — the pages render the JSON +directly. + +## Ordering + +Apps are ordered by **combined App Store + Google Play review count**, +descending, with the Google Play install count as a tiebreaker. Neither store +publishes download totals — Apple exposes no install data publicly and Play +reports only a bucket like "1K+" — so review count is the one verifiable signal +both stores share. + +App Store review counts are **summed across every storefront**, not just the US +one: Apple reports `userRatingCount` per country and publishes no global total, +so an app reviewed mainly in Korea or Japan would otherwise read as zero. + +Maintainers refresh the numbers with: + +```bash +cd packages/docs && bun run showcase:metrics +``` + +Leave `ratings` and `installs` out of your PR — the script fills them in. + +## Fields + +| Field | Required | Notes | +| --------- | -------- | -------------------------------------------------------------------------------------- | +| `name` | ✅ | App name as it appears on the stores. | +| `tagline` | ✅ | One short line. Keep it under ~70 characters so cards stay even. | +| `logo` | ✅ | Path under `packages/docs/public` (e.g. `/showcase/your-app.webp`) or a full https URL. | +| `ratings` / `installs` | — | Maintainer-managed ordering metrics. Leave these out. | +| `library` | ✅ | One of `expo-iap`, `react-native-iap`, `flutter_inapp_purchase`, `kmp-iap`, `maui-iap`, `godot-iap`. | +| `ios` | — | App Store URL. | +| `android` | — | Google Play URL. | +| `web` | — | Website or other store, shown as "Website". | + +At least one of `ios`, `android`, or `web` is required — entries without a link +are skipped at render time. + +## Don't want to send a PR? + +Comment on [issue #280](https://github.com/hyodotdev/openiap/issues/280) or +email **hyo@hyo.dev** with your app name, one-liner, logo, store links, and which +library you use — we'll add it for you. + +## Removal and updates + +Your app is listed only with your permission. To change or remove an entry, open +a PR, comment on issue #280, or email hyo@hyo.dev anytime. diff --git a/packages/docs/package.json b/packages/docs/package.json index 9ee8c8435..3a6382dba 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -4,6 +4,8 @@ "version": "3.0.1", "type": "module", "scripts": { + "showcase:metrics": "node scripts/refresh-showcase-metrics.mjs", + "showcase:metrics:test": "node --test scripts/refresh-showcase-metrics.test.mjs", "dev": "bunx vite", "build": "bun run typecheck && bunx vite build", "typecheck": "tsc --noEmit", diff --git a/packages/docs/public/showcase/loader.webp b/packages/docs/public/showcase/loader.webp new file mode 100644 index 000000000..683ae4c1e Binary files /dev/null and b/packages/docs/public/showcase/loader.webp differ diff --git a/packages/docs/public/showcase/martie.webp b/packages/docs/public/showcase/martie.webp new file mode 100644 index 000000000..1fbd31882 Binary files /dev/null and b/packages/docs/public/showcase/martie.webp differ diff --git a/packages/docs/public/showcase/recallai.webp b/packages/docs/public/showcase/recallai.webp new file mode 100644 index 000000000..15256e7c7 Binary files /dev/null and b/packages/docs/public/showcase/recallai.webp differ diff --git a/packages/docs/public/showcase/sudoku-rabbit.webp b/packages/docs/public/showcase/sudoku-rabbit.webp new file mode 100644 index 000000000..6a36e9a0f Binary files /dev/null and b/packages/docs/public/showcase/sudoku-rabbit.webp differ diff --git a/packages/docs/scripts/refresh-showcase-metrics.mjs b/packages/docs/scripts/refresh-showcase-metrics.mjs new file mode 100644 index 000000000..b40abcd49 --- /dev/null +++ b/packages/docs/scripts/refresh-showcase-metrics.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +// ============================================================================= +// Refresh showcase ordering metrics +// ============================================================================= +// Fills `ratings` (App Store + Google Play review counts) and `installs` +// (Google Play install floor) for every entry in showcase-apps.json. +// +// The home page and /showcase order apps by `ratings` first, then `installs`. +// Neither store publishes download totals — Apple exposes no install data at +// all and Play only reports a bucket like "1K+" — so review count is the one +// verifiable signal both stores share. +// +// bun run showcase:metrics +// ============================================================================= + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DATA_PATH = join(HERE, '..', 'showcase-apps.json'); +const USER_AGENT = 'Mozilla/5.0 (compatible; openiap-showcase-metrics/1.0)'; + +// Apple reports userRatingCount per storefront and publishes no global total, +// so a US-only lookup misses every review left in other markets. Summing every +// storefront reconstructs the worldwide count. (Google Play already reports a +// single global review count, so it needs no equivalent pass.) +const APP_STORE_STOREFRONTS = + `ae ag ai al am ao ar at au az bb be bf bg bh bj bm bn bo br bs bt bw by bz + ca cd cg ch ci cl cm cn co cr cv cy cz de dk dm do dz ec ee eg es fi fj fm + fr ga gb gd gh gm gr gt gw gy hk hn hr hu id ie il in iq is it jm jo jp ke + kg kh kn kr kw ky kz la lb lc lk lr lt lu lv ly ma md me mg mk ml mm mn mo + mr ms mt mu mv mw mx my mz na ne ng ni nl no np nz om pa pe pg ph pk pl pt + pw py qa ro rs ru rw sa sb sc se sg si sk sl sn sr st sv sz tc td th tj tm + tn tr tt tw tz ua ug us uy uz vc ve vg vn vu ws ye za zm zw`.split(/\s+/); + +const STOREFRONT_CONCURRENCY = 5; +const STOREFRONT_BATCH_PAUSE_MS = 150; + +/** "1.2K" -> 1200, "3M" -> 3000000, "55" -> 55 */ +function parseCompact(value) { + const match = /^([\d.,]+)\s*([KMB])?/i.exec(value.trim()); + if (!match) return undefined; + const base = Number(match[1].replace(/,/g, '')); + if (!Number.isFinite(base)) return undefined; + const scale = { k: 1e3, m: 1e6, b: 1e9 }[match[2]?.toLowerCase()] ?? 1; + return Math.round(base * scale); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Apple throttles bursts of storefront lookups (HTTP 403), so retry with + * exponential backoff instead of silently recording a zero. + */ +async function fetchText(url, attempts = 4) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + if (response.ok) return response.text(); + lastError = new Error(`${response.status} ${url}`); + if (response.status !== 403 && response.status !== 429) throw lastError; + } catch (error) { + lastError = error; + } + await sleep(500 * 2 ** attempt); + } + throw lastError; +} + +/** Sums userRatingCount across every App Store storefront the app ships in. */ +async function appleRatings(iosUrl) { + const id = /\/id(\d+)/.exec(iosUrl)?.[1]; + if (!id) return { ratings: undefined, markets: 0 }; + + let ratings = 0; + let markets = 0; + + const lookup = async (country) => { + const body = await fetchText( + `https://itunes.apple.com/lookup?id=${id}&country=${country}` + ); + return JSON.parse(body).results?.[0]?.userRatingCount ?? 0; + }; + + const record = (count) => { + if (count > 0) markets += 1; + ratings += count; + }; + + let pending = APP_STORE_STOREFRONTS; + + for (let round = 0; round < 3 && pending.length > 0; round += 1) { + const failed = []; + + for ( + let offset = 0; + offset < pending.length; + offset += STOREFRONT_CONCURRENCY + ) { + const batch = pending.slice(offset, offset + STOREFRONT_CONCURRENCY); + const results = await Promise.all( + batch.map(async (country) => { + try { + return { count: await lookup(country) }; + } catch { + return { country }; + } + }) + ); + for (const result of results) { + if (result.country) failed.push(result.country); + else record(result.count); + } + await sleep(STOREFRONT_BATCH_PAUSE_MS); + } + + pending = failed; + // Throttled storefronts usually clear after a short cool-down. + if (pending.length > 0) await sleep(5000); + } + + if (pending.length > 0) { + // A partial sweep would silently under-count, so surface it loudly rather + // than writing a number that looks authoritative. + throw new Error( + `${pending.length}/${APP_STORE_STOREFRONTS.length} storefront lookups failed — ratings would be under-counted` + ); + } + return { ratings, markets }; +} + +// Text nodes that end in "reviews" but are chrome, not a count. +const PLAY_REVIEW_CHROME = /^(ratings and reviews|reviews|all reviews)$/i; + +/** + * Extracts Play metrics from a store page, fail-closed. + * + * Play omits the review element entirely for apps with few or no reviews, so an + * absent count is a legitimate zero. Anything else is treated as our selectors + * having drifted from Play's markup, which must not be written over a real + * number: + * + * - install block missing → the page shape changed (it renders on every app + * page, so it is the canary that our selectors still match) + * - a count-shaped review node present but unparseable → the review markup + * changed underneath us + */ +export function parsePlayMetrics(html, packageName = 'app') { + const installs = />([\d.,]+\s*[KMB]?\+)<\/div>
Downloads([\d.,]+\s*[KMB]?)\s*reviews([^<]{0,40}?reviews) match[1].trim()) + .filter((text) => !PLAY_REVIEW_CHROME.test(text)) + .filter((text) => /\d/.test(text)); + + if (orphaned.length > 0) { + throw new Error( + `found review element "${orphaned[0]}" but could not read its count for ${packageName}` + ); + } + + return { ratings: 0, installs: parseCompact(installs) }; +} + +async function playMetrics(androidUrl) { + const packageName = /[?&]id=([^&]+)/.exec(androidUrl)?.[1]; + if (!packageName) return {}; + const html = await fetchText( + `https://play.google.com/store/apps/details?id=${packageName}&hl=en&gl=US` + ); + return parsePlayMetrics(html, packageName); +} + +async function main() { + const data = JSON.parse(await readFile(DATA_PATH, 'utf8')); + let changed = 0; + + let stale = 0; + + for (const app of data.apps) { + // Web-only entries have no store to measure; leave whatever is on record + // instead of writing a zero that looks like a real reading. + if (!app.ios && !app.android) { + console.log(` ${app.name}: no store links — metrics left untouched`); + continue; + } + + let ratings = 0; + let installs; + let appleMarkets = 0; + let incomplete = false; + + if (app.ios) { + try { + const apple = await appleRatings(app.ios); + ratings += apple.ratings ?? 0; + appleMarkets = apple.markets; + } catch (error) { + console.warn(` ! ${app.name}: App Store — ${error.message}`); + incomplete = true; + } + } + + if (app.android) { + try { + const play = await playMetrics(app.android); + ratings += play.ratings ?? 0; + installs = play.installs; + } catch (error) { + console.warn(` ! ${app.name}: Play — ${error.message}`); + incomplete = true; + } + } + + // Last line of defence: every reading can look individually valid and still + // collapse a real count to zero if a selector drifts silently. Losing an + // established count is always a regression, never a legitimate reading. + if (ratings === 0 && (app.ratings ?? 0) > 0) { + incomplete = true; + console.warn( + ` ! ${app.name}: refusing to drop ratings ${app.ratings} → 0 — check the store selectors` + ); + } + + if (incomplete) { + // Keep the previous numbers rather than replacing them with a partial sweep. + stale += 1; + console.log(` ${app.name}: kept existing ratings=${app.ratings ?? 0}`); + continue; + } + + const nextInstalls = installs ?? app.installs; + if (app.ratings !== ratings || app.installs !== nextInstalls) changed += 1; + + app.ratings = ratings; + if (nextInstalls === undefined) delete app.installs; + else app.installs = nextInstalls; + + console.log( + ` ${app.name}: ratings=${ratings}` + + (appleMarkets ? ` (App Store in ${appleMarkets} markets)` : '') + + (nextInstalls === undefined ? '' : ` installs=${nextInstalls}`) + ); + } + + await writeFile(DATA_PATH, `${JSON.stringify(data, null, 2)}\n`); + + const ranking = [...data.apps] + .sort( + (a, b) => + (b.ratings ?? 0) - (a.ratings ?? 0) || + (b.installs ?? 0) - (a.installs ?? 0) + ) + .map((app, index) => ` ${index + 1}. ${app.name} (${app.ratings ?? 0})`) + .join('\n'); + + console.log(`\nUpdated ${changed} of ${data.apps.length} entries.`); + if (stale > 0) { + console.log(`${stale} kept previous numbers — rerun to refresh them.`); + } + console.log(`\nRanking\n${ranking}`); +} + +// Only refresh when run directly; importing for tests must stay side-effect free. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/packages/docs/scripts/refresh-showcase-metrics.test.mjs b/packages/docs/scripts/refresh-showcase-metrics.test.mjs new file mode 100644 index 000000000..729a36c46 --- /dev/null +++ b/packages/docs/scripts/refresh-showcase-metrics.test.mjs @@ -0,0 +1,69 @@ +// Fixtures for the Google Play scrape in refresh-showcase-metrics.mjs. +// +// The parser must never turn a selector drift into a zero: ordering metrics are +// written straight into showcase-apps.json, so a silent zero demotes a real app. +// Play does omit the review element for apps with few or no reviews, though, so +// "absent" and "broken" have to stay distinguishable. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parsePlayMetrics } from './refresh-showcase-metrics.mjs'; + +const installBlock = (value = '1K+') => + `
${value}
Downloads
`; + +const reviewBlock = (value = '55') => + `
${value} reviews
`; + +const heading = '

Ratings and reviews

'; + +test('reads review count and install floor from a populated page', () => { + const html = `${heading}${reviewBlock('55')}${installBlock('1K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 55, + installs: 1000, + }); +}); + +test('parses compact review counts', () => { + const html = `${reviewBlock('1.2K')}${installBlock('500K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 1200, + installs: 500000, + }); +}); + +test('treats an absent review element as a real zero', () => { + // Newly released apps render the heading and install block but no count. + const html = `${heading}${installBlock('1+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.new'), { + ratings: 0, + installs: 1, + }); +}); + +test('throws when the install block is missing', () => { + const html = `${heading}${reviewBlock('55')}`; + assert.throws( + () => parsePlayMetrics(html, 'com.example.app'), + /install count not found/ + ); +}); + +test('throws when a review element exists but its count cannot be read', () => { + // Play kept the element and changed the number format underneath us. + const html = `${heading}
many reviews
1 234 reviews
${installBlock('1K+')}`; + assert.throws( + () => parsePlayMetrics(html, 'com.example.app'), + /could not read its count/ + ); +}); + +test('does not mistake review chrome for a count', () => { + const html = `${heading}Ratings and reviews${installBlock('10K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 0, + installs: 10000, + }); +}); diff --git a/packages/docs/showcase-apps.json b/packages/docs/showcase-apps.json new file mode 100644 index 000000000..401c66760 --- /dev/null +++ b/packages/docs/showcase-apps.json @@ -0,0 +1,43 @@ +{ + "apps": [ + { + "name": "Sudoku Rabbit", + "tagline": "The competitive sudoku app built for speed and comfort", + "logo": "/showcase/sudoku-rabbit.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/sudoku-rabbit-daily-puzzles/id6742900571", + "android": "https://play.google.com/store/apps/details?id=com.bustedout.sudokurabbit", + "ratings": 146, + "installs": 1000 + }, + { + "name": "Loader", + "tagline": "Media player and downloader — stream music, movies, and TV shows", + "logo": "/showcase/loader.webp", + "library": "react-native-iap", + "ios": "https://apps.apple.com/us/app/documents-loader/id1442498151", + "web": "https://loaderapp.info/", + "ratings": 9 + }, + { + "name": "Martie", + "tagline": "Daily trivia quiz — 5 questions a day to build a knowledge habit", + "logo": "/showcase/martie.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/martie-daily-trivia-quiz/id6740057833", + "android": "https://play.google.com/store/apps/details?id=dev.hyo.martie", + "ratings": 12, + "installs": 500 + }, + { + "name": "RecallAI", + "tagline": "Private, local-first AI memo and knowledge base", + "logo": "/showcase/recallai.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/recallai-ai-memo-chat/id6784931826", + "android": "https://play.google.com/store/apps/details?id=dev.hyo.recallai", + "ratings": 0, + "installs": 1 + } + ] +} diff --git a/packages/docs/src/App.tsx b/packages/docs/src/App.tsx index 437de4537..94999ed26 100644 --- a/packages/docs/src/App.tsx +++ b/packages/docs/src/App.tsx @@ -10,6 +10,7 @@ import Docs from './pages/docs'; import Languages from './pages/languages'; import Tutorials from './pages/tutorials'; import Sponsors from './pages/sponsors'; +import Showcase from './pages/showcase'; import NotFound from './pages/404'; import { searchModalSignal, closeSearchModal } from './lib/signals'; import { effect } from '@preact/signals-react'; @@ -37,6 +38,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/packages/docs/src/components/ShowcaseCards.tsx b/packages/docs/src/components/ShowcaseCards.tsx new file mode 100644 index 000000000..3a0ee2047 --- /dev/null +++ b/packages/docs/src/components/ShowcaseCards.tsx @@ -0,0 +1,180 @@ +import type { CSSProperties } from 'react'; +import { SiApple, SiGoogleplay } from 'react-icons/si'; +import { Globe } from 'lucide-react'; +import type { ShowcaseApp } from '../lib/showcase'; + +export const SHOWCASE_ISSUE_URL = + 'https://github.com/hyodotdev/openiap/issues/280'; + +export const SHOWCASE_GUIDE_URL = + 'https://github.com/hyodotdev/openiap/blob/main/packages/docs/SHOWCASE.md'; + +export const showcaseGridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: '1rem', +}; + +const cardStyle: CSSProperties = { + display: 'flex', + gap: '1rem', + alignItems: 'flex-start', + padding: '1.25rem', + border: '1px solid var(--border-color)', + borderRadius: '0.875rem', + textAlign: 'left', +}; + +const logoStyle: CSSProperties = { + width: '56px', + height: '56px', + // Matches the rounded mask baked into /showcase icons so store artwork with + // and without built-in corners renders identically. + borderRadius: '22.37%', + objectFit: 'cover', + flexShrink: 0, +}; + +const storeLinkStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '28px', + height: '28px', + borderRadius: '0.5rem', + border: '1px solid var(--border-color)', + color: 'var(--text-secondary)', + textDecoration: 'none', +}; + +export function ShowcaseAppCard({ app }: { app: ShowcaseApp }) { + return ( +
+ {`${app.name} +
+
+ {app.name} +
+
+ {app.tagline} +
+
+ {app.ios ? ( + + + + ) : null} + {app.android ? ( + + + + ) : null} + {app.web ? ( + + + + ) : null} + + {app.library} + +
+
+
+ ); +} + +/** Sits in the app grid as the last cell, inviting the next submission. */ +export function ShowcaseSubmitCard() { + return ( +
+
Ship with OpenIAP?
+
+ Send your app name, icon, and store links — we'll add it here. +
+ + Submit Your App + +
+ ); +} diff --git a/packages/docs/src/lib/showcase.ts b/packages/docs/src/lib/showcase.ts new file mode 100644 index 000000000..fdcd22129 --- /dev/null +++ b/packages/docs/src/lib/showcase.ts @@ -0,0 +1,69 @@ +// ============================================================================= +// Showcase Apps +// ============================================================================= +// Apps shipped with OpenIAP libraries, rendered in the "Who uses OpenIAP?" +// section on the home page and in full on /showcase. +// +// To add an app, edit `showcase-apps.json` at the root of packages/docs and +// open a pull request. See SHOWCASE.md for the submission guide. +// ============================================================================= + +import * as showcaseData from '../../showcase-apps.json'; +import type { FrameworkLibraryName } from './images'; + +export type ShowcaseApp = { + /** App name as it appears on the stores. */ + name: string; + /** One-line description shown under the app name. */ + tagline: string; + /** Path under packages/docs/public (e.g. `/showcase/app.webp`) or an https URL. */ + logo: string; + /** Which OpenIAP library the app ships with. */ + library: FrameworkLibraryName; + ios?: string; + android?: string; + web?: string; + /** + * App Store + Google Play review counts combined. Primary ordering key — + * neither store publishes download totals, so this is the one verifiable + * signal both platforms share. + */ + ratings?: number; + /** Google Play install floor ("1K+" → 1000). Tiebreaker when ratings match. */ + installs?: number; +}; + +/** How many apps the home page highlights before "See all". */ +export const FEATURED_SHOWCASE_LIMIT = 5; + +function hasLink(app: ShowcaseApp): boolean { + return Boolean(app.ios ?? app.android ?? app.web); +} + +/** + * Orders by combined review count (desc), falling back to Play installs, then + * submission order. Refresh the numbers with `bun run showcase:metrics`. + */ +function byReach(apps: ShowcaseApp[]): ShowcaseApp[] { + return apps + .map((app, index) => ({ app, index })) + .sort((a, b) => { + const ratings = (b.app.ratings ?? 0) - (a.app.ratings ?? 0); + if (ratings !== 0) return ratings; + const installs = (b.app.installs ?? 0) - (a.app.installs ?? 0); + if (installs !== 0) return installs; + return a.index - b.index; + }) + .map((entry) => entry.app); +} + +export const SHOWCASE_APPS: ShowcaseApp[] = byReach( + (showcaseData.apps as ShowcaseApp[]).filter( + (app) => Boolean(app.name && app.logo) && hasLink(app) + ) +); + +export const FEATURED_SHOWCASE_APPS: ShowcaseApp[] = SHOWCASE_APPS.slice( + 0, + FEATURED_SHOWCASE_LIMIT +); diff --git a/packages/docs/src/pages/home.tsx b/packages/docs/src/pages/home.tsx index 8617ebbba..c000880e0 100644 --- a/packages/docs/src/pages/home.tsx +++ b/packages/docs/src/pages/home.tsx @@ -3,6 +3,12 @@ import { Link } from 'react-router-dom'; import { OPENIAP_VERSIONS } from '../lib/versioning'; import { LOGO_PATH } from '../lib/config'; import { LIBRARIES } from '../lib/images'; +import { FEATURED_SHOWCASE_APPS, SHOWCASE_APPS } from '../lib/showcase'; +import { + ShowcaseAppCard, + ShowcaseSubmitCard, + showcaseGridStyle, +} from '../components/ShowcaseCards'; import SEO from '../components/SEO'; const frameworkLinkStyle: CSSProperties = { @@ -511,7 +517,7 @@ function Home() {
-
+

Who uses OpenIAP?

( - {library.displayName} + + {library.displayName} + {index < LIBRARIES.length - 1 ? ', ' : ''} ))} @@ -528,66 +541,26 @@ function Home() {
We'd love to showcase it here.

-
-

- Send us your app name, logo, platform links, and which library you - use — we'll add you to this section. -

- - Submit Your App - -

- Contact: Hyo — Lead Maintainer ( - + {FEATURED_SHOWCASE_APPS.map((app) => ( + + ))} + +

+ {SHOWCASE_APPS.length > FEATURED_SHOWCASE_APPS.length ? ( + + See all {SHOWCASE_APPS.length} apps → + +
+ ) : null}
diff --git a/packages/docs/src/pages/showcase.tsx b/packages/docs/src/pages/showcase.tsx new file mode 100644 index 000000000..35c7a74dd --- /dev/null +++ b/packages/docs/src/pages/showcase.tsx @@ -0,0 +1,121 @@ +import SEO from '../components/SEO'; +import { + ShowcaseAppCard, + ShowcaseSubmitCard, + SHOWCASE_GUIDE_URL, + SHOWCASE_ISSUE_URL, + showcaseGridStyle, +} from '../components/ShowcaseCards'; +import { SHOWCASE_APPS } from '../lib/showcase'; + +function Showcase() { + return ( +
+ +
+
+

Who uses OpenIAP?

+

+ {SHOWCASE_APPS.length} apps ship in-app purchases with OpenIAP + libraries. Ordered by App Store and Google Play review counts. +

+
+ {SHOWCASE_APPS.map((app) => ( + + ))} + +
+ +
+

Add your app

+

+ Comment on{' '} + + the showcase issue + {' '} + with the details below and we'll add your app. Prefer a pull + request? Add an entry to{' '} + + showcase-apps.json + + , or email{' '} + + hyo@hyo.dev + + . +

+
    +
  • + App name and a one-line description +
  • +
  • + App icon — square, 512×512 PNG (we round the + corners and convert it for you) +
  • +
  • + Store links — App Store and/or Google Play +
  • +
  • + Library you ship with (expo-iap, + react-native-iap, flutter_inapp_purchase, kmp-iap, maui-iap, + godot-iap) +
  • +
+

+ Apps are listed only with your permission. Ask for an update or + removal anytime. +

+
+
+
+
+ ); +} + +export default Showcase; diff --git a/packages/docs/src/styles/home.css b/packages/docs/src/styles/home.css index ccc4318c3..980179e14 100644 --- a/packages/docs/src/styles/home.css +++ b/packages/docs/src/styles/home.css @@ -357,6 +357,15 @@ text-align: center; } +.section-subtitle a { + color: var(--accent-color); + text-decoration: none; +} + +.section-subtitle a:hover { + text-decoration: underline; +} + /* Specification Grid */ .specification-grid { display: grid;