diff --git a/.eslintrc.js b/.eslintrc.js index 943c15e1..1ebe6076 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,7 +20,8 @@ module.exports = { }, plugins: ['@typescript-eslint'], rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + // varsIgnorePattern covers _prefixed destructured variables (e.g. const { _unused, ...rest } = obj) + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', diff --git a/.github/workflows/check-csp-hash.yml b/.github/workflows/check-csp-hash.yml new file mode 100644 index 00000000..0f38ab0b --- /dev/null +++ b/.github/workflows/check-csp-hash.yml @@ -0,0 +1,32 @@ +name: Check CSP Hash + +on: + push: + branches: + - main + - develop + paths: + - 'apps/web/index.html' + - 'infra/aws/pr-preview-stack.yml' + - 'scripts/check-csp-hash.mjs' + pull_request: + paths: + - 'apps/web/index.html' + - 'infra/aws/pr-preview-stack.yml' + - 'scripts/check-csp-hash.mjs' + - '.github/workflows/check-csp-hash.yml' + +permissions: + contents: read + +jobs: + check-csp-hash: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - name: Verify CSP inline-script hash + run: node scripts/check-csp-hash.mjs diff --git a/.github/workflows/check-merge-strategy.yml b/.github/workflows/check-merge-strategy.yml index 285e0fc2..1545d5de 100644 --- a/.github/workflows/check-merge-strategy.yml +++ b/.github/workflows/check-merge-strategy.yml @@ -2,7 +2,7 @@ name: Check Merge Strategy on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, edited] permissions: pull-requests: write @@ -10,7 +10,7 @@ permissions: jobs: check-merge-strategy: - if: github.base_ref == 'main' + if: github.base_ref == 'main' && (github.event.action != 'edited' || github.event.changes.base) runs-on: ubuntu-latest steps: - name: Add merge strategy reminder comment diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index cbfe9e26..bce278e5 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -8,6 +8,7 @@ on: permissions: contents: read id-token: write + deployments: write concurrency: group: staging-deploy @@ -146,6 +147,94 @@ jobs: --environment staging \ --region "${AWS_REGION_VALUE}" + - name: Generate staging signed cookie bootstrap URL + id: signed_cookies + shell: bash + env: + CLOUDFRONT_SIGNING_KEY: ${{ secrets.CLOUDFRONT_SIGNING_KEY }} + CLOUDFRONT_SIGNING_KEY_ID: ${{ secrets.CLOUDFRONT_SIGNING_KEY_ID }} + run: | + set -euo pipefail + # secrets.* is not allowed in step `if:` — gate here (direct URL access when neither secret is set). + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" && -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + exit 0 + fi + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" || -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + echo "::error::CLOUDFRONT_SIGNING_KEY and CLOUDFRONT_SIGNING_KEY_ID must both be set. Configure both secrets or neither." + exit 1 + fi + STAGING_DOMAIN="staging.${DOMAIN}" + RESOURCE="https://${STAGING_DOMAIN}/*" + EXPIRY=$(date -u -d "+30 days" +%s) + + POLICY_JSON=$(printf '{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%s}}}]}' \ + "${RESOURCE}" "${EXPIRY}") + + # CloudFront URL-safe base64: + → -, / → ~, = → _ + CF_POLICY=$(printf '%s' "${POLICY_JSON}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + + # Sign with RSA-SHA1 (CloudFront signed cookies requirement) + KEY_FILE=$(mktemp); chmod 600 "${KEY_FILE}" + printf '%s' "${CLOUDFRONT_SIGNING_KEY}" > "${KEY_FILE}" + CF_SIGNATURE=$(printf '%s' "${POLICY_JSON}" | \ + openssl dgst -sha1 -sign "${KEY_FILE}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + rm -f "${KEY_FILE}" + + BOOTSTRAP_URL="https://${STAGING_DOMAIN}/_preview-auth?policy=${CF_POLICY}&sig=${CF_SIGNATURE}&kid=${CLOUDFRONT_SIGNING_KEY_ID}&dest=/" + echo "BOOTSTRAP_URL=${BOOTSTRAP_URL}" >> "${GITHUB_OUTPUT}" + echo "STAGING_URL=https://${STAGING_DOMAIN}" >> "${GITHUB_OUTPUT}" + + - name: Create GitHub Deployment (staging) + uses: actions/github-script@v7 + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + STAGING_URL: ${{ steps.signed_cookies.outputs.STAGING_URL || format('https://staging.{0}', env.DOMAIN) }} + with: + script: | + const environmentUrl = process.env.BOOTSTRAP_URL || process.env.STAGING_URL; + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: 'staging', + auto_merge: false, + required_contexts: [], + description: 'Staging deployment', + }); + if (deployment.data.id) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: environmentUrl, + description: 'Staging deployed', + }); + } + + - name: Write Actions run summary + shell: bash + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + STAGING_URL: ${{ steps.signed_cookies.outputs.STAGING_URL || format('https://staging.{0}', env.DOMAIN) }} + run: | + set -euo pipefail + if [[ -n "${BOOTSTRAP_URL}" ]]; then + cat >> "${GITHUB_STEP_SUMMARY}" < Click the link to set your access cookie, then browse staging normally. Expires in 30 days. + SUMMARY + else + cat >> "${GITHUB_STEP_SUMMARY}" <- + github.event.action != 'closed' && + (github.base_ref != 'develop' || github.head_ref != 'main') && + (github.event.action != 'edited' || github.event.changes.base) runs-on: ubuntu-latest outputs: run_deploy: ${{ steps.check.outputs.run_deploy }} @@ -51,6 +57,14 @@ jobs: if: github.event.action != 'closed' && needs.preview-scope.outputs.run_deploy == 'true' && (github.base_ref != 'develop' || github.head_ref != 'main') needs: preview-scope runs-on: ubuntu-latest + outputs: + bootstrap-url: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + access-mode: ${{ steps.signed_cookies.outputs.ACCESS_MODE }} + supabase-outcome: ${{ steps.supabase.outcome }} + stripe-sync-outcome: ${{ steps.stripe-sync.outcome }} + stripe-webhook-outcome: ${{ steps.stripe_webhook_preview.outcome }} + billing-functions-outcome: ${{ steps.billing-functions.outcome }} + web-outcome: ${{ steps.web.outcome }} env: PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} PREVIEW_HOSTED_ZONE_ID: ${{ vars.PR_PREVIEW_HOSTED_ZONE_ID }} @@ -61,6 +75,9 @@ jobs: SUPABASE_PREVIEW_DB_PASSWORD: ${{ secrets.SUPABASE_PREVIEW_DB_PASSWORD }} SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} SUPABASE_PREVIEW_DB_URL: ${{ secrets.SUPABASE_PREVIEW_DB_URL }} + PRODUCTION_SUPABASE_URL: ${{ secrets.PRODUCTION_SUPABASE_URL }} + STAGING_SUPABASE_URL: ${{ secrets.STAGING_SUPABASE_URL }} + PREVIEW_SUPABASE_URL: ${{ secrets.PREVIEW_SUPABASE_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -123,6 +140,8 @@ jobs: --skip-if-unchanged - name: Sync Stripe prices to preview billing_plans + id: stripe-sync + continue-on-error: true env: STRIPE_SECRET_KEY: ${{ secrets.PREVIEW_STRIPE_SECRET_KEY }} SUPABASE_URL: ${{ secrets.PREVIEW_SUPABASE_URL }} @@ -133,6 +152,7 @@ jobs: - name: Ensure Stripe webhook endpoint (preview) id: stripe_webhook_preview + continue-on-error: true env: STRIPE_SECRET_KEY: ${{ secrets.PREVIEW_STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_URL: ${{ secrets.PREVIEW_SUPABASE_URL }}/functions/v1/stripe-webhook @@ -141,6 +161,8 @@ jobs: run: node scripts/ensure-stripe-webhook-endpoint.mjs - name: Deploy preview billing edge functions + id: billing-functions + continue-on-error: true shell: bash env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} @@ -193,6 +215,123 @@ jobs: --preview-prefix "${PREVIEW_PREFIX_VALUE}" \ --region "${AWS_REGION_VALUE}" + # Generate CloudFront signed cookies and post GitHub Deployment with access URL. + # Skipped when neither signing secret is set (public access mode). Partial config fails the step. + - name: Generate signed cookie bootstrap URL + id: signed_cookies + if: steps.web.outcome == 'success' + shell: bash + env: + CLOUDFRONT_SIGNING_KEY: ${{ secrets.CLOUDFRONT_SIGNING_KEY }} + CLOUDFRONT_SIGNING_KEY_ID: ${{ secrets.CLOUDFRONT_SIGNING_KEY_ID }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + # secrets.* is not allowed in step `if:` — gate here (public preview when neither secret is set). + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" && -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + exit 0 + fi + if [[ -z "${CLOUDFRONT_SIGNING_KEY:-}" || -z "${CLOUDFRONT_SIGNING_KEY_ID:-}" ]]; then + echo "::error::CLOUDFRONT_SIGNING_KEY and CLOUDFRONT_SIGNING_KEY_ID must both be set. Configure both secrets or neither." + exit 1 + fi + PREVIEW_PREFIX_VALUE="${PREVIEW_PREFIX:-pr-}" + DEPLOY_DOMAIN="deploy.${PREVIEW_DOMAIN}" + DEST="/${PREVIEW_PREFIX_VALUE}${PR_NUMBER}/" + RESOURCE="https://${DEPLOY_DOMAIN}/${PREVIEW_PREFIX_VALUE}${PR_NUMBER}/*" + EXPIRY=$(date -u -d "+7 days" +%s) + + POLICY_JSON=$(printf '{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%s}}}]}' \ + "${RESOURCE}" "${EXPIRY}") + + # CloudFront URL-safe base64: + → -, / → ~, = → _ + CF_POLICY=$(printf '%s' "${POLICY_JSON}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + + # Sign with RSA-SHA1 (CloudFront signed cookies requirement) + KEY_FILE=$(mktemp); chmod 600 "${KEY_FILE}" + printf '%s' "${CLOUDFRONT_SIGNING_KEY}" > "${KEY_FILE}" + CF_SIGNATURE=$(printf '%s' "${POLICY_JSON}" | \ + openssl dgst -sha1 -sign "${KEY_FILE}" | base64 -w0 | tr '+' '-' | tr '/' '~' | tr '=' '_') + rm -f "${KEY_FILE}" + + ENCODED_DEST=$(printf '%s' "${DEST}" | python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.stdin.read().strip()))") + BOOTSTRAP_URL="https://${DEPLOY_DOMAIN}/_preview-auth?policy=${CF_POLICY}&sig=${CF_SIGNATURE}&kid=${CLOUDFRONT_SIGNING_KEY_ID}&dest=${ENCODED_DEST}" + echo "BOOTSTRAP_URL=${BOOTSTRAP_URL}" >> "${GITHUB_OUTPUT}" + echo "ACCESS_MODE=signed-cookies" >> "${GITHUB_OUTPUT}" + + - name: Create GitHub Deployment (PR preview) + if: steps.web.outcome == 'success' + uses: actions/github-script@v7 + env: + BOOTSTRAP_URL: ${{ steps.signed_cookies.outputs.BOOTSTRAP_URL }} + PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} + PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + // Fork PRs: pull_request events from forks run with a read-only token; + // deployments: write is unavailable — skip cleanly rather than error. + // head.repo can be null when the fork is deleted before the workflow runs. + const headRepo = context.payload.pull_request.head.repo?.full_name; + const baseRepo = `${context.repo.owner}/${context.repo.repo}`; + if (!headRepo || headRepo !== baseRepo) { + core.info(`Skipping: fork PR (${headRepo ?? 'deleted repo'}) — Deployments API write not available for fork PRs.`); + return; + } + + const environment = `pr-${process.env.PR_NUMBER}-preview`; + const prefix = process.env.PREVIEW_PREFIX || 'pr-'; + const webUrl = `https://deploy.${process.env.PREVIEW_DOMAIN}/${prefix}${process.env.PR_NUMBER}/`; + const environmentUrl = process.env.BOOTSTRAP_URL || webUrl; + const logUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + // Create the new deployment first. Only deactivate previous deployments + // after success so a partial failure doesn't leave the PR Deployments + // panel empty. Use head.sha (not context.sha which is the synthetic merge + // commit) so GitHub associates the deployment with the PR's own head ref. + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.payload.pull_request.head.sha, + environment, + auto_merge: false, + required_contexts: [], + description: `PR #${process.env.PR_NUMBER} preview`, + }); + if (!deployment.data.id) { + core.warning(`createDeployment returned no id (HTTP ${deployment.status}): ${deployment.data.message ?? JSON.stringify(deployment.data)}`); + return; + } + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: environmentUrl, + log_url: logUrl, + description: 'Preview deployed', + }); + + // Deactivate previous deployments for this environment now that the new + // one is registered, keeping only one active row on the PR Deployments tab. + const { data: existing } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment, + per_page: 100, + }); + for (const dep of existing) { + if (dep.id === deployment.data.id) continue; + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: dep.id, + state: 'inactive', + }); + } + + # Publish edge routing only after a successful web build so a failed deploy cannot + # replace live CloudFront function code while leaving stale or missing S3 assets. - name: Publish PR path router (CloudFront) if: steps.web.outcome == 'success' shell: bash @@ -308,6 +447,14 @@ jobs: env: PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + BOOTSTRAP_URL: ${{ needs.deploy-preview.outputs.bootstrap-url }} + ACCESS_MODE: ${{ needs.deploy-preview.outputs.access-mode }} + SUPABASE_OUTCOME: ${{ needs.deploy-preview.outputs.supabase-outcome }} + STRIPE_SYNC_OUTCOME: ${{ needs.deploy-preview.outputs.stripe-sync-outcome }} + STRIPE_WEBHOOK_OUTCOME: ${{ needs.deploy-preview.outputs.stripe-webhook-outcome }} + BILLING_FUNCTIONS_OUTCOME: ${{ needs.deploy-preview.outputs.billing-functions-outcome }} + WEB_OUTCOME: ${{ needs.deploy-preview.outputs.web-outcome }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} MOBILE_JOB_RESULT: ${{ needs.deploy-preview-mobile.result }} MOBILE_CHANNEL: ${{ needs.deploy-preview-mobile.outputs.mobile-channel }} MOBILE_UPDATE_URL: ${{ needs.deploy-preview-mobile.outputs.mobile-update-url }} @@ -321,49 +468,110 @@ jobs: const prefix = process.env.PREVIEW_PREFIX || 'pr-'; const prNumber = context.payload.pull_request.number; const webUrl = domain ? `https://deploy.${domain}/${prefix}${prNumber}/` : ''; - const mobileJobResult = process.env.MOBILE_JOB_RESULT; - const mobileChannel = process.env.MOBILE_CHANNEL; - const mobileUpdateUrl = process.env.MOBILE_UPDATE_URL; + const runUrl = process.env.RUN_URL; + const bootstrapUrl = process.env.BOOTSTRAP_URL; + const isSignedCookies = process.env.ACCESS_MODE === 'signed-cookies'; + + const webOutcome = process.env.WEB_OUTCOME; + const supabaseOutcome = process.env.SUPABASE_OUTCOME; + const stripeSyncOutcome = process.env.STRIPE_SYNC_OUTCOME; + const stripeWebhookOutcome = process.env.STRIPE_WEBHOOK_OUTCOME; + const billingFnsOutcome = process.env.BILLING_FUNCTIONS_OUTCOME; + const mobileJobResult = process.env.MOBILE_JOB_RESULT; + const mobileChannel = process.env.MOBILE_CHANNEL; + const mobileUpdateUrl = process.env.MOBILE_UPDATE_URL; + + function statusCell(outcome, successLabel) { + if (outcome === 'success') return `✅ ${successLabel}`; + if (outcome === 'failure') return '❌ Failed'; + if (outcome === 'skipped') return '⏭️ Skipped'; + if (outcome === 'cancelled') return '🚫 Cancelled'; + return '—'; + } + + // Aggregate billing: any failure beats all; then cancelled; then success + const billingOutcomes = [stripeSyncOutcome, stripeWebhookOutcome, billingFnsOutcome]; + let billingStatus; + if (billingOutcomes.some(o => o === 'failure')) billingStatus = 'failure'; + else if (billingOutcomes.some(o => o === 'cancelled')) billingStatus = 'cancelled'; + else if (billingOutcomes.every(o => o === 'success')) billingStatus = 'success'; + else billingStatus = billingOutcomes.find(o => o && o !== 'success') || 'success'; + + const logsLink = `[View logs](${runUrl})`; + const webLink = (() => { + if (webOutcome !== 'success') return logsLink; + if (isSignedCookies && bootstrapUrl) + return `[Access preview →](${bootstrapUrl}) _(sets cookie, expires 7 days)_`; + return webUrl ? `[Preview →](${webUrl})` : '—'; + })(); + + const mobileStatus = + mobileJobResult === 'skipped' ? 'skipped' : + (mobileJobResult === 'success' && !mobileUpdateUrl) ? 'skipped' : + mobileJobResult || 'skipped'; + const mobileLinkCell = mobileJobResult === 'failure' ? logsLink : '—'; + + const table = [ + '| Component | Status | Link |', + '|-----------|--------|------|', + `| 🌐 Web | ${statusCell(webOutcome, 'Deployed')} | ${webLink} |`, + `| 🗄️ Database | ${statusCell(supabaseOutcome, 'Migrated')} | ${supabaseOutcome === 'failure' ? logsLink : '—'} |`, + `| 💳 Billing | ${statusCell(billingStatus, 'Deployed')} | ${billingStatus === 'failure' ? logsLink : '—'} |`, + `| 📱 Mobile | ${statusCell(mobileStatus, 'Deployed')} | ${mobileLinkCell} |`, + ].join('\n'); + + // HH:MM PT timestamp + const now = new Date(); + const timeStr = now.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + const tzStr = now.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + timeZoneName: 'short', + }).split(', ').pop().split(' ').pop(); const lines = []; - lines.push(''); - if (webUrl) { - lines.push(`🌐 **Web preview:** ${webUrl}`); - } - if (mobileJobResult === 'skipped') { - // Mobile disabled via MOBILE_ENABLED=false — omit mobile section - } else if (mobileJobResult === 'failure') { - lines.push(''); - lines.push('❌ **Mobile preview failed.** Check the **deploy-preview-mobile** job logs for details.'); - } else if (mobileUpdateUrl) { + lines.push(marker); + lines.push(''); + lines.push(table); + lines.push(''); + lines.push(`_Last deployed: ${timeStr} ${tzStr}_`); + + // Mobile detail section — only when mobile succeeded with an update URL + if (mobileJobResult !== 'skipped' && mobileJobResult !== 'failure' && mobileUpdateUrl) { const iosDownloadUrl = process.env.MOBILE_IOS_DOWNLOAD_URL; const androidDownloadUrl = process.env.MOBILE_ANDROID_DOWNLOAD_URL; const needsNativeBuild = process.env.NEEDS_NATIVE_BUILD === 'true'; - + + lines.push(''); + lines.push('---'); + lines.push(''); lines.push(`📱 **Mobile preview:** Channel \`${mobileChannel}\``); lines.push(''); - - // Show native build downloads if available + if (iosDownloadUrl || androidDownloadUrl) { lines.push('### 📱 Mobile Preview Builds'); lines.push(''); lines.push('**Native builds are available for download:**'); lines.push(''); - + if (iosDownloadUrl) { lines.push('**iOS Simulator Build:**'); lines.push(`- [Download .app file](${iosDownloadUrl})`); lines.push('- Install with: `xcrun simctl install booted ~/Downloads/BeakerStack.app`'); lines.push(''); } - + if (androidDownloadUrl) { lines.push('**Android Build (Device & Emulator):**'); lines.push(`- [Download .apk file](${androidDownloadUrl})`); lines.push('- Install with: `adb install ~/Downloads/BeakerStack.apk`'); lines.push(''); } - + lines.push('**Note:** These builds include the preview Supabase configuration and are ready to use.'); lines.push(''); } else if (needsNativeBuild) { @@ -397,23 +605,11 @@ jobs: lines.push(`**Alternative:** For local development, use: \`cd apps/mobile && npx expo start --dev-client\`, then press 'i' for iOS simulator.`); lines.push(''); } - + lines.push('📖 See [Mobile Build Testing Guide](../../docs/MOBILE_BUILD_TESTING.md) for detailed instructions.'); } - let body = lines.join('\n'); - // Add timestamp in Pacific time - const now = new Date(); - const pacificTime = now.toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - dateStyle: 'long', - timeStyle: 'short' - }); - const timezone = now.toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - timeZoneName: 'short' - }).split(' ').pop(); - body = `${body}\n\n---\n\n_Updated at: ${pacificTime} ${timezone}_`; + const body = lines.join('\n'); const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -440,6 +636,52 @@ jobs: }); } + lighthouse: + if: >- + github.event.action != 'closed' && + needs.deploy-preview.result == 'success' && + needs.deploy-preview.outputs.access-mode != 'signed-cookies' + needs: [deploy-preview] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install @lhci/cli + run: npm install -g @lhci/cli@0.14 + + - name: Warm up preview URL + env: + PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} + PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + run: | + set -euo pipefail + PREVIEW_PREFIX_VALUE="${PREVIEW_PREFIX:-pr-}" + URL="https://deploy.${PREVIEW_DOMAIN}/${PREVIEW_PREFIX_VALUE}${{ github.event.pull_request.number }}/" + echo "Warming up ${URL}" + curl -sf --max-time 30 --retry 5 --retry-delay 5 --retry-all-errors "${URL}" -o /dev/null + sleep 5 + + - name: Run Lighthouse CI + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} + PREVIEW_DOMAIN: ${{ vars.PR_PREVIEW_DOMAIN }} + PREVIEW_PREFIX: ${{ vars.PR_PREVIEW_PREFIX }} + run: | + set -euo pipefail + if [[ -z "${LHCI_GITHUB_APP_TOKEN:-}" ]]; then + echo "LHCI_GITHUB_APP_TOKEN not set — skipping Lighthouse CI." + exit 0 + fi + PREVIEW_PREFIX_VALUE="${PREVIEW_PREFIX:-pr-}" + URL="https://deploy.${PREVIEW_DOMAIN}/${PREVIEW_PREFIX_VALUE}${{ github.event.pull_request.number }}/" + lhci autorun --collect.url="${URL}" + teardown-preview: if: github.event.action == 'closed' && (github.base_ref != 'develop' || github.head_ref != 'main') runs-on: ubuntu-latest diff --git a/.github/workflows/project-label-bridge.yml b/.github/workflows/project-label-bridge.yml new file mode 100644 index 00000000..8e52bc02 --- /dev/null +++ b/.github/workflows/project-label-bridge.yml @@ -0,0 +1,177 @@ +# Optional: moves org GitHub Project (v2) cards when issues/PRs get mapped labels. +# The repo does not require this workflow; see docs/project-label-bridge.md for why, +# setup (secret + variables), and how to align labels with your board. +# +# Repository variables must NOT use the GITHUB_ prefix (reserved for built-in context). +# Use vars.PROJECT_NUMBER and vars.PROJECT_ORG — see docs/project-label-bridge.md. + +name: Project label bridge + +on: + issues: + types: [labeled] + pull_request: + types: [labeled] + +concurrency: + group: project-label-bridge-${{ github.event_name }}-${{ github.event.issue.node_id || github.event.pull_request.node_id }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + sync-status-from-label: + runs-on: ubuntu-latest + permissions: + contents: read + # Secrets are not available in job-level `if`; gate on vars only (forks can skip via empty var). + if: vars.PROJECT_NUMBER != '' + + steps: + - name: Resolve label → Status and update Project + env: + GH_TOKEN: ${{ secrets.ORG_PROJECT_GITHUB_TOKEN }} + LABEL_NAME: ${{ github.event.label.name }} + CONTENT_NODE_ID: ${{ github.event.issue.node_id || github.event.pull_request.node_id }} + PROJECT_ORG: ${{ vars.PROJECT_ORG || 'Artificer-Innovations' }} + PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::warning::ORG_PROJECT_GITHUB_TOKEN is not set; cannot update the org project." + exit 0 + fi + + # Labels: project/status- (lowercase a-z, digits, hyphens). + # → Project "Status" option: each hyphen becomes a space. Default: Title Case + # per word (e.g. backlog → Backlog, ready-for-qa → Ready For Qa). + # Exception: leading segment "in" with more segments → "In " + remaining + # words lowercased (Kanban: "In progress", "In review"). + if [[ "$LABEL_NAME" != project/status-* ]]; then + echo "Label '$LABEL_NAME' is not a project status label (expected prefix project/status-); skipping." + exit 0 + fi + slug="${LABEL_NAME#project/status-}" + slug="${slug,,}" + if [[ -z "$slug" || "$slug" == *[^a-z0-9-]* ]]; then + echo "Label '$LABEL_NAME' has an invalid slug after project/status- (use a-z, 0-9, hyphens only); skipping." + exit 0 + fi + + IFS='-' read -ra parts <<< "$slug" + n=${#parts[@]} + if [[ "$n" -eq 0 ]]; then + echo "Label '$LABEL_NAME' is empty after project/status-; skipping." + exit 0 + fi + if [[ "${parts[0],,}" == "in" && "$n" -eq 1 ]]; then + echo "Label '$LABEL_NAME' is incomplete (project/status-in); skipping." + exit 0 + fi + + if [[ "${parts[0],,}" == "in" && "$n" -ge 2 ]]; then + TARGET_STATUS="In" + for ((i = 1; i < n; i++)); do + w="${parts[i],,}" + [[ -z "$w" ]] && continue + TARGET_STATUS+=" $w" + done + else + TARGET_STATUS="" + for ((i = 0; i < n; i++)); do + w="${parts[i],,}" + [[ -z "$w" ]] && continue + [[ -n "$TARGET_STATUS" ]] && TARGET_STATUS+=" " + TARGET_STATUS+="${w^}" + done + fi + + echo "Mapping label '$LABEL_NAME' → Status '$TARGET_STATUS'" + + gh api graphql -f query=' + query($org: String!, $number: Int!) { + organization(login: $org) { + projectV2(number: $number) { + id + fields(first: 40) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + }' -f org="$PROJECT_ORG" -F number="$PROJECT_NUMBER" --jq . > project_meta.json + + PROJECT_ID=$(jq -r '.data.organization.projectV2.id' project_meta.json) + STATUS_FIELD_ID=$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name == "Status") | .id' project_meta.json) + OPTION_ID=$(jq -r --arg s "$TARGET_STATUS" '.data.organization.projectV2.fields.nodes[] | select(.name == "Status") | .options[] | select(.name == $s) | .id' project_meta.json) + + if [[ "$PROJECT_ID" == "null" || -z "$PROJECT_ID" ]]; then + echo "::error::Could not load project $PROJECT_ORG#$PROJECT_NUMBER (check vars PROJECT_ORG / PROJECT_NUMBER and token access)." + exit 1 + fi + if [[ "$STATUS_FIELD_ID" == "null" || -z "$STATUS_FIELD_ID" ]]; then + echo "::error::Project has no single-select field named 'Status'. Rename the field or adjust this workflow." + exit 1 + fi + if [[ "$OPTION_ID" == "null" || -z "$OPTION_ID" ]]; then + echo "::error::No Status option named '$TARGET_STATUS'. Add it to the project, or use a project/status- label that derives to an existing option name (see workflow comments)." + exit 1 + fi + + gh api graphql -f query=' + query($id: ID!) { + node(id: $id) { + ... on Issue { + projectItems(first: 30) { + nodes { + id + project { ... on ProjectV2 { id number } } + } + } + } + ... on PullRequest { + projectItems(first: 30) { + nodes { + id + project { ... on ProjectV2 { id number } } + } + } + } + } + }' -f id="$CONTENT_NODE_ID" --jq . > content_items.json + + ITEM_ID=$(jq -r --argjson n "$PROJECT_NUMBER" ' + .data.node.projectItems.nodes[] + | select(.project.number == $n) + | .id + ' content_items.json 2>/dev/null || true) + + if [[ -z "$ITEM_ID" || "$ITEM_ID" == "null" ]]; then + echo "Item not on project yet — adding content then setting Status." + ITEM_ID=$(gh api graphql -f query=' + mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { + item { id } + } + }' -f project="$PROJECT_ID" -f content="$CONTENT_NODE_ID" --jq -r '.data.addProjectV2ItemById.item.id') + fi + + gh api graphql -f query=' + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $project + itemId: $item + fieldId: $field + value: { singleSelectOptionId: $option } + } + ) { + projectV2Item { id } + } + }' -f project="$PROJECT_ID" -f item="$ITEM_ID" -f field="$STATUS_FIELD_ID" -f option="$OPTION_ID" --silent + + echo "Updated project item $ITEM_ID to Status='$TARGET_STATUS'." diff --git a/.github/workflows/release-template.yml b/.github/workflows/release-template.yml new file mode 100644 index 00000000..1e721f3c --- /dev/null +++ b/.github/workflows/release-template.yml @@ -0,0 +1,67 @@ +name: Release Template + +on: + workflow_dispatch: + inputs: + override_tag: + description: 'Override computed tag (e.g. 2026.003) — leave blank to auto-compute' + required: false + +jobs: + release: + name: Cut CalVer release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate override tag format + if: inputs.override_tag != '' + run: | + if [[ ! "${{ inputs.override_tag }}" =~ ^[0-9]{4}\.[0-9]{3}$ ]]; then + echo "Invalid tag format '${{ inputs.override_tag }}' — must match YYYY.NNN (e.g. 2026.003)" + exit 1 + fi + + - name: Compute next CalVer tag + id: tag + run: | + if [[ -n "${{ inputs.override_tag }}" ]]; then + echo "tag=${{ inputs.override_tag }}" >> $GITHUB_OUTPUT + else + YEAR=$(date +%Y) + LAST_N=$(git tag -l "${YEAR}.*" | sed "s/${YEAR}\.//" | sort -n | tail -1) + NEXT_N=$(( ${LAST_N:-0} + 1 )) + printf -v PADDED_N "%03d" $NEXT_N + echo "tag=${YEAR}.${PADDED_N}" >> $GITHUB_OUTPUT + fi + + - name: Generate release notes + uses: orhun/git-cliff-action@v3 + with: + config: cliff.toml + args: >- + --tag-pattern '^[0-9]{4}\.' + --tag ${{ steps.tag.outputs.tag }} + --latest + env: + OUTPUT: RELEASE_NOTES.md + GITHUB_REPO: ${{ github.repository }} + + - name: Create annotated tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a ${{ steps.tag.outputs.tag }} -m "Release ${{ steps.tag.outputs.tag }}" + git push origin ${{ steps.tag.outputs.tag }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + body_path: RELEASE_NOTES.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d6f1046..64277cf4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,7 @@ on: - main - develop pull_request: + types: [opened, synchronize, reopened, edited] paths: # Application code and tests - 'apps/**' @@ -39,6 +40,8 @@ permissions: jobs: tests: + # On edited events, only run when the base branch was retargeted (not title/body-only edits). + if: github.event.action != 'edited' || github.event.changes.base runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.gitignore b/.gitignore index 47c4b8ab..12f1fd8d 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,10 @@ dist # Gatsby files .cache/ public +!apps/web/public/ +apps/web/public/* +!apps/web/public/landing/ +!apps/web/public/landing/** # Vuepress build output .vuepress/dist diff --git a/.husky/pre-commit b/.husky/pre-commit index 94a3698b..89e58a66 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,18 +1,12 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -# Phase 8: Tooling setup - non-blocking enforcement -# TODO: Make these blocking once codebase is cleaned up - -# Run lint with auto-fix (non-blocking) -npm run lint:fix || echo "⚠️ Lint issues found (non-blocking in Phase 8)" - -# Run type check (non-blocking) -npm run type-check || echo "⚠️ Type errors found (non-blocking in Phase 8)" - -# Check formatting (non-blocking) -npm run format:check || echo "⚠️ Formatting issues found (non-blocking in Phase 8)" - -# Always allow commit to proceed -exit 0 - +# Lint and format staged files only (fast — only changed files, not the whole monorepo). +npx lint-staged + +# Type-check only when TypeScript files are staged. +# Triggers pretype-check (builds packages/shared) + tsc --noEmit across all packages. +# Expect a few extra seconds compared to non-TS commits. +if git diff --cached --name-only | grep -qE '\.(ts|tsx)$'; then + npm run type-check +fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19d78f27..41a5e5d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,21 @@ Thank you for helping improve Beaker Stack. 4. Open a pull request with a clear description of **what** changed and **why**. +## Pre-commit hook + +The repository uses [lint-staged](https://github.com/lint-staged/lint-staged) and [Husky](https://typicode.github.io/husky/) to enforce code quality on every commit. + +**What runs on every commit:** + +- ESLint `--fix` + Prettier on staged `*.{ts,tsx,js,mjs,cjs}` files +- Prettier on staged `*.{json,md}` files + +**What runs only when TypeScript files are staged:** + +- `npm run type-check` — runs `tsc --noEmit` across all packages. This triggers `pretype-check` first, which builds `packages/shared` so downstream packages type-check against the latest types. Expect a few extra seconds on commits that touch `.ts`/`.tsx` files. + +If the hook blocks your commit, fix the reported errors and re-commit. Do not use `--no-verify` to skip the hook. + ## Pull requests - Prefer small PRs over large mixed ones. diff --git a/QUICKSTART.md b/QUICKSTART.md index 9b3d2660..910e7b03 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -103,6 +103,8 @@ Commit any updates to `docs/reference/github-actions-secrets.md` when you change At minimum for deploy workflows you will need **`SUPABASE_ACCESS_TOKEN`** and **AWS access keys** (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, plus optional `AWS_SESSION_TOKEN`), plus the Supabase URL/keys and project refs for each tier—see the generated table. **`EXPO_TOKEN`**, `EXPO_PROJECT_ID`, `EXPO_ACCOUNT`, and all `GOOGLE_SERVICES_*` entries are mobile-only; set `MOBILE_ENABLED=false` (GitHub variable) to skip them for web-only repos. +**Lighthouse CI** scores are posted as GitHub status checks on each PR preview if `LHCI_GITHUB_APP_TOKEN` is set (install the [Lighthouse CI GitHub App](https://github.com/apps/lighthouse-ci) to get the token). The step is skipped automatically when the preview is signed-cookie gated — Lighthouse cannot authenticate headlessly. See [docs/lighthouse-ci.md](docs/lighthouse-ci.md) for details. + ### 6.2 Full interactive bootstrap ```bash @@ -111,14 +113,14 @@ npm run setup:full Options (see also `npm run setup:full -- --help`): -| Flag | Meaning | -| -------------------- | ------------------------------------ | -| `--dry-run` | No file writes; log-only GitHub sync | -| `--from=PHASE` | Resume at a phase (see table below) | -| `--skip-rename` | Skip template rename | -| `--skip-github` | Do not push secrets with `gh` | +| Flag | Meaning | +| -------------------- | ------------------------------------------- | +| `--dry-run` | No file writes; log-only GitHub sync | +| `--from=PHASE` | Resume at a phase (see table below) | +| `--skip-rename` | Skip template rename | +| `--skip-github` | Do not push secrets with `gh` | | `--skip-mobile` | Skip Expo/EAS/Google setup (web-only repos) | -| `--aws-profile=NAME` | Pass through to AWS bootstrap script | +| `--aws-profile=NAME` | Pass through to AWS bootstrap script | **Phase names** for `--from=` (order matters; later phases assume earlier work or merged `.env*` files): diff --git a/README.md b/README.md index 4ef3470b..88dda263 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,16 @@ For **native clean rebuilds**, simulator uninstall, and `prebuild --clean`, see Details: [docs/pr-preview-setup.md](docs/pr-preview-setup.md). +## Releases and versioning + +Template snapshots are tagged on `main` using **CalVer** (`2026.001`, `2026.002`, …). Each release includes generated notes covering what changed and whether there are any breaking steps. `@beakerstack/*` packages use independent **semver** on npm. + +| Trigger | Workflow | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| **Manual dispatch** | [release-template.yml](.github/workflows/release-template.yml) — cuts a new CalVer tag and GitHub Release | + +See [VERSIONING.md](VERSIONING.md) for what counts as a breaking change and [UPGRADING.md](UPGRADING.md) for how to pull template changes into an existing fork. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 00000000..830ea03f --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,63 @@ +# Upgrading + +This guide covers how to pull BeakerStack changes into a fork you have already set up. See [VERSIONING.md](VERSIONING.md) for how template tags and package versions work. + +## Prerequisites + +Add the BeakerStack repo as an upstream remote if you have not already: + +```bash +git remote add upstream https://github.com/Artificer-Innovations/BeakerStack.git +``` + +--- + +## Option A — Upgrade to a full template snapshot + +Use this when you want to pull in everything from a specific tagged release as a single merge commit. + +```bash +# Fetch upstream commits and tags (tags land as local refs, not as upstream/TAG) +git fetch upstream --tags + +# Merge the snapshot tag into your branch +git merge 2026.003 +``` + +> **"Use this template" users:** If you created your repo using GitHub's "Use this template" button, git treats the histories as unrelated. Your first merge will need `--allow-unrelated-histories`: +> +> ```bash +> git merge --allow-unrelated-histories 2026.003 +> ``` +> +> Subsequent merges work without the flag. + +Resolve any conflicts, then review the release notes for that tag on GitHub for any breaking changes that need manual follow-up (new secrets, renamed variables, migration steps). + +## Option B — Cherry-pick specific changes + +Use this when you only want selected commits, not the full snapshot: + +```bash +git fetch upstream +git log upstream/main --oneline # find the commit(s) you want +git cherry-pick +``` + +## Option C — Upgrade an individual `@beakerstack/*` package + +Use this when a package dependency has shipped a new version and you want to update it in isolation: + +```bash +npm install @beakerstack/billing@x.y.z +``` + +Read the package's GitHub Release notes for that version — package release notes describe what changed at the API level, not the broader template context. + +--- + +## After any upgrade + +1. Re-run `npm install` to sync lockfile. +2. Check the release notes for any new required GitHub Actions secrets or variables. +3. Run `npm run type-check && npm run test` to catch regressions before pushing. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..c9a27f19 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,44 @@ +# Versioning + +BeakerStack uses two parallel versioning schemes — one for the monorepo template and one for its published packages. + +## Template releases — `YYYY.NNN` + +Template releases are **monorepo snapshot tags** on `main`. A tag like `2026.003` means: _this is what BeakerStack looked like at that point in time, and it is a good base to fork from._ + +Tags use **CalVer with a zero-padded sequence number** within the year: + +``` +2026.001 first release of 2026 +2026.002 second release of 2026 +2026.013 thirteenth release of 2026 +``` + +`NNN` increments arbitrarily — there is no meaning to how much time passes between releases. + +### What counts as a breaking change + +A template release is **breaking** if adopters who have forked the template need to take manual action before upgrading. That includes: + +- A new required GitHub Actions secret or repository variable +- A renamed or removed secret / variable that CI depends on +- A changed top-level folder structure (e.g. a directory moved or renamed) +- A changed setup script behavior (flags renamed, phases reordered, outputs changed) +- A new migration step needed to align an existing fork with the updated baseline + +Breaking changes are called out explicitly in the release notes generated for that tag. + +### `main` vs. tagged releases + +| Ref | What it is | Recommended for | +| ---------- | ------------------- | ------------------------------------------------------------------- | +| `main` | Current stable HEAD | Following along with active development | +| `2026.NNN` | Snapshot tag | Starting a new fork; upgrading an existing fork in a controlled way | + +If you are forking BeakerStack to build a product, start from a tagged release so your upgrade story is clear from day one. + +## Package releases — semver + +`@beakerstack/*` packages published to npm use standard **semantic versioning** (`MAJOR.MINOR.PATCH`). Each package is versioned independently via changesets. Release notes for package releases live on the package's GitHub Release page. + +Package versions are independent of template CalVer tags. diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index f5012efd..e7815527 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -2,14 +2,19 @@ import { useEffect } from 'react'; import { StatusBar } from 'expo-status-bar'; import Constants from 'expo-constants'; import * as Updates from 'expo-updates'; +import { BillingProvider } from '@beakerstack/billing'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; // Import from native-specific file for correct types import { configureGoogleSignIn } from '@beakerstack/shared/hooks/useAuth.native'; import { Logger } from '@beakerstack/shared/utils/logger'; +import { beakerstackBillingConfig } from './src/billing/beakerstackBillingConfig'; +import { getMobileBillingProviderUrls } from './src/billing/mobileBillingUrls'; import { supabase } from './src/lib/supabase'; import { AppNavigator } from './src/navigation/AppNavigator'; +const mobileBillingUrls = getMobileBillingProviderUrls(); + export default function App() { useEffect(() => { // Expose Constants globally for debugging in Chrome Console (after app loads) @@ -105,8 +110,16 @@ export default function App() { return ( - - + + supabase={supabase} + config={beakerstackBillingConfig} + checkoutSuccessUrl={mobileBillingUrls.checkoutSuccessUrl} + checkoutCancelUrl={mobileBillingUrls.checkoutCancelUrl} + portalReturnUrl={mobileBillingUrls.portalReturnUrl} + > + + + ); diff --git a/apps/mobile/__tests__/App.test.tsx b/apps/mobile/__tests__/App.test.tsx index 49395105..9de877be 100644 --- a/apps/mobile/__tests__/App.test.tsx +++ b/apps/mobile/__tests__/App.test.tsx @@ -19,6 +19,12 @@ jest.mock('expo-constants', () => ({ }, })); +jest.mock('@beakerstack/billing', () => ({ + __esModule: true, + BillingProvider: ({ children }: { children: React.ReactNode }) => children, + defineBillingConfig: (c: unknown) => c, +})); + // Mock expo-updates (added for OTA update debugging) jest.mock('expo-updates', () => ({ isEnabled: false, @@ -159,7 +165,7 @@ jest.mock('@beakerstack/shared/components/forms/FormError.native', () => ({ })); // Avoid loading @beakerstack/billing in Jest (package uses TS paths Jest does not resolve like Metro) -jest.mock('../src/screens/BillingScreen', () => { +jest.mock('../src/navigation/BillingNavigator', () => { const { View, Text } = require('react-native'); return { __esModule: true, diff --git a/apps/mobile/__tests__/components/DebugTools.test.tsx b/apps/mobile/__tests__/components/DebugTools.test.tsx deleted file mode 100644 index af345a85..00000000 --- a/apps/mobile/__tests__/components/DebugTools.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React from 'react'; -import { Alert } from 'react-native'; -import { DebugTools } from '../../src/components/DebugTools'; -import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; -import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import renderer from 'react-test-renderer'; - -// Mock expo-constants -jest.mock('expo-constants', () => ({ - default: { - expoConfig: { - extra: { - supabaseUrl: 'http://localhost:54321', - supabaseAnonKey: 'test-anon-key', - }, - }, - }, -})); - -// Mock supabase -const mockFrom = jest.fn(); -const mockSelect = jest.fn(); -const mockEq = jest.fn(); -const mockLimit = jest.fn(); - -jest.mock('../../src/lib/supabase', () => ({ - supabase: { - from: mockFrom, - }, -})); - -// Mock Logger -jest.mock('@beakerstack/shared/utils/logger', () => ({ - Logger: { - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -// Mock useProfile hook -const mockUseProfile = jest.fn(); -jest.mock('@beakerstack/shared/hooks/useProfile', () => ({ - useProfile: () => mockUseProfile(), -})); - -// Mock ProfileEditor.native -jest.mock( - '@beakerstack/shared/components/profile/ProfileEditor.native', - () => ({ - ProfileEditor: () =>
Profile Editor
, - }) -); - -// Mock form components -jest.mock('@beakerstack/shared/components/forms/FormInput.native', () => ({ - FormInput: () =>
Form Input
, -})); - -jest.mock('@beakerstack/shared/components/forms/FormButton.native', () => ({ - FormButton: () =>
Form Button
, -})); - -jest.mock('@beakerstack/shared/components/forms/FormError.native', () => ({ - FormError: () =>
Form Error
, -})); - -// Mock profile display components -jest.mock( - '@beakerstack/shared/components/profile/ProfileAvatar.native', - () => ({ - ProfileAvatar: () =>
Avatar
, - }) -); - -jest.mock( - '@beakerstack/shared/components/profile/ProfileHeader.native', - () => ({ - ProfileHeader: () =>
Header
, - }) -); - -jest.mock('@beakerstack/shared/components/profile/ProfileStats.native', () => ({ - ProfileStats: () =>
Stats
, -})); - -const createMockSupabaseClient = (hasUser = false): SupabaseClient => { - const mockUser = { - id: 'test-user-id', - email: 'test@example.com', - app_metadata: {}, - user_metadata: {}, - aud: 'authenticated', - created_at: new Date().toISOString(), - }; - - const mockSession = { - access_token: 'mock-token', - refresh_token: 'mock-refresh', - expires_in: 3600, - expires_at: Date.now() + 3600000, - token_type: 'bearer', - user: mockUser, - }; - - return { - auth: { - getSession: jest.fn().mockResolvedValue({ - data: { session: hasUser ? mockSession : null }, - error: null, - }), - onAuthStateChange: jest.fn(() => ({ - data: { - subscription: { - unsubscribe: jest.fn(), - }, - }, - })), - signOut: jest.fn(), - }, - from: mockFrom, - channel: jest.fn(() => ({ - on: jest.fn().mockReturnThis(), - subscribe: jest.fn(() => ({ - unsubscribe: jest.fn(), - })), - })), - } as unknown as SupabaseClient; -}; - -const createMockProfile = () => ({ - loading: false, - profile: { - id: 'profile-1', - user_id: 'test-user-id', - username: 'testuser', - display_name: 'Test User', - bio: 'Test bio', - location: 'Test Location', - website: 'https://example.com', - avatar_url: null, - created_at: '2024-01-01', - updated_at: '2024-01-01', - }, - error: null, - createProfile: jest.fn().mockResolvedValue({}), - updateProfile: jest.fn().mockResolvedValue({}), - refreshProfile: jest.fn().mockResolvedValue({}), - fetchProfile: jest.fn(), -}); - -describe('DebugTools', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - - // Setup default mocks - mockFrom.mockReturnValue({ - select: mockSelect, - }); - mockSelect.mockReturnValue({ - eq: mockEq, - limit: mockLimit, - }); - mockEq.mockReturnValue({ - single: jest.fn().mockResolvedValue({ data: null, error: null }), - }); - mockLimit.mockResolvedValue({ data: [], error: null }); - - mockUseProfile.mockReturnValue(createMockProfile()); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it('renders component', () => { - const tree = renderer.create( - - - - - - ); - expect(tree).toBeTruthy(); - }); - - it('renders hidden button when not visible', () => { - const tree = renderer.create( - - - - - - ); - const instance = tree.root; - // Component should render something - expect(instance).toBeTruthy(); - }); -}); diff --git a/apps/mobile/__tests__/navigation/AppNavigator.test.tsx b/apps/mobile/__tests__/navigation/AppNavigator.test.tsx index c7ce1dce..1fcfe400 100644 --- a/apps/mobile/__tests__/navigation/AppNavigator.test.tsx +++ b/apps/mobile/__tests__/navigation/AppNavigator.test.tsx @@ -53,13 +53,16 @@ jest.mock('../../src/screens/ProfileScreen', () => { ); }); -jest.mock('../../src/screens/BillingScreen', () => { +jest.mock('../../src/navigation/BillingNavigator', () => { const { View, Text } = require('react-native'); - return () => ( - - Billing Screen - - ); + return { + __esModule: true, + default: () => ( + + Billing Screen + + ), + }; }); describe('AppNavigator', () => { diff --git a/apps/mobile/__tests__/screens/BillingScreen.test.tsx b/apps/mobile/__tests__/screens/BillingScreen.test.tsx deleted file mode 100644 index 3a538366..00000000 --- a/apps/mobile/__tests__/screens/BillingScreen.test.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; -import BillingScreen from '../../src/screens/BillingScreen'; - -const mockGoBack = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actual = jest.requireActual( - '@react-navigation/native' - ); - return { - ...actual, - useNavigation: () => ({ - goBack: mockGoBack, - }), - }; -}); - -jest.mock('@beakerstack/billing', () => ({ - defineBillingConfig: (c: unknown) => c, - BillingProvider: ({ children }: { children: React.ReactNode }) => children, - useUsage: jest.fn(() => ({ - used: 0, - limit: 30, - remaining: 30, - resetsAt: '', - exceeded: false, - loading: false, - error: null, - refresh: jest.fn().mockResolvedValue(undefined), - })), - useRecordUsage: jest.fn(() => ({ - record: jest.fn().mockResolvedValue(undefined), - pending: false, - })), -})); - -jest.mock('@beakerstack/billing/native', () => { - const { Text } = require('react-native'); - return { - CustomerPortalLink: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - FeatureGate: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - PricingTable: () => pricing, - SubscriptionStatus: () => status, - UpgradePrompt: ({ reason }: { reason: string }) => {reason}, - UsageIndicator: () => usage, - }; -}); - -jest.mock('react-native-safe-area-context', () => { - const { View } = require('react-native'); - return { - SafeAreaView: View, - }; -}); - -jest.mock('../../src/lib/supabase', () => ({ - supabase: { - rpc: jest.fn().mockResolvedValue({ data: null, error: null }), - }, -})); - -jest.mock('expo-constants', () => ({ - default: { - expoConfig: { - extra: { - supabaseUrl: 'http://localhost:54321', - supabaseAnonKey: 'test-anon-key', - }, - }, - }, -})); - -function restoreBillingMocks(): void { - const billing = jest.requireMock('@beakerstack/billing') as { - useUsage: jest.Mock; - useRecordUsage: jest.Mock; - }; - billing.useUsage.mockImplementation(() => ({ - used: 0, - limit: 30, - remaining: 30, - resetsAt: '', - exceeded: false, - loading: false, - error: null, - refresh: jest.fn().mockResolvedValue(undefined), - })); - billing.useRecordUsage.mockImplementation(() => ({ - record: jest.fn().mockResolvedValue(undefined), - pending: false, - })); -} - -describe('BillingScreen', () => { - beforeEach(() => { - mockGoBack.mockClear(); - restoreBillingMocks(); - ( - jest.requireMock('../../src/lib/supabase').supabase.rpc as jest.Mock - ).mockReset(); - ( - jest.requireMock('../../src/lib/supabase').supabase.rpc as jest.Mock - ).mockResolvedValue({ data: null, error: null }); - if (typeof process !== 'undefined' && process.env) { - process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = ''; - } - }); - - const renderScreen = () => - render( - - - - ); - - it('renders billing title, subtitle, and non-demo hint', () => { - const { getByText } = renderScreen(); - - expect(getByText('Billing')).toBeTruthy(); - expect(getByText(/For the full \/billing experience/i)).toBeTruthy(); - expect(getByText(/Set EXPO_PUBLIC_BILLING_DEMO_MODE=true/i)).toBeTruthy(); - expect(getByText('Subscription')).toBeTruthy(); - expect(getByText('AI summarize (metered)')).toBeTruthy(); - expect(getByText('Use one summarize')).toBeTruthy(); - expect(getByText('Feature B (Max)')).toBeTruthy(); - expect(getByText('Feature B enabled')).toBeTruthy(); - }); - - it('calls navigation.goBack when Back is pressed', async () => { - const { getByText } = renderScreen(); - - fireEvent.press(getByText('← Back')); - - await waitFor(() => { - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - }); - - it('shows UpgradePrompt when meter is exceeded', () => { - const billing = jest.requireMock('@beakerstack/billing') as { - useUsage: jest.Mock; - }; - billing.useUsage.mockImplementation(() => ({ - used: 30, - limit: 30, - remaining: 0, - resetsAt: '', - exceeded: true, - loading: false, - error: null, - refresh: jest.fn().mockResolvedValue(undefined), - })); - - const { getByText } = renderScreen(); - expect(getByText('Monthly limit reached.')).toBeTruthy(); - }); - - it('shows pending ellipsis on metered button and disables press', () => { - const billing = jest.requireMock('@beakerstack/billing') as { - useRecordUsage: jest.Mock; - }; - billing.useRecordUsage.mockImplementation(() => ({ - record: jest.fn().mockResolvedValue(undefined), - pending: true, - })); - - const { getByText } = renderScreen(); - expect(getByText('…')).toBeTruthy(); - }); - - it('invokes record and refresh from metered block', async () => { - const billing = jest.requireMock('@beakerstack/billing') as { - useRecordUsage: jest.Mock; - useUsage: jest.Mock; - }; - const record = jest.fn().mockResolvedValue(undefined); - const refresh = jest.fn().mockResolvedValue(undefined); - billing.useRecordUsage.mockImplementation(() => ({ - record, - pending: false, - })); - billing.useUsage.mockImplementation(() => ({ - used: 0, - limit: 30, - remaining: 30, - resetsAt: '', - exceeded: false, - loading: false, - error: null, - refresh, - })); - - const { getByText } = renderScreen(); - - fireEvent.press(getByText('Use one summarize')); - fireEvent.press(getByText('Refresh usage')); - - await waitFor(() => { - expect(record).toHaveBeenCalledWith(1); - expect(refresh).toHaveBeenCalled(); - }); - }); - - it('shows demo controls and success message when Simulate Pro succeeds', async () => { - process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; - const { supabase } = jest.requireMock('../../src/lib/supabase') as { - supabase: { rpc: jest.Mock }; - }; - supabase.rpc.mockResolvedValue({ data: null, error: null }); - - const { getByText } = renderScreen(); - - expect(getByText('Demo mode — not real billing')).toBeTruthy(); - fireEvent.press(getByText('Simulate Pro')); - - await waitFor(() => { - expect(getByText(/Simulated beakerstack_pro/i)).toBeTruthy(); - }); - expect(supabase.rpc).toHaveBeenCalledWith( - 'billing_demo_simulate_upgrade', - expect.objectContaining({ - p_product_id: 'beakerstack', - p_plan_id: 'beakerstack_pro', - }) - ); - }); - - it('shows RPC error message from demo simulate upgrade', async () => { - process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; - const { supabase } = jest.requireMock('../../src/lib/supabase') as { - supabase: { rpc: jest.Mock }; - }; - supabase.rpc.mockResolvedValue({ - data: null, - error: { message: 'Upgrade blocked' }, - }); - - const { getByText } = renderScreen(); - fireEvent.press(getByText('Simulate Pro')); - - await waitFor(() => { - expect(getByText('Upgrade blocked')).toBeTruthy(); - }); - }); -}); diff --git a/apps/mobile/__tests__/screens/DashboardScreen.test.tsx b/apps/mobile/__tests__/screens/DashboardScreen.test.tsx index a6c18010..f0bdabde 100644 --- a/apps/mobile/__tests__/screens/DashboardScreen.test.tsx +++ b/apps/mobile/__tests__/screens/DashboardScreen.test.tsx @@ -301,20 +301,9 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText(/welcome to beakerstack/i)).toBeTruthy(); - }); - }); - - it('navigates to Billing when polished billing link is pressed', async () => { - const { getByText } = renderWithProviders( - - ); - - await waitFor(() => { - expect(getByText(/welcome to beakerstack/i)).toBeTruthy(); + expect(getByText('Simulate AI summarize')).toBeTruthy(); + expect(getByText('Boolean feature gates')).toBeTruthy(); }); - fireEvent.press(getByText('View polished billing →')); - expect(mockNavigate).toHaveBeenCalledWith('Billing'); }); it('records metered usage and shows fake AI summary when Simulate AI summarize is pressed', async () => { @@ -335,6 +324,7 @@ describe('DashboardScreen', () => { expect.objectContaining({ p_product_id: 'beakerstack', p_quantity: 1, + p_idempotency_key: expect.any(String), }) ); }); @@ -359,7 +349,8 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText(/Limit reached — open Billing for plans/i)).toBeTruthy(); + expect(getByText('Limit reached')).toBeTruthy(); + expect(getByText(/Monthly limit reached for this meter/i)).toBeTruthy(); }); }); @@ -399,12 +390,11 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText('Add collection')).toBeTruthy(); + expect(getByText('+ New collection')).toBeTruthy(); }); - fireEvent.press(getByText('Add collection')); + fireEvent.press(getByText('+ New collection')); await waitFor(() => { - expect(getByText(/Collections:/)).toBeTruthy(); expect(getByText(/1 of 2/)).toBeTruthy(); }); }); @@ -589,7 +579,7 @@ describe('DashboardScreen', () => { }); }); - it('shows Failed when addItem throws a non-Error from RPC', async () => { + it('shows Action failed when addItem rejects with a non-Error from RPC', async () => { const billing = jest.requireMock('@beakerstack/billing') as { useFeature: jest.Mock; }; @@ -643,12 +633,12 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText('Add item')).toBeTruthy(); + expect(getByText('+ Add item')).toBeTruthy(); }); - fireEvent.press(getByText('Add item')); + fireEvent.press(getByText('+ Add item')); await waitFor(() => { - expect(getByText('Failed')).toBeTruthy(); + expect(getByText('Action failed.')).toBeTruthy(); }); }); @@ -694,13 +684,13 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText('Delete')).toBeTruthy(); + expect(getByText('Del')).toBeTruthy(); }); - fireEvent.press(getByText('Delete')); + fireEvent.press(getByText('Del')); await waitFor(() => { expect( - getByText(/No collections yet\. Tap Add collection to start\./) + getByText(/No collections yet\. Tap 'New collection' to start\./) ).toBeTruthy(); }); }); @@ -756,7 +746,7 @@ describe('DashboardScreen', () => { ); await waitFor(() => { - expect(getByText('Limit')).toBeTruthy(); + expect(getByText('Item limit reached')).toBeTruthy(); }); }); }); diff --git a/apps/mobile/__tests__/screens/HomeScreen.test.tsx b/apps/mobile/__tests__/screens/HomeScreen.test.tsx index 63bb602b..3e07b487 100644 --- a/apps/mobile/__tests__/screens/HomeScreen.test.tsx +++ b/apps/mobile/__tests__/screens/HomeScreen.test.tsx @@ -301,8 +301,4 @@ describe('HomeScreen', () => { fireEvent.press(signUps[signUps.length - 1]); expect(mockNavigate).toHaveBeenCalledWith('Signup'); }); - - // Note: Debug tools (database test, auth context test) are now in DebugTools component - // which is hidden by default and activated via 4 clicks in bottom left corner. - // These tests have been removed as the debug components are no longer directly visible. }); diff --git a/apps/mobile/__tests__/screens/billing/BillingScreens.test.tsx b/apps/mobile/__tests__/screens/billing/BillingScreens.test.tsx new file mode 100644 index 00000000..a598b9dd --- /dev/null +++ b/apps/mobile/__tests__/screens/billing/BillingScreens.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { BillingOverviewScreen } from '../../../src/screens/billing/BillingOverviewScreen'; +import { BillingUsageScreen } from '../../../src/screens/billing/BillingUsageScreen'; + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getParent: () => null, + }), + useRoute: () => ({ name: 'BillingOverview' }), + }; +}); + +jest.mock('@beakerstack/shared/contexts/AuthContext', () => ({ + useAuthContext: () => ({ + user: { created_at: '2024-01-15T00:00:00.000Z' }, + }), +})); + +jest.mock('../../../src/billing/useDemoCollectionCount', () => ({ + useDemoCollectionCount: () => ({ + count: 1, + maxItemsInAnyCollection: 2, + loading: false, + error: null, + refresh: jest.fn(), + }), +})); + +jest.mock('@beakerstack/billing/presentation', () => ({ + formatMonthYear: () => 'Jan 2024', + formatDate: (d: string) => d, + mergePlanFeatureRows: (config: { + planFeatureRows?: { + id: string; + featureKey: string; + kind: string; + label: string; + }[]; + }) => config.planFeatureRows ?? [], + mergeUsageLimitsCopy: (config: { + usageLimitsCopy?: Record; + }) => config.usageLimitsCopy ?? {}, + mergeUsageMeterCopy: (config: { + usageMeterCopy?: Record; + }) => config.usageMeterCopy ?? {}, + planFeatureLine: ( + plan: { features: Record }, + row: { label: string; featureKey: string } + ) => ({ + ok: !!plan.features[row.featureKey], + text: row.label, + }), +})); + +jest.mock('@beakerstack/billing', () => ({ + defineBillingConfig: (c: unknown) => c, + useBillingState: jest.fn(() => ({ + kind: 'free', + subscription: { status: 'free', stripe_subscription_id: null }, + })), + usePlan: jest.fn(() => ({ + data: { + id: 'beakerstack_free', + display_name: 'Free', + features: { + containers_per_account_max: 2, + items_per_container_max: 3, + feature_a: false, + feature_b: false, + }, + usage_limits: { ai_summarize: 30 }, + }, + loading: false, + })), + usePlanCatalog: jest.fn(() => ({ plans: [] })), + useUsage: jest.fn(() => ({ + used: 1, + limit: 30, + remaining: 29, + loading: false, + })), + useBillingConfig: jest.fn(() => ({ + productId: 'beakerstack', + planFeatureRows: [ + { + id: 'feature_a', + featureKey: 'feature_a', + kind: 'boolean', + label: 'Feature A', + }, + ], + usageMeterCopy: { + ai_summarize: { label: 'AI summarize', description: 'Demo meter' }, + }, + usageLimitsCopy: { + collectionsRowName: 'Collections', + itemsRowName: 'Items', + collectionsFootnote: 'Demo footnote', + }, + })), +})); + +jest.mock('@beakerstack/billing/native', () => { + const { Text, View } = require('react-native'); + return { + UsageIndicator: ({ label }: { label?: string }) => ( + {label ?? 'meter'} + ), + FeatureCheckIcon: () => , + FeatureXIcon: () => , + }; +}); + +function renderWithNav(ui: React.ReactElement) { + return render({ui}); +} + +describe('BillingOverviewScreen', () => { + it('shows plan name and usage stats', () => { + const { getByText } = renderWithNav(); + expect(getByText(/You're on the Free plan/)).toBeTruthy(); + expect(getByText(/1 of 30 AI summaries/)).toBeTruthy(); + expect(getByText(/1 of 2/)).toBeTruthy(); + }); +}); + +describe('BillingUsageScreen', () => { + it('renders usage meters and boolean plan features from config', () => { + const { getByText } = renderWithNav(); + expect(getByText('Plan features')).toBeTruthy(); + expect(getByText('AI summarize')).toBeTruthy(); + expect(getByText('Feature A')).toBeTruthy(); + expect(getByText('Not available')).toBeTruthy(); + }); +}); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9f17a7fb..e52ac56c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -105,8 +105,7 @@ "!src/**/*.d.ts", "!src/**/__tests__/**", "!src/**/__mocks__/**", - "!src/**/types/**", - "!src/**/DebugTools.tsx" + "!src/**/types/**" ], "coverageDirectory": "coverage", "coverageReporters": [ diff --git a/apps/mobile/src/billing/beakerstackBillingConfig.ts b/apps/mobile/src/billing/beakerstackBillingConfig.ts index 0b24a221..e5795f44 100644 --- a/apps/mobile/src/billing/beakerstackBillingConfig.ts +++ b/apps/mobile/src/billing/beakerstackBillingConfig.ts @@ -2,6 +2,7 @@ import { defineBillingConfig, type InferFeatureKeys, } from '@beakerstack/billing'; +import { BRANDING } from '@beakerstack/shared/config/branding'; /** * Template-owned billing config (Free / Pro / Max). IDs match `supabase/seed.sql`. @@ -10,7 +11,7 @@ import { */ export const beakerstackBillingConfig = defineBillingConfig({ productId: 'beakerstack', - displayName: 'BeakerStack', + displayName: BRANDING.displayName, description: 'Template demo', plans: [ { diff --git a/apps/mobile/src/billing/mobileBillingUrls.ts b/apps/mobile/src/billing/mobileBillingUrls.ts new file mode 100644 index 00000000..7d9676a8 --- /dev/null +++ b/apps/mobile/src/billing/mobileBillingUrls.ts @@ -0,0 +1,32 @@ +/** + * Stripe return / portal URLs for the mobile app. + * Defaults to the Expo scheme (`beaker-stack://billing`) so hosted Supabase accepts + * redirects without a LAN IP. Override with `EXPO_PUBLIC_BILLING_DEMO_BASE_URL` for + * web-style paths (e.g. `http://192.168.x.x:8081` on a physical device). + * + * Hosted Edge: if the scheme/host differs, add `protocol//host` to `BILLING_ALLOWED_ORIGINS` + * (see `MOBILE_APP_REDIRECT_KEYS` in `billing-origins.ts`). + */ +export function getMobileBillingProviderUrls(): { + checkoutSuccessUrl: string; + checkoutCancelUrl: string; + portalReturnUrl: string; +} { + const base = + (typeof process !== 'undefined' && + process.env?.['EXPO_PUBLIC_BILLING_DEMO_BASE_URL']) || + 'beaker-stack://billing'; + const isDeepLink = base.includes('://') && !base.startsWith('http'); + if (isDeepLink) { + return { + checkoutSuccessUrl: `${base}?checkout=success`, + checkoutCancelUrl: `${base}?checkout=cancel`, + portalReturnUrl: base, + }; + } + return { + checkoutSuccessUrl: `${base}/billing?checkout=success`, + checkoutCancelUrl: `${base}/billing/plans?checkout=cancel`, + portalReturnUrl: `${base}/billing`, + }; +} diff --git a/apps/mobile/src/billing/planFeatureValue.ts b/apps/mobile/src/billing/planFeatureValue.ts new file mode 100644 index 00000000..ac4f2098 --- /dev/null +++ b/apps/mobile/src/billing/planFeatureValue.ts @@ -0,0 +1,9 @@ +/** Numeric entitlement from plan.features (-1 = unlimited sentinel). */ +export function numericPlanFeature( + features: Record, + key: string, + fallback = -1 +): number { + const v = features[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} diff --git a/apps/mobile/src/billing/useDemoCollectionCount.ts b/apps/mobile/src/billing/useDemoCollectionCount.ts new file mode 100644 index 00000000..9a7e776d --- /dev/null +++ b/apps/mobile/src/billing/useDemoCollectionCount.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { supabase } from '../lib/supabase'; + +const PRODUCT_ID = 'beakerstack'; + +/** + * Demo collections count and max item_count (from `billing_demo_get_collections`). + * Mirrors web `useDemoCollectionCount` for billing usage/plans rows. + */ +export function useDemoCollectionCount() { + const [count, setCount] = useState(0); + const [maxItemsInAnyCollection, setMaxItemsInAnyCollection] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error: rpcError } = await supabase.rpc( + 'billing_demo_get_collections', + { + p_product_id: PRODUCT_ID, + } + ); + if (rpcError) throw rpcError; + const rows = (data as { id: string; item_count: number }[] | null) ?? []; + setCount(rows.length); + setMaxItemsInAnyCollection( + rows.length > 0 + ? Math.max(...rows.map(r => Number(r.item_count ?? 0))) + : 0 + ); + } catch (e) { + setError( + e instanceof Error ? e : new Error('Failed to load collections') + ); + setCount(0); + setMaxItemsInAnyCollection(0); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { + count, + maxItemsInAnyCollection, + loading, + error, + refresh, + }; +} diff --git a/apps/mobile/src/components/DebugTools.tsx b/apps/mobile/src/components/DebugTools.tsx deleted file mode 100644 index fc9f4b2d..00000000 --- a/apps/mobile/src/components/DebugTools.tsx +++ /dev/null @@ -1,1314 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - Modal, - ScrollView, - Alert, - ActivityIndicator, - SafeAreaView, -} from 'react-native'; -import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; -import { useProfileContext } from '@beakerstack/shared/contexts/ProfileContext'; -import { useProfile } from '@beakerstack/shared/hooks/useProfile'; -import { supabase } from '../lib/supabase'; -import { Logger } from '@beakerstack/shared/utils/logger'; -import { - profileFormSchema, - type ProfileFormInput, -} from '@beakerstack/shared/validation/profileSchema'; -import { ZodError, type ZodIssue } from 'zod'; -import { TextInput } from 'react-native'; - -export function DebugTools() { - const [isVisible, setIsVisible] = useState(false); - const [clickCount, setClickCount] = useState(0); - const clickTimeoutRef = useRef(null); - const auth = useAuthContext(); - const profile = useProfile(supabase, auth.user); - - const handleHiddenTap = () => { - // Clear existing timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - - const newCount = clickCount + 1; - setClickCount(newCount); - - if (newCount >= 4) { - setIsVisible(true); - setClickCount(0); - } else { - // Reset counter after 2 seconds of inactivity - clickTimeoutRef.current = setTimeout(() => { - setClickCount(0); - }, 2000); - } - }; - - useEffect(() => { - return () => { - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - }; - }, []); - - if (!isVisible) { - return ( - - ); - } - - return ( - setIsVisible(false)} - > - - - 🧪 Debug Tools - setIsVisible(false)} - style={styles.closeButton} - > - Close - - - - - {/* Database Test Section - First */} - - - 🧪 Database Connection Test - - - - - {/* AuthContext Test Section - Second */} - - 🧪 AuthContext Test - - - Loading:{' '} - - {auth.loading ? 'true' : 'false'} - - - - User:{' '} - - {auth.user ? auth.user.email : 'null'} - - - - Session:{' '} - - {auth.session ? 'active' : 'null'} - - - - Error:{' '} - - {auth.error ? auth.error.message : 'null'} - - - - - ✓ Context provides auth state to components - - - - {/* Task 4.1: useProfile Hook Test */} - {auth.user ? ( - - - 🧪 useProfile Hook Test (Task 4.1) - - - - ) : ( - - - 🧪 useProfile Hook Test (Task 4.1) - - - Please sign in to test this component. - - - )} - - {/* Task 4.2: Profile Validation Schema Test */} - - - 🧪 Profile Validation Schema Test (Task 4.2) - - - Test the validation schema by entering invalid data and seeing - error messages appear. - - - - ✓ Try: username too short, display name too long, invalid website - URL, etc. - - - - {/* Task 4.3: Form Components Test */} - - - 🧪 Form Components Test (Task 4.3) - - - Test the shared form components to verify they work identically on - web and mobile. - - - - ✓ Test all component states: normal, error, disabled, loading - - - - {/* Task 4.4: Profile Editor Test */} - {auth.user ? ( - - - 🧪 Profile Editor Test (Task 4.4) - - - Test the ProfileEditor component - edit your profile and save - changes. - - - - ✓ Edit profile fields and click "Update Profile" or "Create - Profile" - - - ) : ( - - - 🧪 Profile Editor Test (Task 4.4) - - - Please sign in to test this component. - - - )} - - {/* Task 4.5: Profile Display Components Test */} - {auth.user ? ( - - - 🧪 Profile Display Components Test (Task 4.5) - - - Test the profile display components (ProfileAvatar, - ProfileHeader, ProfileStats) to verify they display user data - correctly. - - {profile.profile && ( - - )} - - ✓ Verify: Avatar shows image or initials, Header displays all - profile info, Stats show member since and completion % - - - ) : ( - - - 🧪 Profile Display Components Test (Task 4.5) - - - Please sign in to test this component. - - - )} - - - - ); -} - -// Database Test Component -function DatabaseTest() { - const [testResult, setTestResult] = useState(''); - const [isLoading, setIsLoading] = useState(false); - - const handleTestDatabase = async () => { - setIsLoading(true); - setTestResult(''); - - try { - const { data, error } = await supabase - .from('user_profiles') - .select('id, username') - .limit(5); - - if (error) { - const message = `❌ Error: ${error.message}`; - setTestResult(message); - Alert.alert('Database Test Failed', error.message); - } else { - const message = `✅ Success! Found ${data.length} user profiles`; - setTestResult(message); - Alert.alert( - 'Database Test Passed', - `Found ${data.length} user profiles` - ); - } - } catch (err) { - const message = `❌ Exception: ${err instanceof Error ? err.message : 'Unknown error'}`; - setTestResult(message); - Alert.alert('Database Test Failed', message); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - {isLoading ? 'Testing...' : '🧪 Test Database'} - - - - {testResult ? ( - - {testResult} - - ) : null} - - ); -} - -// UseProfile Test Component -function UseProfileTest({ - profile, - auth, -}: { - profile: ReturnType; - auth: ReturnType; -}) { - const handleCreateProfile = async () => { - if (!auth.user) return; - try { - await profile.createProfile(auth.user.id, { - username: `testuser_${Date.now()}`, - display_name: 'Test User', - bio: 'Created via useProfile hook test', - }); - Alert.alert('Success', '✅ Profile created! Check the display above.'); - } catch (err) { - Alert.alert( - 'Error', - `❌ ${err instanceof Error ? err.message : 'Unknown error'}` - ); - } - }; - - const handleUpdateProfile = async () => { - if (!auth.user) return; - try { - await profile.updateProfile(auth.user.id, { - display_name: `Test User ${Date.now()}`, - }); - Alert.alert('Success', '✅ Profile updated! Check the display above.'); - } catch (err) { - Alert.alert( - 'Error', - `❌ ${err instanceof Error ? err.message : 'Unknown error'}` - ); - } - }; - - const handleRefreshProfile = async () => { - try { - await profile.refreshProfile(); - Alert.alert('Success', '✅ Profile refreshed!'); - } catch (err) { - Alert.alert( - 'Error', - `❌ ${err instanceof Error ? err.message : 'Unknown error'}` - ); - } - }; - - return ( - - - Loading: - - {profile.loading ? 'true' : 'false'} - - - - Profile: - - {profile.profile ? 'exists' : 'null'} - - - - Error: - - {profile.error ? profile.error.message : 'null'} - - - - {profile.profile && ( - - Profile Data: - - Username: {profile.profile.username || 'null'} - - - Display Name: {profile.profile.display_name || 'null'} - - - Bio: {profile.profile.bio || 'null'} - - - Location: {profile.profile.location || 'null'} - - - Website: {profile.profile.website || 'null'} - - - )} - - - - {profile.profile ? 'Update Profile' : 'Create Profile'} - - - - - Refresh Profile - - - - ✓ Test create, read, update operations above - - - ); -} - -// Validation test form component for mobile -function ValidationTestFormMobile() { - const [formData, setFormData] = useState({ - username: '', - display_name: '', - bio: '', - website: '', - location: '', - avatar_url: '', - }); - const [errors, setErrors] = useState>({}); - const [lastValidationResult, setLastValidationResult] = useState< - string | null - >(null); - - const handleFieldChange = (field: keyof ProfileFormInput, value: string) => { - setFormData(prev => - prev[field] === value ? prev : { ...prev, [field]: value } - ); - if (errors[field]) { - setErrors(prev => { - if (!prev[field]) { - return prev; - } - const newErrors = { ...prev }; - delete newErrors[field]; - return newErrors; - }); - } - setLastValidationResult(null); - }; - - const validateField = (field: keyof ProfileFormInput, value: string) => { - try { - profileFormSchema.parse({ ...formData, [field]: value }); - setErrors(prev => { - if (!prev[field]) { - return prev; - } - const newErrors = { ...prev }; - delete newErrors[field]; - return newErrors; - }); - } catch (err) { - if (err instanceof ZodError) { - const fieldError = err.issues.find((issue: ZodIssue) => - issue.path.includes(field) - ); - if (fieldError) { - setErrors(prev => ({ ...prev, [field]: fieldError.message })); - } - } - } - }; - - const handleValidateAll = () => { - try { - profileFormSchema.parse(formData); - setErrors({}); - Alert.alert('Success', '✅ All fields are valid!'); - setLastValidationResult('✅ All fields are valid!'); - } catch (err) { - if (err instanceof ZodError) { - const newErrors: Record = {}; - err.issues.forEach((issue: ZodIssue) => { - const field = issue.path[0] as string; - if (field) { - newErrors[field] = issue.message; - } - }); - setErrors(newErrors); - const errorCount = err.issues.length; - Alert.alert( - 'Validation Failed', - `❌ Validation failed: ${errorCount} error(s)` - ); - setLastValidationResult(`❌ Validation failed: ${errorCount} error(s)`); - } - } - }; - - return ( - - - Username - handleFieldChange('username', text)} - onBlur={() => validateField('username', formData.username || '')} - placeholder='3-30 chars, alphanumeric + underscore' - /> - {errors.username && ( - {errors.username} - )} - - - - Display Name - handleFieldChange('display_name', text)} - onBlur={() => - validateField('display_name', formData.display_name || '') - } - placeholder='Max 100 characters' - /> - {errors.display_name && ( - {errors.display_name} - )} - - - - Bio - handleFieldChange('bio', text)} - onBlur={() => validateField('bio', formData.bio || '')} - placeholder='Max 500 characters' - multiline - numberOfLines={3} - /> - - {errors.bio && ( - {errors.bio} - )} - - {formData.bio?.length || 0}/500 - - - - - - Website - handleFieldChange('website', text)} - onBlur={() => validateField('website', formData.website || '')} - placeholder='https://example.com' - keyboardType='url' - autoCapitalize='none' - /> - {errors.website && ( - {errors.website} - )} - - - - Location - handleFieldChange('location', text)} - placeholder='Any text' - /> - - - - Avatar URL - handleFieldChange('avatar_url', text)} - onBlur={() => validateField('avatar_url', formData.avatar_url || '')} - placeholder='https://example.com/avatar.jpg' - keyboardType='url' - autoCapitalize='none' - /> - {errors.avatar_url && ( - {errors.avatar_url} - )} - - - - Validate All Fields - - - {lastValidationResult && ( - - - {lastValidationResult} - - - )} - - ); -} - -// Form components test component for mobile -function FormComponentsTestMobile() { - const [inputValue, setInputValue] = useState(''); - const [buttonLoading, setButtonLoading] = useState(false); - const [showError, setShowError] = useState(false); - const [componentsLoaded, setComponentsLoaded] = useState(false); - const [FormComponents, setFormComponents] = useState<{ - FormInput: React.ComponentType< - import('@beakerstack/shared/components/forms/FormInput.native').FormInputProps - >; - FormButton: React.ComponentType< - import('@beakerstack/shared/components/forms/FormButton.native').FormButtonProps - >; - FormError: React.ComponentType< - import('@beakerstack/shared/components/forms/FormError.native').FormErrorProps - >; - } | null>(null); - - useEffect(() => { - if (!componentsLoaded) { - // Dynamic imports are supported by Metro bundler - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - TypeScript doesn't recognize dynamic imports but Metro supports them - Promise.all([ - import('@beakerstack/shared/components/forms/FormInput.native'), - import('@beakerstack/shared/components/forms/FormButton.native'), - import('@beakerstack/shared/components/forms/FormError.native'), - ]) - .then(([FormInputModule, FormButtonModule, FormErrorModule]) => { - setFormComponents({ - FormInput: FormInputModule.FormInput, - FormButton: FormButtonModule.FormButton, - FormError: FormErrorModule.FormError, - }); - setComponentsLoaded(true); - }) - .catch(err => { - Logger.error( - '[FormComponentsTest] Failed to load form components:', - err - ); - }); - } - }, [componentsLoaded]); - - const handleButtonClick = () => { - setButtonLoading(true); - setTimeout(() => { - setButtonLoading(false); - setShowError(!showError); - Alert.alert('Button Clicked', 'Loading state completed!'); - }, 1500); - }; - - if (!componentsLoaded || !FormComponents) { - return ( - - - - Loading form components... - - - ); - } - - const { FormInput, FormButton, FormError } = FormComponents; - - return ( - - - FormInput Examples: - - - - {}} - error='This field has an error message' - /> - - {}} - disabled - /> - - {}} - multiline - numberOfLines={4} - placeholder='Enter multiple lines...' - /> - - - - FormButton Examples: - - Alert.alert('Button Clicked', 'Normal button works!')} - /> - - - - {}} disabled /> - - - - {}} - variant='primary' - fullWidth={false} - /> - - - {}} - variant='secondary' - fullWidth={false} - /> - - - {}} - variant='danger' - fullWidth={false} - /> - - - - - - FormError Examples: - - - - setShowError(!showError)} - style={styles.toggleButton} - > - - {showError ? 'Hide' : 'Show'} Dynamic Error - - - - {showError && ( - - )} - - - ); -} - -// ProfileEditor test component for mobile -function ProfileEditorTestMobile() { - const auth = useAuthContext(); - const profileContext = useProfileContext(); - const user = profileContext.currentUser ?? auth.user; - const [componentsLoaded, setComponentsLoaded] = useState(false); - const [ProfileEditor, setProfileEditor] = useState | null>(null); - - useEffect(() => { - if (!componentsLoaded) { - import('@beakerstack/shared/components/profile/ProfileEditor.native') - .then(module => { - setProfileEditor(() => module.ProfileEditor); - setComponentsLoaded(true); - }) - .catch(err => { - Logger.error( - '[ProfileEditorTest] Failed to load ProfileEditor:', - err - ); - }); - } - }, [componentsLoaded]); - - if (!componentsLoaded || !ProfileEditor) { - return ( - - - - Loading ProfileEditor... - - - ); - } - - return ( - - { - Logger.debug('[ProfileEditorTest] onSuccess callback fired'); - Alert.alert('Success', 'Profile saved successfully!'); - }} - onError={(error: Error) => { - Logger.error( - '[ProfileEditorTest] onError callback fired:', - error.message, - error.stack - ); - Alert.alert('Error', `Failed to save profile: ${error.message}`); - }} - /> - - ); -} - -// Profile Display Components test component for mobile -function ProfileDisplayTestMobile({ - profile, -}: { - profile: import('@beakerstack/shared/types/profile').UserProfile; -}) { - const [componentsLoaded, setComponentsLoaded] = useState(false); - const [ProfileAvatar, setProfileAvatar] = useState | null>(null); - const [ProfileHeader, setProfileHeader] = useState | null>(null); - const [ProfileStats, setProfileStats] = useState | null>(null); - - useEffect(() => { - if (!componentsLoaded) { - Promise.all([ - import('@beakerstack/shared/components/profile/ProfileAvatar.native'), - import('@beakerstack/shared/components/profile/ProfileHeader.native'), - import('@beakerstack/shared/components/profile/ProfileStats.native'), - ]) - .then(([avatarModule, headerModule, statsModule]) => { - setProfileAvatar(() => avatarModule.ProfileAvatar); - setProfileHeader(() => headerModule.ProfileHeader); - setProfileStats(() => statsModule.ProfileStats); - setComponentsLoaded(true); - }) - .catch(err => { - Logger.error('[ProfileDisplayTest] Failed to load components:', err); - }); - } - }, [componentsLoaded]); - - if (!componentsLoaded || !ProfileAvatar || !ProfileHeader || !ProfileStats) { - return ( - - - - Loading Profile Display Components... - - - ); - } - - return ( - - - - ProfileHeader: - - - - - - ProfileAvatar (different sizes): - - - - - - Small - - - - - - Medium - - - - - - Large - - - - - - - ProfileStats: - - - - - ); -} - -const styles = StyleSheet.create({ - hiddenButton: { - position: 'absolute', - bottom: 16, - left: 16, - width: 20, - height: 20, - backgroundColor: 'transparent', - zIndex: 9999, - }, - modalContainer: { - flex: 1, - backgroundColor: '#f9fafb', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - backgroundColor: '#ffffff', - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - }, - headerTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#111827', - }, - closeButton: { - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: '#e5e7eb', - borderRadius: 6, - }, - closeButtonText: { - fontSize: 14, - fontWeight: '500', - color: '#374151', - }, - scrollView: { - flex: 1, - }, - content: { - padding: 16, - paddingBottom: 40, - }, - testCard: { - backgroundColor: '#ffffff', - borderRadius: 12, - padding: 16, - marginBottom: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 3.84, - elevation: 5, - }, - testCardTitle: { - fontSize: 18, - fontWeight: '600', - color: '#111827', - marginBottom: 12, - }, - disabledCard: { - backgroundColor: '#f3f4f6', - borderRadius: 12, - padding: 16, - marginBottom: 20, - borderWidth: 1, - borderColor: '#d1d5db', - }, - disabledCardTitle: { - fontSize: 18, - fontWeight: '600', - color: '#374151', - marginBottom: 8, - }, - disabledCardText: { - fontSize: 14, - color: '#6b7280', - }, - testInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 8, - }, - testLabel: { - fontSize: 12, - color: '#6b21a8', - fontWeight: '500', - }, - testValue: { - fontSize: 12, - color: '#6b21a8', - fontFamily: 'monospace', - }, - profileDataContainer: { - backgroundColor: '#ffffff', - borderRadius: 8, - padding: 12, - marginTop: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: '#d8b4fe', - }, - profileDataTitle: { - fontSize: 12, - fontWeight: '600', - color: '#6b21a8', - marginBottom: 8, - }, - profileDataText: { - fontSize: 11, - color: '#6b21a8', - marginBottom: 4, - fontFamily: 'monospace', - }, - testButton: { - backgroundColor: '#d8b4fe', - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - marginBottom: 8, - borderWidth: 1, - borderColor: '#c084fc', - }, - testButtonDisabled: { - opacity: 0.5, - }, - testButtonText: { - color: '#6b21a8', - fontSize: 14, - fontWeight: '600', - }, - testNote: { - fontSize: 11, - color: '#9333ea', - fontStyle: 'italic', - marginTop: 8, - textAlign: 'center', - }, - authContextContainer: { - marginBottom: 20, - padding: 16, - backgroundColor: '#eff6ff', - borderRadius: 8, - borderWidth: 1, - borderColor: '#bfdbfe', - }, - authContextTitle: { - fontSize: 14, - fontWeight: '600', - color: '#1e3a8a', - marginBottom: 8, - }, - authContextContent: { - gap: 4, - }, - authContextItem: { - fontSize: 12, - color: '#1e40af', - }, - authContextValue: { - fontFamily: 'monospace', - }, - authContextFooter: { - marginTop: 8, - fontSize: 12, - color: '#2563eb', - fontStyle: 'italic', - }, - resultContainer: { - marginTop: 12, - padding: 12, - backgroundColor: '#ffffff', - borderRadius: 8, - borderWidth: 1, - borderColor: '#d1d5db', - }, - resultText: { - fontSize: 14, - color: '#374151', - textAlign: 'center', - }, - validationTestCard: { - backgroundColor: '#dbeafe', - borderRadius: 12, - padding: 16, - marginBottom: 20, - borderWidth: 1, - borderColor: '#93c5fd', - }, - validationTestCardTitle: { - fontSize: 16, - fontWeight: '600', - color: '#1e40af', - marginBottom: 8, - }, - validationTestCardDescription: { - fontSize: 12, - color: '#1e40af', - marginBottom: 12, - }, - validationTestNote: { - fontSize: 11, - color: '#2563eb', - fontStyle: 'italic', - marginTop: 12, - textAlign: 'center', - }, - validationFormContainer: { - gap: 12, - }, - validationField: { - marginBottom: 12, - }, - validationLabel: { - fontSize: 12, - fontWeight: '500', - color: '#1e40af', - marginBottom: 4, - }, - validationInput: { - backgroundColor: '#ffffff', - borderWidth: 1, - borderColor: '#93c5fd', - borderRadius: 8, - padding: 10, - fontSize: 14, - color: '#1e40af', - }, - validationInputError: { - borderColor: '#ef4444', - backgroundColor: '#fef2f2', - }, - validationTextArea: { - minHeight: 80, - textAlignVertical: 'top', - }, - validationErrorText: { - fontSize: 11, - color: '#dc2626', - fontWeight: '500', - marginTop: 4, - }, - validationFieldFooter: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 4, - }, - validationCharCount: { - fontSize: 11, - color: '#2563eb', - }, - validationButton: { - backgroundColor: '#bfdbfe', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - marginTop: 8, - borderWidth: 1, - borderColor: '#93c5fd', - }, - validationButtonText: { - color: '#1e40af', - fontSize: 14, - fontWeight: '600', - }, - validationResult: { - padding: 12, - borderRadius: 8, - borderWidth: 1, - marginTop: 8, - }, - validationResultSuccess: { - backgroundColor: '#d1fae5', - borderColor: '#86efac', - }, - validationResultError: { - backgroundColor: '#fee2e2', - borderColor: '#fca5a5', - }, - validationResultText: { - fontSize: 12, - fontWeight: '500', - textAlign: 'center', - }, - formComponentsTestCard: { - backgroundColor: '#dcfce7', - borderRadius: 12, - padding: 16, - marginBottom: 20, - borderWidth: 1, - borderColor: '#86efac', - }, - formComponentsTestCardTitle: { - fontSize: 16, - fontWeight: '600', - color: '#166534', - marginBottom: 8, - }, - formComponentsTestCardDescription: { - fontSize: 12, - color: '#166534', - marginBottom: 12, - }, - formComponentsTestNote: { - fontSize: 11, - color: '#16a34a', - fontStyle: 'italic', - marginTop: 12, - textAlign: 'center', - }, - formTestSection: { - marginBottom: 24, - }, - formTestSectionTitle: { - fontSize: 14, - fontWeight: '600', - color: '#166534', - marginBottom: 12, - }, - buttonRow: { - flexDirection: 'row', - gap: 8, - marginTop: 8, - }, - buttonRowItem: { - flex: 1, - }, - toggleButton: { - padding: 12, - backgroundColor: '#86efac', - borderRadius: 8, - marginTop: 8, - alignItems: 'center', - }, - toggleButtonText: { - fontSize: 12, - color: '#166534', - fontWeight: '500', - }, -}); diff --git a/apps/mobile/src/components/dashboard/AnnotatedPrimitive.tsx b/apps/mobile/src/components/dashboard/AnnotatedPrimitive.tsx new file mode 100644 index 00000000..b0ba1bfb --- /dev/null +++ b/apps/mobile/src/components/dashboard/AnnotatedPrimitive.tsx @@ -0,0 +1,92 @@ +import React, { type ReactNode } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +type TagVariant = 'usage' | 'gate' | 'feature'; + +const dotColor: Record = { + usage: '#fbbf24', + gate: '#60a5fa', + feature: '#4ade80', +}; + +interface AnnotatedPrimitiveProps { + tag: string; + variant: TagVariant; + tooltip?: string; + children: ReactNode; +} + +export function AnnotatedPrimitive({ + tag, + variant, + tooltip, + children, +}: AnnotatedPrimitiveProps) { + const hint = tooltip ? `${tag} — ${tooltip}` : tag; + return ( + + + + {tag} + + {tooltip ? ( + + {tooltip} + + ) : null} + {children} + + ); +} + +const styles = StyleSheet.create({ + card: { + position: 'relative', + borderRadius: 12, + borderWidth: 2, + borderStyle: 'dashed', + borderColor: '#c4b5fd', + padding: 16, + paddingTop: 28, + marginBottom: 16, + backgroundColor: '#fff', + }, + tagRow: { + position: 'absolute', + top: -10, + left: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: '#0f172a', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.12, + shadowRadius: 2, + elevation: 2, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + tagText: { + fontFamily: 'monospace', + fontSize: 11, + color: '#e9d5ff', + }, + tooltip: { + marginTop: 4, + marginBottom: 4, + fontSize: 11, + color: '#64748b', + lineHeight: 15, + }, + body: { marginTop: 8 }, +}); diff --git a/apps/mobile/src/components/dashboard/BooleanFeatureTiles.tsx b/apps/mobile/src/components/dashboard/BooleanFeatureTiles.tsx new file mode 100644 index 00000000..b214559d --- /dev/null +++ b/apps/mobile/src/components/dashboard/BooleanFeatureTiles.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { useFeature } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; + +interface TileProps { + label: string; + plan: string; + enabled: boolean; + loading: boolean; + hookExpr: string; +} + +function FeatureTile({ label, plan, enabled, loading, hookExpr }: TileProps) { + return ( + + + + {label} + {plan} + + + {loading ? ( + + ) : ( + + {enabled ? '✓' : '✕'} + + )} + + + + + {hookExpr} →{' '} + + {loading ? '…' : String(enabled)} + + + + + ); +} + +export function BooleanFeatureTiles() { + const featureA = useFeature( + 'feature_a' + ); + const featureB = useFeature( + 'feature_b' + ); + + return ( + + Boolean feature gates + + + + + + ); +} + +const styles = StyleSheet.create({ + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + marginBottom: 12, + }, + row: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 }, + tile: { + flex: 1, + minWidth: 140, + borderRadius: 10, + borderWidth: 2, + padding: 14, + }, + tileOn: { + borderColor: '#86efac', + backgroundColor: '#f0fdf4', + }, + tileOff: { + borderColor: '#e5e7eb', + backgroundColor: '#f9fafb', + }, + tileHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 8, + }, + tileLabel: { fontSize: 13, fontWeight: '600', color: '#111827' }, + tilePlan: { marginTop: 2, fontSize: 11, color: '#6b7280' }, + iconWrap: { + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 4, + }, + iconOn: { backgroundColor: '#dcfce7' }, + iconOff: { backgroundColor: '#e5e7eb' }, + iconPlaceholder: { width: 14, height: 14 }, + iconText: { fontSize: 12, fontWeight: '800', color: '#9ca3af' }, + iconTextOn: { color: '#16a34a' }, + codeBox: { + marginTop: 10, + backgroundColor: '#0f172a', + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 8, + }, + codeText: { + fontFamily: 'monospace', + fontSize: 11, + color: '#cbd5e1', + }, + codeValOn: { color: '#4ade80' }, + codeValOff: { color: '#64748b' }, +}); diff --git a/apps/mobile/src/components/dashboard/CollectionDetail.tsx b/apps/mobile/src/components/dashboard/CollectionDetail.tsx new file mode 100644 index 00000000..260b8653 --- /dev/null +++ b/apps/mobile/src/components/dashboard/CollectionDetail.tsx @@ -0,0 +1,400 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { + mapUnknownError, + useBillingContext, + useFeature, + useUsage, +} from '@beakerstack/billing'; +import type { BillingError } from '@beakerstack/billing'; +import { supabase } from '../../lib/supabase'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { nextFakeAiSummary } from '../../lib/fakeAi'; +import { randomUuid } from '../../lib/randomUuid'; +import type { DemoCollectionRow } from '../../billing/useDemoCollections'; +import type { ActivityEntry } from './types'; +import { limLabel } from './utils'; + +interface Props { + collection: DemoCollectionRow | undefined; + addItem: (collectionId: string) => Promise; + onActivity?: (entry: Omit) => void; +} + +export function CollectionDetail({ collection, addItem, onActivity }: Props) { + const { config } = useBillingContext(); + const { value: maxItemsRaw, loading: featLoading } = useFeature< + typeof beakerstackBillingConfig, + 'items_per_container_max' + >('items_per_container_max'); + const featureA = useFeature( + 'feature_a' + ); + const featureB = useFeature( + 'feature_b' + ); + const { + exceeded: usageExceeded, + loading: usageLoading, + refresh: refreshUsage, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + + const maxItems = typeof maxItemsRaw === 'number' ? maxItemsRaw : null; + const itemCount = collection?.item_count ?? 0; + const atItemCap = + maxItems !== null && maxItems !== -1 && itemCount >= maxItems; + + const [summaries, setSummaries] = useState>(new Map()); + const [summarizeBusy, setSummarizeBusy] = useState>(new Set()); + const [summarizeErrors, setSummarizeErrors] = useState< + Map + >(new Map()); + const [summarizeKeys, setSummarizeKeys] = useState>( + new Map() + ); + const [addBusy, setAddBusy] = useState(false); + const [addErr, setAddErr] = useState(null); + const [featureToast, setFeatureToast] = useState(null); + const toastTimer = useRef | null>(null); + const inFlightRef = useRef(0); + + useEffect(() => { + setSummaries(new Map()); + setSummarizeBusy(new Set()); + setSummarizeErrors(new Map()); + setSummarizeKeys(new Map()); + }, [collection?.id]); + + useEffect( + () => () => { + if (toastTimer.current) clearTimeout(toastTimer.current); + }, + [] + ); + + const showToast = useCallback((msg: string) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setFeatureToast(msg); + toastTimer.current = setTimeout(() => setFeatureToast(null), 3000); + }, []); + + const onSummarize = useCallback( + async (itemIndex: number) => { + if (!collection || usageExceeded || inFlightRef.current > 0) return; + inFlightRef.current += 1; + setSummarizeBusy(prev => new Set(prev).add(itemIndex)); + setSummarizeErrors(prev => { + const next = new Map(prev); + next.delete(itemIndex); + return next; + }); + const key = summarizeKeys.get(itemIndex) ?? randomUuid(); + setSummarizeKeys(prev => new Map(prev).set(itemIndex, key)); + try { + const { error: rpcErr } = await supabase.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: BEAKERSTACK_METER_AI_SUMMARIZE, + p_quantity: 1, + p_metadata: {}, + p_idempotency_key: key, + } + ); + if (rpcErr) throw rpcErr; + await refreshUsage(); + const text = nextFakeAiSummary(); + setSummaries(prev => new Map(prev).set(itemIndex, text)); + setSummarizeKeys(prev => { + const next = new Map(prev); + next.delete(itemIndex); + return next; + }); + onActivity?.({ + label: `Item ${itemIndex + 1} summarized`, + rpc: 'billing_record_usage_event', + }); + } catch (e) { + setSummarizeErrors(prev => + new Map(prev).set(itemIndex, mapUnknownError(e)) + ); + } finally { + inFlightRef.current -= 1; + setSummarizeBusy(prev => { + const next = new Set(prev); + next.delete(itemIndex); + return next; + }); + } + }, + [ + collection, + usageExceeded, + config.productId, + refreshUsage, + onActivity, + summarizeKeys, + ] + ); + + const onAddItem = useCallback(async () => { + if (!collection || atItemCap) return; + setAddErr(null); + setAddBusy(true); + try { + await addItem(collection.id); + onActivity?.({ label: 'Item added', rpc: 'billing_demo_add_item' }); + } catch (e) { + setAddErr(e instanceof Error ? e.message : 'Action failed.'); + } finally { + setAddBusy(false); + } + }, [collection, atItemCap, addItem, onActivity]); + + if (!collection) { + return ( + + + Select a collection above to view its items. + + + ); + } + + const itemRows = Array.from({ length: itemCount }, (_, i) => i); + const anySummarizeBusy = summarizeBusy.size > 0; + + return ( + + + + Items + + {featLoading + ? '…' + : `${itemCount} of ${limLabel(maxItems)} in this collection`} + + + + { + if (featureA.enabled) { + showToast('Feature A action triggered'); + } else { + showToast( + 'Feature A is not enabled on your current plan (useFeature returns false).' + ); + } + }} + > + + Feature A + + + { + if (featureB.enabled) { + showToast('Feature B action triggered'); + } else { + showToast( + 'Feature B is not enabled on your current plan (useFeature returns false).' + ); + } + }} + > + + Feature B + + + + + + {featureToast ? ( + + {featureToast} + + ) : null} + + {addErr ? ( + + {addErr} + + ) : null} + + {itemRows.length === 0 ? ( + No items yet. Add one below. + ) : ( + + {itemRows.map(i => { + const isBusy = summarizeBusy.has(i); + const summary = summaries.get(i); + const err = summarizeErrors.get(i); + const summarizeDisabled = + anySummarizeBusy || usageExceeded || usageLoading; + return ( + + + Item {i + 1} + void onSummarize(i)} + accessibilityLabel={ + usageExceeded + ? 'AI summarize limit reached' + : anySummarizeBusy && !isBusy + ? 'Another item is being summarized' + : 'Summarize' + } + > + + {isBusy + ? '…' + : usageExceeded + ? 'Limit reached' + : 'Summarize'} + + + + {err ? ( + + {err.message} + + ) : null} + {summary ? ( + {summary} + ) : null} + + ); + })} + + )} + + void onAddItem()} + > + + {addBusy ? '…' : atItemCap ? 'Item limit reached' : '+ Add item'} + + + + ); +} + +const styles = StyleSheet.create({ + emptySelect: { paddingVertical: 24, alignItems: 'center' }, + emptySelectText: { fontSize: 13, color: '#6b7280' }, + headerRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + gap: 10, + marginBottom: 12, + }, + sectionTitle: { fontSize: 14, fontWeight: '600', color: '#111827' }, + sub: { marginTop: 2, fontSize: 11, color: '#6b7280' }, + featureBtns: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + featBtn: { + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + }, + featBtnAOn: { backgroundColor: '#e0e7ff' }, + featBtnBOn: { backgroundColor: '#f3e8ff' }, + featBtnOff: { backgroundColor: '#f3f4f6' }, + featBtnText: { fontSize: 11, fontWeight: '700', color: '#4338ca' }, + featBtnTextOff: { color: '#9ca3af' }, + toast: { + marginBottom: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: '#c7d2fe', + backgroundColor: '#eef2ff', + padding: 10, + }, + toastText: { fontSize: 13, color: '#3730a3' }, + err: { marginBottom: 10, fontSize: 13, color: '#dc2626' }, + emptyItems: { fontSize: 13, color: '#6b7280', paddingVertical: 8 }, + itemList: { gap: 8, marginBottom: 10 }, + itemCard: { + borderRadius: 10, + borderWidth: 1, + borderColor: '#e5e7eb', + backgroundColor: '#f9fafb', + padding: 12, + }, + itemRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + }, + itemLabel: { fontSize: 13, fontWeight: '600', color: '#1f2937' }, + summarizeBtn: { + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#fff', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + }, + summarizeBtnDisabled: { opacity: 0.5 }, + summarizeBtnText: { fontSize: 11, fontWeight: '600', color: '#374151' }, + itemErr: { marginTop: 6, fontSize: 11, color: '#dc2626' }, + summaryText: { + marginTop: 8, + fontSize: 11, + color: '#4b5563', + lineHeight: 16, + }, + addItemBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderStyle: 'dashed', + borderColor: '#d1d5db', + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 14, + }, + addItemBtnText: { fontSize: 13, fontWeight: '500', color: '#6b7280' }, + btnDisabled: { opacity: 0.5 }, +}); diff --git a/apps/mobile/src/components/dashboard/CollectionsGrid.tsx b/apps/mobile/src/components/dashboard/CollectionsGrid.tsx new file mode 100644 index 00000000..ac4e7875 --- /dev/null +++ b/apps/mobile/src/components/dashboard/CollectionsGrid.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useState } from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { useFeature } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import type { DemoCollectionRow } from '../../billing/useDemoCollections'; +import type { ActivityEntry } from './types'; +import { limLabel } from './utils'; + +interface Props { + collections: DemoCollectionRow[]; + loading: boolean; + error: string | null; + selectedId: string | null; + onSelect: (id: string) => void; + addCollection: () => Promise; + deleteCollection: (id: string) => Promise; + onActivity?: (entry: Omit) => void; +} + +export function CollectionsGrid({ + collections, + loading, + error, + selectedId, + onSelect, + addCollection, + deleteCollection, + onActivity, +}: Props) { + const { value: maxCollectionsRaw, loading: featLoading } = useFeature< + typeof beakerstackBillingConfig, + 'containers_per_account_max' + >('containers_per_account_max'); + + const maxCollections = + typeof maxCollectionsRaw === 'number' ? maxCollectionsRaw : null; + const atCap = + maxCollections !== null && + maxCollections !== -1 && + collections.length >= maxCollections; + + const [busy, setBusy] = useState(null); + const [actionErr, setActionErr] = useState(null); + + const wrap = useCallback(async (key: string, fn: () => Promise) => { + setActionErr(null); + setBusy(key); + try { + await fn(); + } catch (e) { + setActionErr(e instanceof Error ? e.message : 'Action failed.'); + } finally { + setBusy(null); + } + }, []); + + const handleAdd = () => + void wrap('add', async () => { + await addCollection(); + onActivity?.({ + label: 'Collection added', + rpc: 'billing_demo_add_collection', + }); + }); + + const handleDelete = (id: string) => + void wrap(`del:${id}`, async () => { + await deleteCollection(id); + onActivity?.({ + label: 'Collection deleted', + rpc: 'billing_demo_delete_collection', + }); + }); + + return ( + + + + Collections + + {loading || featLoading + ? '…' + : `${collections.length} of ${limLabel(maxCollections)}`} + + + + + {busy === 'add' + ? '…' + : atCap + ? 'Limit reached' + : '+ New collection'} + + + + + {error ? ( + + {error}{' '} + + (Requires demo billing RPCs and{' '} + demo_billing_mode in the database.) + + + ) : null} + + {actionErr ? ( + + {actionErr} + + ) : null} + + {!loading && collections.length === 0 ? ( + + No collections yet. Tap 'New collection' to start. + + ) : ( + + {collections.map(col => { + const isSelected = col.id === selectedId; + const delKey = `del:${col.id}`; + return ( + onSelect(col.id)} + accessibilityRole='button' + accessibilityState={{ selected: isSelected }} + > + handleDelete(col.id)} + > + Del + + + {col.id.slice(0, 8)}… + + Collection + + {col.item_count} item{col.item_count !== 1 ? 's' : ''} + + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + headerRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignItems: 'center', + gap: 10, + marginBottom: 12, + }, + sectionTitle: { fontSize: 14, fontWeight: '600', color: '#111827' }, + sub: { marginTop: 2, fontSize: 11, color: '#6b7280' }, + addBtn: { + backgroundColor: '#4f46e5', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + }, + addBtnText: { fontSize: 13, fontWeight: '700', color: '#fff' }, + btnDisabled: { opacity: 0.5 }, + warn: { + marginBottom: 10, + fontSize: 13, + color: '#92400e', + }, + warnMuted: { color: '#4b5563', fontSize: 12 }, + mono: { fontFamily: 'monospace', fontSize: 11 }, + err: { marginBottom: 10, fontSize: 13, color: '#dc2626' }, + empty: { fontSize: 13, color: '#6b7280' }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + card: { + width: '47%', + minWidth: 140, + flexGrow: 1, + borderRadius: 10, + borderWidth: 2, + borderColor: '#e5e7eb', + backgroundColor: '#fff', + padding: 12, + paddingTop: 36, + }, + cardSelected: { + borderColor: '#6366f1', + shadowColor: '#6366f1', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 2, + }, + deleteBtn: { + position: 'absolute', + top: 6, + right: 6, + padding: 6, + zIndex: 1, + }, + deleteBtnText: { fontSize: 14, color: '#9ca3af' }, + monoId: { + fontFamily: 'monospace', + fontSize: 11, + color: '#9ca3af', + }, + cardTitle: { + marginTop: 4, + fontSize: 13, + fontWeight: '600', + color: '#111827', + }, + cardMeta: { marginTop: 2, fontSize: 11, color: '#6b7280' }, +}); diff --git a/apps/mobile/src/components/dashboard/FeatureGateCard.tsx b/apps/mobile/src/components/dashboard/FeatureGateCard.tsx new file mode 100644 index 00000000..20de3cbc --- /dev/null +++ b/apps/mobile/src/components/dashboard/FeatureGateCard.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { usePlan } from '@beakerstack/billing'; +import { FeatureGate } from '@beakerstack/billing/native'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import { LucideLockIcon } from './LucideLockIcon'; + +export function FeatureGateCard() { + const { loading: planLoading } = usePlan(); + + return ( + + + Feature A (Pro+) + + {planLoading ? ( + + ) : ( + + feature='feature_a' + fallback={ + + + + + + + Feature A is locked on your current plan + + + + useFeature("feature_a").enabled + {' '} + returns false on your plan + — FeatureGate renders this + fallback instead of children. + + + + } + > + + + + Feature A is active + + + useFeature("feature_a").enabled + + {' → '} + true ·{' '} + FeatureGate renders children + + + + + )} + + ); +} + +const styles = StyleSheet.create({ + heading: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + marginBottom: 10, + }, + headingMuted: { fontWeight: '400', color: '#6b7280' }, + muted: { fontSize: 13, color: '#6b7280' }, + fallbackBox: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + borderRadius: 10, + borderWidth: 1, + borderColor: '#e9d5ff', + backgroundColor: '#faf5ff', + padding: 14, + }, + lockCircle: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#f3e8ff', + alignItems: 'center', + justifyContent: 'center', + }, + fallbackTextWrap: { flex: 1, minWidth: 0 }, + fallbackTitle: { + fontSize: 13, + fontWeight: '600', + color: '#111827', + }, + fallbackBody: { + marginTop: 4, + fontSize: 12, + color: '#4b5563', + lineHeight: 18, + }, + monoSm: { fontFamily: 'monospace', fontSize: 11, color: '#374151' }, + successBox: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + borderRadius: 10, + borderWidth: 1, + borderColor: '#bbf7d0', + backgroundColor: '#f0fdf4', + padding: 14, + }, + checkGlyph: { + fontSize: 18, + fontWeight: '700', + color: '#16a34a', + marginTop: -2, + }, + successTitle: { + fontSize: 13, + fontWeight: '600', + color: '#166534', + }, + successSub: { + marginTop: 4, + fontSize: 11, + color: '#15803d', + lineHeight: 16, + }, +}); diff --git a/apps/mobile/src/components/dashboard/LucideLockIcon.tsx b/apps/mobile/src/components/dashboard/LucideLockIcon.tsx new file mode 100644 index 00000000..8343fa59 --- /dev/null +++ b/apps/mobile/src/components/dashboard/LucideLockIcon.tsx @@ -0,0 +1,28 @@ +import React, { type ReactElement } from 'react'; +import Svg, { Path, Rect } from 'react-native-svg'; + +/** Lucide `Lock` (16px) — matches web FeatureGateCard fallback icon. */ +export function LucideLockIcon({ + size = 16, + color = '#9333ea', +}: { + size?: number; + color?: string; +}): ReactElement { + return ( + + + + + ); +} diff --git a/apps/mobile/src/components/dashboard/UsageStrip.tsx b/apps/mobile/src/components/dashboard/UsageStrip.tsx new file mode 100644 index 00000000..9c9cd400 --- /dev/null +++ b/apps/mobile/src/components/dashboard/UsageStrip.tsx @@ -0,0 +1,253 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { + mapUnknownError, + useBillingContext, + useUsage, +} from '@beakerstack/billing'; +import type { BillingError } from '@beakerstack/billing'; +import { supabase } from '../../lib/supabase'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { nextFakeAiSummary } from '../../lib/fakeAi'; +import { randomUuid } from '../../lib/randomUuid'; +import type { ActivityEntry } from './types'; + +function readDemoUseRealAi(): boolean { + return process.env?.['EXPO_PUBLIC_DEMO_USE_REAL_AI'] === 'true'; +} + +interface Props { + onActivity?: (entry: Omit) => void; +} + +export function UsageStrip({ onActivity }: Props) { + const { config } = useBillingContext(); + const { + used, + limit, + resetsAt, + exceeded, + loading, + error: usageError, + refresh, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + + const [results, setResults] = useState< + { id: string; text: string; at: number }[] + >([]); + const [pending, setPending] = useState(false); + const [recordError, setRecordError] = useState(null); + const [pendingKey, setPendingKey] = useState(null); + const simulateInFlightRef = useRef(false); + + const resolveSummaryText = useCallback(async (): Promise => { + if (readDemoUseRealAi()) { + try { + const { data, error: fnErr } = await supabase.functions.invoke( + 'demo-ai-summarize', + { + body: { + prompt: 'Write a short lorem-style summary (3-5 lines).', + }, + } + ); + if (!fnErr) { + const t = (data as { text?: string } | null)?.text; + if (typeof t === 'string' && t.trim()) return t.trim(); + } + } catch { + /* edge function unavailable — use fake fallback */ + } + } + return nextFakeAiSummary(); + }, []); + + const onSimulate = useCallback(async () => { + if (exceeded || pending || simulateInFlightRef.current) return; + simulateInFlightRef.current = true; + const key = pendingKey ?? randomUuid(); + if (!pendingKey) setPendingKey(key); + setPending(true); + setRecordError(null); + try { + const summaryText = await resolveSummaryText(); + const { error: rpcErr } = await supabase.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: BEAKERSTACK_METER_AI_SUMMARIZE, + p_quantity: 1, + p_metadata: {}, + p_idempotency_key: key, + } + ); + if (rpcErr) throw rpcErr; + await refresh(); + setResults(prev => + [ + { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + at: Date.now(), + text: summaryText, + }, + ...prev, + ].slice(0, 3) + ); + setPendingKey(null); + onActivity?.({ + label: 'AI summarize recorded', + rpc: 'billing_record_usage_event', + }); + } catch (e) { + setRecordError(mapUnknownError(e)); + } finally { + simulateInFlightRef.current = false; + setPending(false); + } + }, [ + exceeded, + pending, + pendingKey, + config.productId, + refresh, + resolveSummaryText, + onActivity, + ]); + + const lim = limit === null ? '∞' : String(limit); + const pct = + limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + const capLine = + limit === null + ? `${used} used this period · unlimited` + : `${used} of ${lim} used · resets ${ + resetsAt ? new Date(resetsAt).toLocaleDateString() : '—' + }`; + + const displayError = recordError ?? usageError; + + return ( + + + AI Summaries + {exceeded ? ( + + Limit reached + + ) : null} + + + {limit != null ? ( + + + + ) : null} + {loading ? '…' : capLine} + + + {exceeded ? ( + + Monthly limit reached for this meter. + + ) : ( + void onSimulate()} + > + + {pending ? '…' : 'Simulate AI summarize'} + + + )} + + + {displayError ? ( + + {displayError.message} + + ) : null} + + {results.length > 0 ? ( + + {results.map(r => ( + + + {new Date(r.at).toLocaleTimeString()} + + {r.text} + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + titleRow: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + marginBottom: 12, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + limitBadge: { + backgroundColor: '#fee2e2', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + }, + limitBadgeText: { + fontSize: 11, + fontWeight: '600', + color: '#b91c1c', + }, + barTrack: { + height: 8, + width: '100%', + borderRadius: 4, + backgroundColor: '#e5e7eb', + overflow: 'hidden', + }, + barFill: { + height: 8, + borderRadius: 4, + backgroundColor: '#4f46e5', + }, + capLine: { + marginTop: 6, + fontSize: 13, + color: '#6b7280', + }, + actions: { marginTop: 16, flexDirection: 'row', flexWrap: 'wrap', gap: 12 }, + btnPrimary: { + backgroundColor: '#4f46e5', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 8, + }, + btnPrimaryText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + btnDisabled: { opacity: 0.5 }, + limitNote: { fontSize: 14, color: '#6b7280' }, + err: { marginTop: 8, fontSize: 13, color: '#dc2626' }, + results: { marginTop: 16, gap: 8 }, + resultItem: { + borderRadius: 8, + backgroundColor: '#f9fafb', + padding: 12, + }, + resultTs: { fontSize: 11, color: '#9ca3af', marginBottom: 4 }, + resultBody: { fontSize: 13, color: '#374151' }, +}); diff --git a/apps/mobile/src/components/dashboard/index.ts b/apps/mobile/src/components/dashboard/index.ts new file mode 100644 index 00000000..9c7bea87 --- /dev/null +++ b/apps/mobile/src/components/dashboard/index.ts @@ -0,0 +1,8 @@ +export type { ActivityEntry } from './types'; +export { limLabel } from './utils'; +export { AnnotatedPrimitive } from './AnnotatedPrimitive'; +export { UsageStrip } from './UsageStrip'; +export { FeatureGateCard } from './FeatureGateCard'; +export { CollectionsGrid } from './CollectionsGrid'; +export { CollectionDetail } from './CollectionDetail'; +export { BooleanFeatureTiles } from './BooleanFeatureTiles'; diff --git a/apps/mobile/src/components/dashboard/types.ts b/apps/mobile/src/components/dashboard/types.ts new file mode 100644 index 00000000..93bab625 --- /dev/null +++ b/apps/mobile/src/components/dashboard/types.ts @@ -0,0 +1,6 @@ +export interface ActivityEntry { + id: string; + at: Date; + label: string; + rpc: string; +} diff --git a/apps/mobile/src/components/dashboard/utils.ts b/apps/mobile/src/components/dashboard/utils.ts new file mode 100644 index 00000000..534465cb --- /dev/null +++ b/apps/mobile/src/components/dashboard/utils.ts @@ -0,0 +1,5 @@ +export function limLabel(v: number | null): string { + if (v === null) return '…'; + if (v === -1) return '∞'; + return String(v); +} diff --git a/apps/mobile/src/lib/randomUuid.ts b/apps/mobile/src/lib/randomUuid.ts new file mode 100644 index 00000000..79bffe96 --- /dev/null +++ b/apps/mobile/src/lib/randomUuid.ts @@ -0,0 +1,26 @@ +import 'react-native-get-random-values'; + +/** + * UUID for idempotency keys and activity log ids. Uses `crypto.randomUUID` + * when available; RN often exposes `crypto` without `randomUUID`, so we fall + * back to a high-entropy string (getRandomValues is polyfilled by the import). + */ +export function randomUuid(): string { + const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined; + if (c && typeof c.randomUUID === 'function') { + return c.randomUUID(); + } + const bytes = new Uint8Array(16); + if (typeof c?.getRandomValues === 'function') { + c.getRandomValues(bytes); + } else { + for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256); + } + // RFC 4122 variant — good enough for client idempotency keys + const b6 = bytes[6] ?? 0; + const b8 = bytes[8] ?? 0; + bytes[6] = (b6 & 0x0f) | 0x40; + bytes[8] = (b8 & 0x3f) | 0x80; + const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx index 7986de43..69f35bd9 100644 --- a/apps/mobile/src/navigation/AppNavigator.tsx +++ b/apps/mobile/src/navigation/AppNavigator.tsx @@ -9,7 +9,7 @@ import LoginScreen from '../screens/LoginScreen'; import SignupScreen from '../screens/SignupScreen'; import DashboardScreen from '../screens/DashboardScreen'; import ProfileScreen from '../screens/ProfileScreen'; -import BillingScreen from '../screens/BillingScreen'; +import BillingNavigator from '../navigation/BillingNavigator'; import { useFeatureFlags } from '../config/featureFlags'; type RootStackParamList = { @@ -57,8 +57,8 @@ export const AppNavigator = () => { diff --git a/apps/mobile/src/navigation/BillingNavigator.tsx b/apps/mobile/src/navigation/BillingNavigator.tsx new file mode 100644 index 00000000..e5188571 --- /dev/null +++ b/apps/mobile/src/navigation/BillingNavigator.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { BillingOverviewScreen } from '../screens/billing/BillingOverviewScreen'; +import { BillingUsageScreen } from '../screens/billing/BillingUsageScreen'; + +export type BillingStackParamList = { + BillingOverview: undefined; + BillingUsage: undefined; +}; + +const Stack = createNativeStackNavigator(); + +export default function BillingNavigator(): React.ReactElement { + return ( + + + + + ); +} diff --git a/apps/mobile/src/screens/BillingScreen.tsx b/apps/mobile/src/screens/BillingScreen.tsx deleted file mode 100644 index 7cda1a68..00000000 --- a/apps/mobile/src/screens/BillingScreen.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { - BillingProvider, - useRecordUsage, - useUsage, -} from '@beakerstack/billing'; -import { - CustomerPortalLink, - FeatureGate, - PricingTable, - SubscriptionStatus, - UpgradePrompt, - UsageIndicator, -} from '@beakerstack/billing/native'; -import { useNavigation } from '@react-navigation/native'; -import React, { useState, type ReactElement } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { - beakerstackBillingConfig, - BEAKERSTACK_METER_AI_SUMMARIZE, -} from '../billing/beakerstackBillingConfig'; -import { supabase } from '../lib/supabase'; - -function readBillingScreenDemoMode(): boolean { - return process.env?.EXPO_PUBLIC_BILLING_DEMO_MODE === 'true'; -} - -/** Set EXPO_PUBLIC_BILLING_DEMO_BASE_URL to your dev machine URL for Stripe return URLs on device. */ -const billingBaseUrl = - (typeof process !== 'undefined' && - process.env?.EXPO_PUBLIC_BILLING_DEMO_BASE_URL) || - 'http://127.0.0.1:8081'; - -function MeteredBlock(): ReactElement { - const { exceeded, refresh } = useUsage< - typeof beakerstackBillingConfig, - typeof BEAKERSTACK_METER_AI_SUMMARIZE - >(BEAKERSTACK_METER_AI_SUMMARIZE); - const { record, pending } = useRecordUsage< - typeof beakerstackBillingConfig, - typeof BEAKERSTACK_METER_AI_SUMMARIZE - >(BEAKERSTACK_METER_AI_SUMMARIZE); - return ( - - AI summarize (metered) - - meter={BEAKERSTACK_METER_AI_SUMMARIZE} - variant='text' - /> - {exceeded ? ( - - targetTier='beakerstack_pro' - reason='Monthly limit reached.' - /> - ) : ( - void record(1)} - > - - {pending ? '…' : 'Use one summarize'} - - - )} - void refresh()}> - Refresh usage - - - ); -} - -function DemoRpcButtons(): ReactElement { - const [msg, setMsg] = useState(null); - if (!readBillingScreenDemoMode()) { - return ( - - Set EXPO_PUBLIC_BILLING_DEMO_MODE=true and DB demo_billing_mode for - simulate controls. - - ); - } - const sim = async (planId: string) => { - const { error } = await supabase.rpc('billing_demo_simulate_upgrade', { - p_product_id: 'beakerstack', - p_plan_id: planId, - }); - setMsg(error ? error.message : `Simulated ${planId}`); - }; - return ( - - Demo mode — not real billing - void sim('beakerstack_pro')} - > - Simulate Pro - - {msg ? {msg} : null} - - ); -} - -export default function BillingScreen(): ReactElement { - const navigation = useNavigation(); - return ( - - - navigation.goBack()} - style={{ marginBottom: 8 }} - > - ← Back - - Billing - - For the full /billing experience (tabs, plans, invoices), use the web - app. This screen reuses the billing package for dev testing. - - - supabase={supabase} - config={beakerstackBillingConfig} - checkoutSuccessUrl={`${billingBaseUrl}/billing`} - checkoutCancelUrl={`${billingBaseUrl}/billing`} - portalReturnUrl={`${billingBaseUrl}/billing`} - > - - - Subscription - /> - - style={{ marginTop: 8 }} - > - Customer portal - - highlightCurrent /> - - - - Feature B (Max) - - feature='feature_b' - fallback={ - - targetTier='beakerstack_max' - reason='Feature B requires Max.' - /> - } - > - Feature B enabled - - - - - - ); -} - -const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#f9fafb' }, - scroll: { padding: 16, paddingBottom: 32 }, - h1: { fontSize: 22, fontWeight: '700', marginBottom: 8 }, - subtitle: { - fontSize: 13, - color: '#6b7280', - marginBottom: 12, - lineHeight: 18, - }, - h2: { fontSize: 18, fontWeight: '600', marginBottom: 8 }, - section: { - marginBottom: 16, - padding: 12, - backgroundColor: '#fff', - borderRadius: 8, - borderWidth: 1, - borderColor: '#e5e7eb', - }, - btn: { - backgroundColor: '#4f46e5', - padding: 10, - borderRadius: 6, - marginTop: 8, - alignSelf: 'flex-start', - }, - btnText: { color: '#fff' }, - link: { color: '#4f46e5', marginTop: 8 }, - muted: { color: '#6b7280', fontSize: 12, marginTop: 6 }, - ok: { color: '#15803d' }, - demoBox: { - backgroundColor: '#fef3c7', - padding: 8, - borderRadius: 6, - marginBottom: 12, - }, - warn: { fontWeight: '600', marginBottom: 6 }, - smallBtn: { - alignSelf: 'flex-start', - padding: 6, - borderWidth: 1, - borderColor: '#d97706', - borderRadius: 4, - }, -}); diff --git a/apps/mobile/src/screens/DashboardScreen.tsx b/apps/mobile/src/screens/DashboardScreen.tsx index 60b196f3..af849d8c 100644 --- a/apps/mobile/src/screens/DashboardScreen.tsx +++ b/apps/mobile/src/screens/DashboardScreen.tsx @@ -14,16 +14,7 @@ import { Pressable, } from 'react-native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { - BillingProvider, - mapUnknownError, - useBillingContext, - useFeature, - usePlan, - useUsage, -} from '@beakerstack/billing'; -import type { BillingError } from '@beakerstack/billing'; -import { FeatureGate } from '@beakerstack/billing/native'; +import { useBillingContext, usePlan, useUsage } from '@beakerstack/billing'; import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.native'; import { supabase } from '../lib/supabase'; @@ -32,22 +23,19 @@ import { BEAKERSTACK_METER_AI_SUMMARIZE, } from '../billing/beakerstackBillingConfig'; import { useDemoCollections } from '../billing/useDemoCollections'; -import { nextFakeAiSummary } from '../lib/fakeAi'; +import { + AnnotatedPrimitive, + BooleanFeatureTiles, + CollectionDetail, + CollectionsGrid, + FeatureGateCard, + UsageStrip, +} from '../components/dashboard'; -// Read at call time (not module init) so tests and dev toggles can change process.env without reload. function readBillingDashboardDemoMode(): boolean { return process.env?.['EXPO_PUBLIC_BILLING_DEMO_MODE'] === 'true'; } -function readDemoUseRealAi(): boolean { - return process.env?.['EXPO_PUBLIC_DEMO_USE_REAL_AI'] === 'true'; -} - -const billingBaseUrl = - (typeof process !== 'undefined' && - process.env?.['EXPO_PUBLIC_BILLING_DEMO_BASE_URL']) || - 'http://127.0.0.1:8081'; - type RootStackParamList = { Home: undefined; Login: undefined; @@ -66,356 +54,6 @@ interface Props { navigation: DashboardScreenNavigationProp; } -type SummaryEntry = { id: string; at: number; text: string }; - -function limLabel(v: number | null): string { - if (v === null) return '…'; - if (v === -1) return '∞'; - return String(v); -} - -function SectionCard(props: { - title: string; - demonstrates: string; - description: string; - codeRef: string; - demoMode?: boolean; - children: React.ReactNode; -}): ReactElement { - return ( - - {props.demoMode && ( - - Demo mode only - - )} - {props.title} - - DEMONSTRATES:{' '} - {props.demonstrates} - - {props.description} - {props.children} - // {props.codeRef} - - ); -} - -function MeteredBlock(): ReactElement { - const { config } = useBillingContext(); - const { - used, - limit, - resetsAt, - exceeded, - loading, - error: usageError, - refresh, - } = useUsage< - typeof beakerstackBillingConfig, - typeof BEAKERSTACK_METER_AI_SUMMARIZE - >(BEAKERSTACK_METER_AI_SUMMARIZE); - const [results, setResults] = useState([]); - const [pending, setPending] = useState(false); - const [recordError, setRecordError] = useState(null); - - const resolveSummaryText = useCallback(async (): Promise => { - if (readDemoUseRealAi()) { - try { - const { data, error: fnErr } = await supabase.functions.invoke( - 'demo-ai-summarize', - { - body: { - prompt: 'Write a short lorem-style summary (3-5 lines).', - }, - } - ); - if (!fnErr) { - const t = (data as { text?: string } | null)?.text; - if (typeof t === 'string' && t.trim()) return t.trim(); - } - } catch { - /* optional demo-ai edge unavailable */ - } - } - return nextFakeAiSummary(); - }, []); - - const pushResult = useCallback((text: string) => { - const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - setResults(prev => { - const next: SummaryEntry[] = [{ id, at: Date.now(), text }, ...prev]; - return next.slice(0, 3); - }); - }, []); - - const onSimulate = useCallback(async () => { - if (exceeded || pending) return; - const summaryText = await resolveSummaryText(); - setPending(true); - setRecordError(null); - try { - const { error: rpcErr } = await supabase.rpc( - 'billing_record_usage_event', - { - p_product_id: config.productId, - p_event_type: BEAKERSTACK_METER_AI_SUMMARIZE, - p_quantity: 1, - p_metadata: {}, - } - ); - if (rpcErr) throw rpcErr; - await refresh(); - pushResult(summaryText); - } catch (e) { - setRecordError(mapUnknownError(e)); - } finally { - setPending(false); - } - }, [ - exceeded, - pending, - config.productId, - refresh, - resolveSummaryText, - pushResult, - ]); - - const lim = limit === null ? '∞' : String(limit); - const capLine = - limit === null - ? `${used} used this period · unlimited` - : `${used} of ${lim} used · resets ${ - resetsAt ? new Date(resetsAt).toLocaleDateString() : '—' - }`; - const pct = - limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; - const displayError = recordError ?? usageError; - - return ( - - - {limit != null && ( - - - - )} - {loading ? '…' : capLine} - - - {exceeded ? ( - - Limit reached — open Billing for plans. - - ) : ( - void onSimulate()} - > - - {pending ? '…' : 'Simulate AI summarize'} - - - )} - - {displayError ? ( - {displayError.message} - ) : null} - - {results.length === 0 ? ( - - Tap 'Simulate AI summarize' to generate a result. - - ) : ( - results.map(e => ( - - - {new Date(e.at).toLocaleString()} - - {e.text} - - )) - )} - - - ); -} - -function NumericCapsBlock(): ReactElement { - const { - collections, - loading, - error, - addCollection, - deleteCollection, - addItem, - } = useDemoCollections(); - const { value: maxCollectionsRaw, loading: l1 } = useFeature< - typeof beakerstackBillingConfig, - 'containers_per_account_max' - >('containers_per_account_max'); - const { value: maxItemsRaw, loading: l2 } = useFeature< - typeof beakerstackBillingConfig, - 'items_per_container_max' - >('items_per_container_max'); - const maxCollections = - typeof maxCollectionsRaw === 'number' ? maxCollectionsRaw : null; - const maxItemsPer = typeof maxItemsRaw === 'number' ? maxItemsRaw : null; - const count = collections.length; - const atCollectionCap = - maxCollections !== null && maxCollections !== -1 && count >= maxCollections; - const [busy, setBusy] = useState(null); - const [actionErr, setActionErr] = useState(null); - const wrap = useCallback(async (key: string, fn: () => Promise) => { - setActionErr(null); - setBusy(key); - try { - await fn(); - } catch (e) { - setActionErr(e instanceof Error ? e.message : 'Failed'); - } finally { - setBusy(null); - } - }, []); - const featLoading = l1 || l2; - - return ( - - {error ? ( - - {error} (needs demo_billing_mode in DB) - - ) : null} - - - Collections:{' '} - {loading || featLoading - ? '…' - : `${count} of ${limLabel(maxCollections)}`} - - void wrap('add', addCollection)} - > - - {busy === 'add' - ? '…' - : atCollectionCap - ? 'Limit reached' - : 'Add collection'} - - - - {actionErr ? {actionErr} : null} - {!loading && collections.length === 0 ? ( - - No collections yet. Tap Add collection to start. - - ) : ( - collections.map(row => { - const itemCap = - maxItemsPer !== null && - maxItemsPer !== -1 && - row.item_count >= maxItemsPer; - return ( - - {row.id.slice(0, 8)}… - - Items: {row.item_count} of {limLabel(maxItemsPer)} - - - - void wrap(`i-${row.id}`, () => addItem(row.id)) - } - > - - {itemCap - ? 'Limit' - : busy === `i-${row.id}` - ? '…' - : 'Add item'} - - - - void wrap(`d-${row.id}`, () => deleteCollection(row.id)) - } - > - Delete - - - - ); - }) - )} - - ); -} - -function BooleanGatesBlock(): ReactElement { - const { loading: planLoading } = usePlan(); - const a = useFeature( - 'feature_a' - ); - const b = useFeature( - 'feature_b' - ); - return ( - - Feature A (requires Pro) - {planLoading ? ( - - ) : ( - - feature='feature_a' - fallback={ - - Feature A requires Pro. See Billing for plans. - - } - > - - ✓ Feature A is enabled for your plan. - - - )} - - Feature B (requires Max) - - {planLoading ? ( - - ) : ( - - feature='feature_b' - fallback={ - - Feature B requires Max. See Billing for plans. - - } - > - - ✓ Feature B is enabled for your plan. - - - )} - - useFeature("feature_a") →{' '} - {a.loading ? '…' : String(a.enabled)} · feature_b →{' '} - {b.loading ? '…' : String(b.enabled)} - - - ); -} - const PLANS = [ { id: 'beakerstack_free' as const, label: 'Free' }, { id: 'beakerstack_pro' as const, label: 'Pro' }, @@ -425,7 +63,7 @@ const PLANS = [ const METERS = [BEAKERSTACK_METER_AI_SUMMARIZE] as const; function DemoControlsBlock(): ReactElement | null { - if (!readBillingDashboardDemoMode()) return null; + const demoMode = readBillingDashboardDemoMode(); const { data: plan, loading: planLoading } = usePlan(); const { refreshSubscription } = @@ -450,131 +88,180 @@ function DemoControlsBlock(): ReactElement | null { } }, []); + if (!demoMode) return null; + return ( - - - Current plan: {planLoading ? '…' : (plan?.display_name ?? '—')} + + + Demo mode only + + Demo controls + + DEMONSTRATES:{' '} + + billing_demo_simulate_upgrade, billing_demo_reset_usage + - - {PLANS.map(p => ( - - void run(`p-${p.id}`, async () => { + + App-layer only. Not part of @beakerstack/billing. Remove in production. + + + + Current plan: {planLoading ? '…' : (plan?.display_name ?? '—')} + + + {PLANS.map(p => ( + + void run(`p-${p.id}`, async () => { + const { error: e } = await supabase.rpc( + 'billing_demo_simulate_upgrade', + { + p_product_id: beakerstackBillingConfig.productId, + p_plan_id: p.id, + } + ); + if (e) throw e; + await refreshSubscription(); + }) + } + > + + {pending === `p-${p.id}` ? '…' : `To ${p.label}`} + + + ))} + + + void run('reset', async () => { + for (const m of METERS) { const { error: e } = await supabase.rpc( - 'billing_demo_simulate_upgrade', + 'billing_demo_reset_usage', { p_product_id: beakerstackBillingConfig.productId, - p_plan_id: p.id, + p_event_type: m, } ); if (e) throw e; - await refreshSubscription(); - }) - } - > - - {pending === `p-${p.id}` ? '…' : `To ${p.label}`} - - - ))} - - - void run('reset', async () => { - for (const m of METERS) { - const { error: e } = await supabase.rpc( - 'billing_demo_reset_usage', - { - p_product_id: beakerstackBillingConfig.productId, - p_event_type: m, - } - ); - if (e) throw e; - } - await refreshUsage(); - }) - } - > - - {pending === 'reset' ? '…' : 'Reset all usage counters'} + } + await refreshUsage(); + }) + } + > + + {pending === 'reset' ? '…' : 'Reset all usage counters'} + + + {msg ? {msg} : null} + + These actions bypass Stripe. Do not deploy to production. - - {msg ? {msg} : null} - - These actions bypass Stripe. Do not deploy to production. + + + // supabase.rpc("billing_demo_simulate_upgrade", …) - + ); } -function DashboardBody({ - navigation, -}: { - navigation: DashboardScreenNavigationProp; -}): ReactElement { +function DashboardBody(): ReactElement { + const [selectedCollectionId, setSelectedCollectionId] = useState< + string | null + >(null); + + const { + collections, + loading: collectionsLoading, + error: collectionsError, + addCollection, + deleteCollection, + addItem, + } = useDemoCollections(); + + useEffect(() => { + if (collections.length === 0) { + setSelectedCollectionId(null); + return; + } + if (selectedCollectionId === null) { + setSelectedCollectionId(collections[0].id); + return; + } + if (!collections.find(c => c.id === selectedCollectionId)) { + setSelectedCollectionId(collections[0].id); + } + }, [collections, selectedCollectionId]); + + const selectedCollection = collections.find( + c => c.id === selectedCollectionId + ); + return ( - Welcome to BeakerStack - - This dashboard is a sandbox for @beakerstack/billing. For polished - billing UI, use the web app. - - navigation.navigate('Billing')} - style={styles.navLink} + - View polished billing → - + + - - - - - + + - + + + + - - + + - - - + + @@ -612,15 +299,7 @@ export default function DashboardScreen({ navigation }: Props) { return ( - - supabase={supabase} - config={beakerstackBillingConfig} - checkoutSuccessUrl={`${billingBaseUrl}/billing`} - checkoutCancelUrl={`${billingBaseUrl}/billing/plans?checkout=cancel`} - portalReturnUrl={`${billingBaseUrl}/billing`} - > - - + ); } @@ -628,23 +307,32 @@ export default function DashboardScreen({ navigation }: Props) { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f9fafb' }, scrollView: { flex: 1 }, - content: { padding: 20, paddingBottom: 40 }, - h1: { fontSize: 22, fontWeight: '700', color: '#111827' }, - lede: { marginTop: 8, fontSize: 14, color: '#4b5563', lineHeight: 20 }, - navLink: { marginTop: 12, alignSelf: 'flex-start' }, - navLinkText: { color: '#4f46e5', fontWeight: '600' }, - spacer: { height: 20 }, - card: { + scrollContent: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 24, + maxWidth: 1024, + width: '100%', + alignSelf: 'center', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F9FAFB', + }, + loadingText: { marginTop: 16, fontSize: 16, color: '#6B7280' }, + demoCard: { backgroundColor: '#fff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 16, - marginBottom: 20, + marginBottom: 8, }, - cardDemo: { borderStyle: 'dashed' as const, borderColor: '#d1d5db' }, + cardDemo: { borderStyle: 'dashed', borderColor: '#d1d5db' }, badge: { - position: 'absolute' as const, + position: 'absolute', right: 12, top: 10, backgroundColor: '#fef3c7', @@ -653,7 +341,7 @@ const styles = StyleSheet.create({ borderRadius: 4, }, badgeText: { fontSize: 10, fontWeight: '600', color: '#92400e' }, - cardTitle: { + demoTitle: { fontSize: 18, fontWeight: '600', color: '#111827', @@ -664,57 +352,14 @@ const styles = StyleSheet.create({ fontSize: 10, fontWeight: '600', color: '#6b7280', - textTransform: 'uppercase' as const, + textTransform: 'uppercase', letterSpacing: 0.5, }, demonstratesMono: { fontFamily: 'monospace', fontSize: 12, color: '#374151' }, - cardDesc: { marginTop: 6, fontSize: 14, color: '#4b5563' }, - cardBody: { marginTop: 12 }, - codeLine: { - marginTop: 12, - fontSize: 11, - fontFamily: 'monospace', - color: '#6b7280', - }, - usageBarTrack: { - height: 8, - backgroundColor: '#e5e7eb', - borderRadius: 4, - overflow: 'hidden', - }, - usageBarFill: { - height: 8, - backgroundColor: '#4f46e5', - borderRadius: 4, - }, - usageCapLine: { - marginTop: 8, - fontSize: 13, - color: '#4b5563', - }, - row: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: 8, - marginTop: 8, - }, - rowBetween: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - marginTop: 4, - }, - btnPrimary: { - backgroundColor: '#4f46e5', - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 8, - }, - btnPrimaryText: { color: '#fff', fontWeight: '600' }, - btnDisabled: { opacity: 0.5 }, + demoDesc: { marginTop: 6, fontSize: 14, color: '#4b5563' }, + demoBody: { marginTop: 12 }, + bodyText: { fontSize: 14, color: '#374151' }, + btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, btnSecondary: { borderWidth: 1, borderColor: '#d1d5db', @@ -724,45 +369,13 @@ const styles = StyleSheet.create({ backgroundColor: '#fff', }, btnSecondaryText: { color: '#374151', fontSize: 12, fontWeight: '500' }, - btnDanger: { marginLeft: 8, padding: 6 }, - btnDangerText: { color: '#b91c1c', fontSize: 12, fontWeight: '600' }, - btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, + btnDisabled: { opacity: 0.5 }, muted: { color: '#6b7280', fontSize: 13, marginTop: 4 }, - errText: { color: '#b91c1c', fontSize: 13, marginTop: 4 }, - warnText: { color: '#b45309', fontSize: 12, marginBottom: 6 }, - bodyText: { fontSize: 14, color: '#374151' }, - subH: { fontSize: 14, fontWeight: '600', color: '#111827' }, - okText: { fontSize: 14, color: '#15803d' }, - resultBox: { - marginTop: 12, - borderWidth: 1, - borderColor: '#e5e7eb', - backgroundColor: '#f9fafb', - borderRadius: 8, - padding: 10, - }, - resultItem: { - marginBottom: 10, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - }, - resultTs: { fontSize: 11, color: '#6b7280' }, - resultBody: { fontSize: 13, color: '#1f2937', marginTop: 2 }, - collectionCard: { - marginTop: 10, - padding: 10, - backgroundColor: '#f9fafb', - borderRadius: 8, - borderWidth: 1, - borderColor: '#e5e7eb', - }, - monoSm: { fontFamily: 'monospace', fontSize: 11, color: '#6b7280' }, tinyLegal: { fontSize: 10, color: '#6b7280', marginTop: 8 }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F9FAFB', + codeLine: { + marginTop: 12, + fontSize: 11, + fontFamily: 'monospace', + color: '#6b7280', }, - loadingText: { marginTop: 16, fontSize: 16, color: '#6B7280' }, }); diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 7bb0601b..5d25087c 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -11,7 +11,6 @@ import { HOME_TITLE, HOME_SUBTITLE } from '@beakerstack/shared/utils/strings'; import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.native'; import { supabase } from '../lib/supabase'; -import { DebugTools } from '../components/DebugTools'; type RootStackParamList = { Home: undefined; @@ -87,8 +86,6 @@ export default function HomeScreen({ navigation }: Props) { )} - - ); } diff --git a/apps/mobile/src/screens/billing/BillingLayout.tsx b/apps/mobile/src/screens/billing/BillingLayout.tsx new file mode 100644 index 00000000..5d3ac89b --- /dev/null +++ b/apps/mobile/src/screens/billing/BillingLayout.tsx @@ -0,0 +1,37 @@ +import React, { type ReactNode } from 'react'; +import { Pressable, ScrollView, Text } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { BillingTabBar } from './BillingTabBar'; +import { billingStyles } from './styles'; + +export function BillingLayout({ + children, +}: { + children: ReactNode; +}): React.ReactElement { + const navigation = useNavigation(); + return ( + + + { + const parent = navigation.getParent(); + if (parent?.canGoBack()) { + parent.goBack(); + } else { + navigation.goBack(); + } + }} + > + ← Back + + Billing + + {children} + + + ); +} diff --git a/apps/mobile/src/screens/billing/BillingOverviewScreen.tsx b/apps/mobile/src/screens/billing/BillingOverviewScreen.tsx new file mode 100644 index 00000000..597458d4 --- /dev/null +++ b/apps/mobile/src/screens/billing/BillingOverviewScreen.tsx @@ -0,0 +1,210 @@ +import React, { type ReactElement } from 'react'; +import { Text, View, ActivityIndicator } from 'react-native'; +import { + usePlan, + usePlanCatalog, + useUsage, + useBillingState, + type SubscriptionRow, + type BillingUiStateKind, +} from '@beakerstack/billing'; +import { formatMonthYear } from '@beakerstack/billing/presentation'; +import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; +import { BRANDING } from '@beakerstack/shared/config/branding'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { numericPlanFeature } from '../../billing/planFeatureValue'; +import { useDemoCollectionCount } from '../../billing/useDemoCollectionCount'; +import { BillingLayout } from './BillingLayout'; +import { billingColors, billingStyles } from './styles'; + +function Banner({ + variant, + title, + body, +}: { + variant: 'info' | 'warning' | 'error'; + title: string; + body: string; +}): ReactElement { + const palette = + variant === 'error' + ? { + bg: billingColors.errorBg, + border: billingColors.errorBorder, + text: billingColors.errorText, + } + : variant === 'warning' + ? { + bg: billingColors.warnBg, + border: billingColors.warnBorder, + text: billingColors.warnText, + } + : { + bg: billingColors.infoBg, + border: billingColors.infoBorder, + text: billingColors.infoText, + }; + return ( + + + {title} + + + {body} + + + ); +} + +function OverviewBanners({ + kind, + subscription, + pendingTargetName, +}: { + kind: BillingUiStateKind; + subscription: SubscriptionRow | null; + pendingTargetName?: string; +}): ReactElement | null { + if (kind === 'loading' || kind === 'no_subscription') return null; + if (kind === 'payment_failed') { + return ( + + ); + } + if (kind === 'downgrade_pending' && subscription?.current_period_end) { + const target = pendingTargetName ?? 'your next plan'; + return ( + + ); + } + if (kind === 'cancelled_pending' && subscription?.current_period_end) { + return ( + + ); + } + return null; +} + +function StatTile({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function formatCollectionsStat( + count: number, + cap: number, + loading: boolean, + error: Error | null +): string { + if (loading) return '—'; + if (error) return 'Unable to load'; + if (cap === -1) return `${count} of unlimited`; + return `${count} of ${cap}`; +} + +export function BillingOverviewScreen(): ReactElement { + const { kind, subscription } = + useBillingState(); + const { plans: catalogPlans } = + usePlanCatalog(); + const { data: currentPlan, loading: planLoad } = + usePlan(); + const { + used, + limit, + loading: usageLoad, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const { + count: colCount, + loading: colLoad, + error: colError, + } = useDemoCollectionCount(); + const { user } = useAuthContext(); + + const pendingTargetName = + subscription?.pending_target_plan_id != null + ? catalogPlans.find(p => p.id === subscription.pending_target_plan_id) + ?.display_name + : undefined; + + const containersCap = currentPlan + ? numericPlanFeature( + currentPlan.features as Record, + 'containers_per_account_max' + ) + : -1; + + return ( + + + {planLoad && kind === 'loading' ? ( + + ) : currentPlan ? ( + + + You're on the {currentPlan.display_name} plan + + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/src/screens/billing/BillingTabBar.tsx b/apps/mobile/src/screens/billing/BillingTabBar.tsx new file mode 100644 index 00000000..20730b3f --- /dev/null +++ b/apps/mobile/src/screens/billing/BillingTabBar.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { + Pressable, + ScrollView, + Text, + View, + StyleSheet, + useWindowDimensions, +} from 'react-native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import type { BillingStackParamList } from '../../navigation/BillingNavigator'; +import { billingColors } from './styles'; + +const TABS: { name: keyof BillingStackParamList; label: string }[] = [ + { name: 'BillingOverview', label: 'Overview' }, + { name: 'BillingUsage', label: 'Usage' }, +]; + +export function BillingTabBar(): React.ReactElement { + const navigation = + useNavigation>(); + const route = useRoute(); + const { width } = useWindowDimensions(); + const active = route.name as keyof BillingStackParamList; + + return ( + + + {TABS.map(t => { + const isActive = active === t.name; + return ( + navigation.navigate(t.name)} + style={[styles.tab, isActive && styles.tabActive]} + > + + {t.label} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + borderBottomWidth: 1, + borderBottomColor: billingColors.border, + marginBottom: 8, + }, + scrollInner: { + flexDirection: 'row', + alignItems: 'stretch', + paddingHorizontal: 0, + }, + tab: { + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + tabActive: { + borderBottomColor: billingColors.indigo, + }, + tabText: { + fontSize: 14, + fontWeight: '500', + color: billingColors.textMuted, + }, + tabTextActive: { + color: billingColors.indigo, + }, +}); diff --git a/apps/mobile/src/screens/billing/BillingUsageScreen.tsx b/apps/mobile/src/screens/billing/BillingUsageScreen.tsx new file mode 100644 index 00000000..697098e8 --- /dev/null +++ b/apps/mobile/src/screens/billing/BillingUsageScreen.tsx @@ -0,0 +1,183 @@ +import React, { type ReactElement } from 'react'; +import { Text, View } from 'react-native'; +import type { Plan } from '@beakerstack/billing'; +import { + UsageIndicator, + FeatureCheckIcon, + FeatureXIcon, +} from '@beakerstack/billing/native'; +import { + usePlan, + useBillingState, + useBillingConfig, +} from '@beakerstack/billing'; +import { + mergePlanFeatureRows, + mergeUsageLimitsCopy, + mergeUsageMeterCopy, + planFeatureLine, +} from '@beakerstack/billing/presentation'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { numericPlanFeature } from '../../billing/planFeatureValue'; +import { useDemoCollectionCount } from '../../billing/useDemoCollectionCount'; +import { BillingLayout } from './BillingLayout'; +import { billingColors, billingStyles } from './styles'; + +function FeatureLimitRow({ + name, + used, + cap, + capIsUnlimited, +}: { + name: string; + used: number; + cap: number; + capIsUnlimited: boolean; +}) { + const u = used ?? 0; + if (capIsUnlimited) { + return ( + + {name} + {u} of unlimited + + ); + } + const ratio = cap > 0 ? u / cap : 0; + const heavy = ratio >= 0.8 && u < cap; + const at = u >= cap; + const rightColor = at + ? billingColors.errorText + : heavy + ? billingColors.warnText + : billingColors.textMuted; + return ( + + {name} + + {u} of {cap} + + + ); +} + +function BooleanPlanFeatures({ plan }: { plan: Plan }): ReactElement { + const billingConfig = useBillingConfig(); + const rows = mergePlanFeatureRows(billingConfig).filter( + row => row.kind === 'boolean' + ); + + return ( + <> + {rows.map(row => { + const { ok, text } = planFeatureLine(plan, row); + return ( + + + {ok ? : } + {text} + + + {ok ? 'Available' : 'Not available'} + + + ); + })} + + ); +} + +export function BillingUsageScreen(): ReactElement { + const billingConfig = useBillingConfig(); + const meterCopy = mergeUsageMeterCopy(billingConfig); + const limitsCopy = mergeUsageLimitsCopy(billingConfig); + const { data: plan } = usePlan(); + const { kind, subscription } = + useBillingState(); + const { + count: colCount = 0, + maxItemsInAnyCollection = 0, + error: colError, + } = useDemoCollectionCount(); + + if (!plan) { + return ( + + Loading plan… + + ); + } + + const features = plan.features as Record; + const containers = numericPlanFeature(features, 'containers_per_account_max'); + const itemsCap = numericPlanFeature(features, 'items_per_container_max'); + + return ( + + {kind === 'payment_failed' ? ( + + + Payment failed. Limits may change if your plan lapses. + + + ) : null} + {colError ? ( + + + Could not load demo collection counts. + + + ) : null} + + + {subscription?.status === 'free' || + !subscription?.stripe_subscription_id + ? 'Your usage resets at the start of the next calendar month (free tier).' + : 'Your usage resets on your next billing date (see Usage below for the exact reset date for meters).'} + + + + Usage + {Object.keys(plan.usage_limits).map(m => ( + + + meter={m as typeof BEAKERSTACK_METER_AI_SUMMARIZE} + variant='expanded' + label={meterCopy[m]?.label ?? m} + description={meterCopy[m]?.description} + /> + + ))} + + + Limits + + + + + {limitsCopy.collectionsFootnote} + + + + + Plan features + + + + + + ); +} diff --git a/apps/mobile/src/screens/billing/styles.ts b/apps/mobile/src/screens/billing/styles.ts new file mode 100644 index 00000000..43b42b1b --- /dev/null +++ b/apps/mobile/src/screens/billing/styles.ts @@ -0,0 +1,113 @@ +import { StyleSheet } from 'react-native'; + +export const billingColors = { + pageBg: '#f9fafb', + cardBg: '#ffffff', + border: '#e5e7eb', + textPrimary: '#111827', + textMuted: '#6b7280', + indigo: '#4f46e5', + indigoDark: '#4338ca', + errorBg: '#fef2f2', + errorBorder: '#fecaca', + errorText: '#991b1b', + infoBg: '#eff6ff', + infoBorder: '#bfdbfe', + infoText: '#1e3a8a', + warnBg: '#fffbeb', + warnBorder: '#fde68a', + warnText: '#92400e', +}; + +export const billingStyles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: billingColors.pageBg }, + scrollContent: { padding: 16, paddingBottom: 40 }, + h1: { + fontSize: 22, + fontWeight: '700', + color: billingColors.textPrimary, + marginBottom: 4, + }, + backLink: { color: billingColors.indigo, fontSize: 15, marginBottom: 8 }, + card: { + backgroundColor: billingColors.cardBg, + borderRadius: 12, + borderWidth: 1, + borderColor: billingColors.border, + padding: 16, + marginBottom: 16, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: billingColors.textPrimary, + marginBottom: 8, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: billingColors.textPrimary, + marginBottom: 8, + }, + body: { fontSize: 14, color: billingColors.textMuted, lineHeight: 20 }, + small: { fontSize: 12, color: billingColors.textMuted, lineHeight: 18 }, + primaryBtn: { + backgroundColor: billingColors.indigo, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 8, + }, + primaryBtnText: { color: '#fff', fontWeight: '600', fontSize: 15 }, + secondaryBtn: { + borderWidth: 1, + borderColor: billingColors.border, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 8, + }, + secondaryBtnText: { color: billingColors.textPrimary, fontWeight: '600' }, + link: { color: billingColors.indigo, fontSize: 14, marginTop: 8 }, + rowBetween: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#f3f4f6', + }, + banner: { + borderRadius: 8, + borderWidth: 1, + padding: 12, + marginBottom: 12, + }, + bannerTitle: { fontWeight: '600', fontSize: 14 }, + bannerBody: { marginTop: 4, fontSize: 14 }, + statTileCard: { marginBottom: 12 }, + statValue: { + marginTop: 6, + fontSize: 15, + fontWeight: '600', + color: billingColors.textPrimary, + }, + paymentFailedCard: { + backgroundColor: billingColors.errorBg, + borderColor: billingColors.errorBorder, + }, + sectionBlock: { marginBottom: 8 }, + meterBlock: { marginTop: 12 }, + booleanFeatureLabel: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: 8, + }, + booleanFeatureName: { + marginLeft: 8, + color: billingColors.textPrimary, + }, +}); diff --git a/apps/mobile/supabase/seed.sql b/apps/mobile/supabase/seed.sql index f3db6581..804b853e 100644 --- a/apps/mobile/supabase/seed.sql +++ b/apps/mobile/supabase/seed.sql @@ -3,10 +3,12 @@ INSERT INTO public.billing_products (id, display_name, description) VALUES ( 'beakerstack', - 'BeakerStack', + 'Beaker Stack', 'Template billing demo product' ) -ON CONFLICT (id) DO NOTHING; +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + description = EXCLUDED.description; INSERT INTO public.billing_plans ( id, product_id, display_name, description, price_cents, billing_period, diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 8ee1956e..7ab9e2c4 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -11,7 +11,10 @@ "@beakerstack/shared/*": ["../../packages/shared/src/*"], "@beakerstack/billing": ["../../packages/billing/src/index.ts"], "@beakerstack/billing/web": ["../../packages/billing/src/web.ts"], - "@beakerstack/billing/native": ["../../packages/billing/src/native.ts"] + "@beakerstack/billing/native": ["../../packages/billing/src/native.ts"], + "@beakerstack/billing/presentation": [ + "../../packages/billing/src/presentation/index.ts" + ] } }, "include": [ diff --git a/apps/web/docs/billing-demo.md b/apps/web/docs/billing-demo.md deleted file mode 100644 index 9ce8f741..00000000 --- a/apps/web/docs/billing-demo.md +++ /dev/null @@ -1,22 +0,0 @@ -# Billing demo (deprecated) - -> **Replaced by** production billing at **`/billing`** (Overview, Usage, Plans, Invoices) and the testing doc **[billing-testing.md](./billing-testing.md)**. - -The former `apps/web/src/billing-demo/` app folder and `/billing-demo` route have been removed. Template config now lives at: - -- `apps/web/src/billing/beakerstackBillingConfig.ts` -- `apps/web/src/billing/billing-sync.json` (for `npm run billing:sync-stripe`) - -## Webhook testing (Stripe CLI) - -Use **[billing-testing.md](./billing-testing.md)** for the authoritative walkthrough: `stripe listen` forwarding to `stripe-webhook`, signing secrets, invoice lifecycle triggers, and **idempotency** (`billing_webhook_events` dedupe). - -**Invoice / subscription race (local QA):** if an `invoice.*` event arrives before checkout has linked `stripe_customer_id` on `billing_subscriptions`, the webhook **skips** invoice upsert with a log line and returns **200** so Stripe does not retry indefinitely; a later event reconciles once the row exists. - -## Mobile - -See `apps/mobile/src/screens/BillingScreen.tsx` and `apps/mobile/src/billing/beakerstackBillingConfig.ts` for the native dev surface that wraps `@beakerstack/billing`. - -## Historical content - -Prior versions of this file documented Stripe CLI triggers, env vars, and sync steps; those are consolidated in **[billing-testing.md](./billing-testing.md)** with updated paths and invoice coverage. diff --git a/apps/web/docs/billing-testing.md b/apps/web/docs/billing-testing.md index f2eea988..cba081af 100644 --- a/apps/web/docs/billing-testing.md +++ b/apps/web/docs/billing-testing.md @@ -89,6 +89,6 @@ Run the web app, sign in, go to **`/billing/plans`**, use test card `4242 4242 4 `EXPO_PUBLIC_BILLING_DEMO_MODE` and `EXPO_PUBLIC_BILLING_DEMO_BASE_URL` (LAN URL for return URLs on device). Billing package smoke screen: `apps/mobile/src/screens/BillingScreen.tsx`. Full tab parity is web-first; the native app links users to the web for the complete `/billing` UI if needed. -## Legacy +## Routes -The old `/billing-demo` route has been removed. If you have bookmarks, use `/billing` instead. See [billing-demo.md](./billing-demo.md) (deprecated) for any retained historical notes. +Production billing lives at **`/billing`**, `/billing/usage`, `/billing/plans`, and `/billing/invoices`. The old `/billing-demo` route was removed; use `/billing` instead. diff --git a/apps/web/index.html b/apps/web/index.html index ae26003e..ccfad61a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,7 +8,7 @@ - \n\n \n \n\n `, updated on each push +2. **GitHub Deployments** — visible in the PR's "Deployments" section and the repo's Environments tab + +After a staging deploy, CI posts to: + +1. **GitHub Deployments** — `staging` environment, updated on each merge to `develop` +2. **Actions run summary** — visible directly in the workflow run without navigating to Environments + +**Access flow:** + +1. Stakeholder clicks the bootstrap link from GitHub +2. Browser hits `/_preview-auth?policy=...&sig=...&kid=...&dest=/pr-42/` +3. CloudFront Function sets all three cookies and redirects to `dest` +4. All subsequent requests to the domain carry the signed cookies +5. CloudFront validates at the edge — no application-layer auth needed + +## Disabling signed cookies + +To revert to public access: + +1. Update the CloudFormation stack parameters to `PreviewAccessControl=public` and/or `StagingAccessControl=public`: + + ```bash + aws cloudformation deploy \ + --template-file infra/aws/pr-preview-stack.yml \ + --stack-name "${PR_PREVIEW_STACK_NAME}" \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + "PreviewAccessControl=public" \ + "StagingAccessControl=public" + ``` + +2. Remove the GitHub secrets `CLOUDFRONT_SIGNING_KEY` and `CLOUDFRONT_SIGNING_KEY_ID`. + +3. Optionally delete the CloudFront public key from the AWS console (CloudFront → Public keys). + +## Future access control modes + +The `PreviewAccessControl` and `StagingAccessControl` parameters are designed for future expansion. Values reserved for future use: + +- `basic-auth` — HTTP Basic Auth via Lambda@Edge (not yet implemented) +- `platform-auth` — SSO/OIDC integration (not yet implemented) diff --git a/docs/project-label-bridge.md b/docs/project-label-bridge.md new file mode 100644 index 00000000..d6c88fba --- /dev/null +++ b/docs/project-label-bridge.md @@ -0,0 +1,89 @@ +# Project label bridge (optional GitHub Actions) + +This repository **does not depend** on the [Project label bridge workflow](../.github/workflows/project-label-bridge.yml). CI, builds, and day-to-day development work the same if you never configure it. It exists only if you want **automation between issue labels and an organization GitHub Project (v2) board**. + +## Why it exists + +At **Artificer Innovations** we treat AI coding agents as part of the engineering team: they follow the same habits we expect from human engineers—issues, PRs, reviews, and incremental work you can trace in history. You can see that pattern in this repository, where multiple agent-led changes sit alongside human contributions. + +Humans still rely on **Kanban-style boards** to see flow at a glance. Agents can usually **label** issues and PRs, but we found that **fine-grained personal access tokens** often cannot grant **organization Projects** write access in a way agents can use reliably, so cards on the org board would not move even when work progressed. + +This workflow bridges that gap: **agents (or humans) only change labels**; GitHub Actions runs with a **classic PAT** (or another token that can write org projects) and updates the project **Status** field so the board stays in sync with the labels. + +If you do not use an org-level project board, or you move cards manually, you can ignore this workflow entirely. + +## When the workflow runs + +- Triggers on **`issues: labeled`** and **`pull_request: labeled`**. +- If the **`PROJECT_NUMBER`** repository variable is unset, the job is skipped (no failure). +- If `ORG_PROJECT_GITHUB_TOKEN` is unset, the run exits with a warning and succeeds (so forks and clones without secrets do not break). + +## Setup + +### Quick setup (`gh` + npm) + +If you use the [GitHub CLI](https://cli.github.com/) (`gh`) with permission to manage Actions **variables** and **secrets** on this repository: + +```bash +npm run setup:project-label-bridge -- --help +# Preview: +npm run setup:project-label-bridge -- --dry-run --number 5 --org Artificer-Innovations +# Apply variables (org optional); you are then prompted once for the PAT (masked typing on a real TTY, same idea as npm run setup:full): +npm run setup:project-label-bridge -- --number 5 --org Artificer-Innovations +# Non-interactive: pipe or file (avoid echoing the PAT in argv) +printf '%s' "$ORG_PROJECT_GITHUB_TOKEN" | npm run setup:project-label-bridge -- --number 5 --token-stdin +npm run setup:project-label-bridge -- --number 5 --token-file ~/.config/beakerstack/github-pat-project.txt +# Variables only (skip secret prompt entirely): +npm run setup:project-label-bridge -- --number 5 --skip-secret +``` + +The helper is [`scripts/github/setup-project-label-bridge.mjs`](../scripts/github/setup-project-label-bridge.mjs). It uses [`scripts/lib/setup-secret-input.mjs`](../scripts/lib/setup-secret-input.mjs) (`readMaskedLineIfTty`, `resolveSecretInputLine`) so pastes, paths to bare secret files, and `ORG_PROJECT_GITHUB_TOKEN=...` dotenv lines behave like **setup-full**. Flags: `--plain-secret-prompts` (typed echo), `--skip-secret`, `--dry-run`. If the env var **`ORG_PROJECT_GITHUB_TOKEN`** is already set, it is used without prompting. + +### Manual setup (GitHub UI) + +1. **Repository variable (required)** + In GitHub: **Settings → Secrets and variables → Actions → Variables** + - **`PROJECT_NUMBER`** — the number in the project URL, e.g. `https://github.com/orgs/YourOrg/projects/5` → `5`. + + > **Naming:** GitHub reserves the `GITHUB_` prefix for built-in workflow context. Repository/configuration variables **must not** start with `GITHUB_` (they can be rejected or ineffective). Do not use `GITHUB_PROJECT_NUMBER`. + +2. **Repository variable (optional)** + - **`PROJECT_ORG`** — organization login owning the project. If omitted, the workflow default is `Artificer-Innovations` (change the default in the workflow file if your org differs and you prefer not to set a variable). + + If you previously created **`GITHUB_PROJECT_NUMBER`** / **`GITHUB_PROJECT_ORG`**, delete them and recreate as **`PROJECT_NUMBER`** / **`PROJECT_ORG`**, or run `npm run setup:project-label-bridge` again so `gh variable set` uses the correct names. + +3. **Repository secret (required for the bridge to do anything)** + **Settings → Secrets and variables → Actions → Secrets** + - **`ORG_PROJECT_GITHUB_TOKEN`** — a **classic** personal access token with at least **`repo`** and **`project`** (and whatever your org requires for SSO). Fine-grained PATs are often insufficient for org Projects; see GitHub’s [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) note about `GITHUB_TOKEN` vs PAT/App for org projects. + +4. **Labels and Status names** + Use labels **`project/status-`** where `` is lowercase letters, digits, and hyphens (for example `project/status-ready-for-qa`). The workflow derives the GitHub Project **Status** option name automatically: + - Hyphens become spaces. + - **Default:** each word is **title-cased** (first letter uppercase, rest lowercase), e.g. `project/status-backlog` → `Backlog`, `project/status-ready` → `Ready`, `project/status-planning` → `Planning`, `project/status-done` → `Done`. + - **Exception:** if the slug starts with **`in-`** and has more segments (`in-*`), the result is **`In`** plus a space, then the **remaining words in all lowercase** — so `project/status-in-progress` → **`In progress`** and `project/status-in-review` → **`In review`** (matching this repo’s Kanban wording). + + The derived string must match a **Status** single-select option on the project **exactly**. If you add a column, pick a kebab slug that produces that name, or rename the option in GitHub Projects to match what the label derives to. + + Examples for this board: + + | Label | Derived Status | + | ---------------------------- | -------------- | + | `project/status-backlog` | Backlog | + | `project/status-ready` | Ready | + | `project/status-planning` | Planning | + | `project/status-in-progress` | In progress | + | `project/status-in-review` | In review | + | `project/status-done` | Done | + +5. **Items not yet on the board** + If an issue or PR is not already on the project, the workflow adds it, then sets **Status**. + +## Security notes + +- Treat **`ORG_PROJECT_GITHUB_TOKEN`** like any other powerful PAT: minimal lifetime, rotate on schedule, and restrict org/repo access at the PAT level where GitHub allows it. +- Workflows triggered from **fork pull requests** do not receive secrets; the bridge will no-op for those runs. + +## Related files + +- [`.github/workflows/project-label-bridge.yml`](../.github/workflows/project-label-bridge.yml) — derives **Status** from `project/status-` labels and GraphQL updates. +- [`scripts/github/setup-project-label-bridge.mjs`](../scripts/github/setup-project-label-bridge.mjs) — `gh` + masked secret prompts (`npm run setup:project-label-bridge`). diff --git a/docs/project/TASKS.md b/docs/project/TASKS.md deleted file mode 100644 index 817ca5a5..00000000 --- a/docs/project/TASKS.md +++ /dev/null @@ -1,896 +0,0 @@ -# MVP Development Tasks - -This document contains granular, step-by-step tasks to build the MVP based on the architecture defined in ARCHITECTURE.md. Each task is designed to be: - -- **Incredibly small and testable** - has both unit tests and manual tests -- **Clear start and end** - well-defined scope and completion criteria -- **Single concern** - focuses on one specific functionality -- **Sequential** - can be completed one at a time with testing in between - -## Phase 1: Project Foundation & Setup - -### DONE - Task 1.1: Initialize Monorepo Structure - -**Goal**: Create the basic monorepo structure with package.json files -**Scope**: Root package.json, workspace configuration, basic scripts -**Tests**: - -- Unit: `npm run test` should run without errors -- Manual: `npm install` should install all dependencies successfully - -**Deliverables**: - -- Root `package.json` with workspace configuration -- Basic npm scripts for development -- `.gitignore` file -- `README.md` with setup instructions - ---- - -### DONE - Task 1.2: Setup Shared Package - -**Goal**: Create the shared package structure for cross-platform code -**Scope**: Basic package structure, TypeScript config, initial exports -**Tests**: - -- Unit: `cd packages/shared && npm test` should pass -- Manual: `npm run type-check:shared` should pass - -**Deliverables**: - -- `packages/shared/package.json` -- `packages/shared/tsconfig.json` -- `packages/shared/src/index.ts` (empty exports) -- Basic Jest configuration - ---- - -### DONE - Task 1.3: Setup Web App Foundation - -**Goal**: Create React web app with Vite, TypeScript, and basic routing -**Scope**: Vite config, React setup, basic routing structure -**Tests**: - -- Unit: `cd apps/web && npm test` should pass -- Manual: `npm run web` should start dev server on port 5173 - -**Deliverables**: - -- `apps/web/package.json` with React, Vite, TypeScript dependencies -- `apps/web/vite.config.ts` with path aliases -- `apps/web/tsconfig.json` -- `apps/web/src/App.tsx` with basic routing -- `apps/web/src/index.tsx` entry point -- `apps/web/public/index.html` - ---- - -### DONE - Task 1.4: Setup Mobile App Foundation - -**Goal**: Create React Native app with Expo, TypeScript, and basic navigation -**Scope**: Expo config, React Native setup, basic navigation structure -**Tests**: - -- Unit: `cd apps/mobile && npm test` should pass -- Manual: `npm run mobile` should start Expo dev server - -**Deliverables**: - -- `apps/mobile/package.json` with React Native, Expo dependencies -- `apps/mobile/app.json` Expo configuration -- `apps/mobile/tsconfig.json` -- `apps/mobile/src/App.tsx` with basic navigation -- `apps/mobile/babel.config.js` -- `apps/mobile/metro.config.js` - ---- - -### DONE - Task 1.5: Setup Supabase Local Environment - -**Goal**: Configure Supabase for local development with Docker -**Scope**: Supabase CLI setup, local database, basic configuration -**Tests**: - -- Unit: `supabase status` should show all services running - git - -**Deliverables**: - -- `supabase/config.toml` configuration file -- `supabase/seed.sql` with basic test data -- Working local Supabase instance -- Environment variables setup - ---- - -## Phase 2: Database Schema & Types - -### DONE - Task 2.1: Create Initial Database Schema - -**Goal**: Set up basic user profiles table with RLS policies -**Scope**: User profiles table, RLS policies, basic constraints -**Tests**: - -- Unit: `supabase test db` should pass -- Manual: Can create/read/update user profile in Supabase Studio - -**Deliverables**: - -- `supabase/migrations/20241018000001_initial_schema.sql` -- `supabase/migrations/20241018000002_user_profiles.sql` -- RLS policies for user_profiles table -- Database tests in `supabase/tests/` - ---- - -### DONE - Task 2.2: Generate TypeScript Types - -**Goal**: Generate TypeScript types from database schema -**Scope**: Type generation script, type exports, integration with apps -**Tests**: - -- Unit: Generated types should compile without errors -- Manual: `npm run gen:types` should update type files - -**Deliverables**: - -- `scripts/generate-types.sh` -- `apps/mobile/src/types/database.ts` -- `apps/web/src/types/database.ts` -- `packages/shared/src/types/database.ts` -- npm script for type generation - ---- - -### DONE - Task 2.3: Setup Supabase Client Configuration - -**Goal**: Configure Supabase clients for both web and mobile apps -**Scope**: Client setup, environment variables, type safety -**Tests**: - -- Unit: Supabase client should connect successfully -- Manual: Can make basic query to database from both apps - -**Deliverables**: - -- `apps/mobile/src/lib/supabase.ts` -- `apps/web/src/lib/supabase.ts` -- Environment variable configuration -- Type-safe database client setup - ---- - -## Phase 3: Authentication Foundation - -### DONE - Task 3.1: Create Shared Auth Hook - -**Goal**: Build useAuth hook that works on both platforms -**Scope**: Authentication state management, session handling -**Tests**: - -- Unit: `useAuth` hook should manage auth state correctly -- Manual: Hook should return loading/user/session states - -**Deliverables**: - -- `packages/shared/src/hooks/useAuth.ts` -- `packages/shared/__tests__/hooks/useAuth.test.ts` -- Auth state interface and types -- Session persistence logic - ---- - -### DONE - Task 3.2: Create Auth Context Provider - -**Goal**: Provide authentication context to both apps -**Scope**: React context for auth state, provider component -**Tests**: - -- Unit: Auth context should provide user state to children -- Manual: Components should access auth state via context - -**Deliverables**: - -- `packages/shared/src/contexts/AuthContext.tsx` -- `packages/shared/__tests__/contexts/AuthContext.test.tsx` -- Auth provider component -- Context hook for consuming auth state - ---- - -### DONE - Task 3.3: Implement Web Authentication - -**Goal**: Add Google/Apple sign-in to web app -**Scope**: OAuth integration, redirect handling, session management -**Tests**: - -- Unit: Auth functions should handle OAuth flow correctly ✅ -- Manual: Can sign in with Google/Apple on web app (requires OAuth credentials) - -**Deliverables**: - -- `apps/web/src/pages/LoginPage.tsx` ✅ (with email/password + OAuth) -- `apps/web/src/pages/SignupPage.tsx` ✅ (with email/password + OAuth) -- `apps/web/src/components/SocialLoginButton.tsx` ✅ -- `apps/web/src/components/__tests__/SocialLoginButton.test.tsx` ✅ -- `apps/web/src/pages/AuthCallbackPage.tsx` ✅ (OAuth redirect handler) -- `packages/shared/src/hooks/useAuth.ts` ✅ (updated with OAuth redirect URL) -- [OAuth setup (production)](../oauth/OAUTH_SETUP.md) ✅ - -**Notes**: - -- OAuth UI is fully implemented and tested -- OAuth will show error until credentials are configured in Supabase -- See [../oauth/OAUTH_SETUP.md](../oauth/OAUTH_SETUP.md) for detailed setup instructions -- Email/password authentication is fully functional - ---- - -### DONE - Task 3.4: Implement Mobile Authentication - -**Goal**: Add Google/Apple sign-in to mobile app -**Scope**: Native auth libraries, token handling, session storage -**Tests**: - -- Unit: Auth functions should handle native auth correctly -- Manual: Can sign in with Google/Apple on mobile app - -**Deliverables**: - -- `apps/mobile/src/screens/LoginScreen.tsx` -- `apps/mobile/src/components/auth/SocialLoginButton.tsx` -- Native auth library integration -- AsyncStorage session management - ---- - -### DONE - Task 3.5: Create Protected Route Components - -**Goal**: Build components to protect authenticated routes -**Scope**: Route protection, loading states, redirect logic -**Tests**: - -- Unit: Protected routes should redirect unauthenticated users -- Manual: Unauthenticated users should be redirected to login - -**Deliverables**: - -- `packages/shared/src/components/auth/ProtectedRoute.tsx` -- `packages/shared/__tests__/components/auth/ProtectedRoute.test.tsx` -- Route protection logic -- Loading and redirect states - -### DONE - Task 3.6: Verify proper OAUTH production settings - -**Goal**: All web, ios, and android to use email AND google logins or signups -**Status**: this is working for google after implementing native support because the expo-go web oauth approach -did not work. We did however remove the stubs for apple login; and will have to come back to support that. -**Tests**: - -- Manual: Full signup and login flows with google - ---- - -## Phase 4: User Profile Management - -### DONE - Task 4.1: Create Profile Hook - -**Goal**: Build useProfile hook for profile data management -**Scope**: Profile CRUD operations, loading states, error handling -**Tests**: - -- Unit: Profile hook should handle CRUD operations correctly -- Manual: Can create, read, update profile data - -**Deliverables**: - -- `packages/shared/src/hooks/useProfile.ts` -- `packages/shared/__tests__/hooks/useProfile.test.ts` -- Profile data interface -- CRUD operation functions - ---- - -### DONE - Task 4.2: Create Profile Validation Schema - -**Goal**: Set up Zod validation for profile data -**Scope**: Validation rules, error messages, type inference -**Tests**: - -- Unit: Validation should catch invalid profile data -- Manual: Form should show validation errors for invalid input - -**Deliverables**: - -- `packages/shared/src/validation/profileSchema.ts` -- `packages/shared/__tests__/validation/profileSchema.test.ts` -- Validation rules for all profile fields -- Type-safe form data interface - ---- - -### DONE - Task 4.3: Create Shared Form Components - -**Goal**: Build reusable form components for both platforms -**Scope**: Form inputs, buttons, error display, validation integration -**Tests**: - -- Unit: Form components should handle input and validation correctly -- Manual: Forms should work identically on web and mobile - -**Deliverables**: - -- `packages/shared/src/components/forms/FormInput.tsx` -- `packages/shared/src/components/forms/FormButton.tsx` -- `packages/shared/src/components/forms/FormError.tsx` -- `packages/shared/__tests__/components/forms/` test files - ---- - -### DONE - Task 4.4: Create Profile Editor Component - -**Goal**: Build profile editing form that works on both platforms -**Scope**: Form logic, validation, submission, error handling -**Tests**: - -- Unit: Profile editor should validate and submit data correctly -- Manual: Can edit and save profile on both web and mobile - -**Deliverables**: - -- `packages/shared/src/components/profile/ProfileEditor.tsx` -- `packages/shared/__tests__/components/profile/ProfileEditor.test.tsx` -- Form state management -- Integration with useProfile hook - ---- - -### DONE - Task 4.5: Create Profile Display Components - -**Goal**: Build components to display user profile information -**Scope**: Profile header, stats, avatar display -**Tests**: - -- Unit: Profile display should render user data correctly -- Manual: Profile should display correctly on both platforms - -**Deliverables**: - -- `packages/shared/src/components/profile/ProfileHeader.tsx` -- `packages/shared/src/components/profile/ProfileStats.tsx` -- `packages/shared/src/components/profile/ProfileAvatar.tsx` -- `packages/shared/__tests__/components/profile/` test files - ---- - -## Phase 5: Platform-Specific Screens/Pages - -### DONE - Task 5.1: Create Web Profile Page - -**Goal**: Build profile page for web app using shared components -**Scope**: Page layout, routing, component integration -**Tests**: - -- Unit: Profile page should render without errors -- Manual: Can navigate to profile page and see user data - -**Deliverables**: - -- `apps/web/src/pages/ProfilePage.tsx` -- `apps/web/__tests__/pages/ProfilePage.test.tsx` -- Page routing configuration -- Integration with shared components - ---- - -### DONE - Task 5.2: Create Mobile Profile Screen - -**Goal**: Build profile screen for mobile app using shared components -**Scope**: Screen layout, navigation, component integration -**Tests**: - -- Unit: Profile screen should render without errors -- Manual: Can navigate to profile screen and see user data - -**Deliverables**: - -- `apps/mobile/src/screens/ProfileScreen.tsx` -- `apps/mobile/__tests__/screens/ProfileScreen.test.tsx` -- Screen navigation configuration -- Integration with shared components - ---- - -### DONE - Task 5.3: Create Web Home Page - -**Goal**: Build home page for web app with navigation -**Scope**: Page layout, navigation menu, basic content -**Tests**: - -- Unit: Home page should render without errors -- Manual: Can navigate between pages on web app - -**Deliverables**: - -- `apps/web/src/pages/HomePage.tsx` -- `apps/web/__tests__/pages/HomePage.test.tsx` -- Navigation component -- Basic page content - ---- - -### DONE - Task 5.4: Create Mobile Home Screen - -**Goal**: Build home screen for mobile app with navigation -**Scope**: Screen layout, tab navigation, basic content -**Tests**: - -- Unit: Home screen should render without errors -- Manual: Can navigate between screens on mobile app - -**Deliverables**: - -- `apps/mobile/src/screens/HomeScreen.tsx` -- `apps/mobile/__tests__/screens/HomeScreen.test.tsx` -- Tab navigation setup -- Basic screen content - ---- - -## Phase 6: Avatar Upload & Storage - -### DONE - Task 6.1: Setup Storage Bucket Configuration - -**Goal**: Configure Supabase storage for avatar uploads -**Scope**: Storage bucket setup, policies, file size limits -**Tests**: - -- Unit: Storage policies should allow authenticated uploads -- Manual: Can upload files to storage bucket via Supabase Studio - -**Deliverables**: - -- `supabase/migrations/20241018000003_storage_policies.sql` -- Storage bucket configuration -- RLS policies for file access -- File size and type restrictions - ---- - -### DONE - Task 6.2: Create Avatar Upload Hook - -**Goal**: Build hook for handling avatar uploads -**Scope**: File upload logic, progress tracking, error handling -**Tests**: - -- Unit: Upload hook should handle file uploads correctly -- Manual: Can upload avatar image and get URL back - -**Deliverables**: - -- `packages/shared/src/hooks/useAvatarUpload.ts` -- `packages/shared/__tests__/hooks/useAvatarUpload.test.ts` -- File upload logic -- Progress and error state management - ---- - -### DONE - Task 6.3: Create Avatar Upload Component - -**Goal**: Build avatar upload component for both platforms -**Scope**: File picker, upload progress, preview, error handling -**Tests**: - -- Unit: Avatar upload component should handle file selection and upload -- Manual: Can select and upload avatar on both platforms - -**Deliverables**: - -- `packages/shared/src/components/profile/AvatarUpload.tsx` -- `packages/shared/__tests__/components/profile/AvatarUpload.test.tsx` -- File picker integration -- Upload progress display - ---- - -### DONE - Task 6.4: Integrate Avatar Upload in Profile Editor - -**Goal**: Add avatar upload functionality to profile editor -**Scope**: Component integration, state management, UI updates -**Tests**: - -- Unit: Profile editor should handle avatar uploads correctly -- Manual: Can upload avatar while editing profile - -**Deliverables**: - -- Updated `ProfileEditor.tsx` with avatar upload -- Integration with useAvatarUpload hook -- UI updates for avatar preview -- Error handling for upload failures - ---- - -## DONE - Phase 7: Testing Infrastructure - -### DONE - Task 7.1: Setup Unit Test Infrastructure - -**Goal**: Configure Jest and React Testing Library for all packages -**Scope**: Test configuration, mocking, coverage setup -**Tests**: - -- Unit: All test configurations should work correctly -- Manual: `npm run test:unit` should run all unit tests - -**Deliverables**: - -- Jest configurations for all packages -- React Testing Library setup -- Test utilities and mocks -- Coverage configuration - ---- - -### DONE - Task 7.2: Setup Integration Test Infrastructure - -**Goal**: Configure integration tests for cross-platform functionality -**Scope**: Test database setup, client factories, test utilities -**Tests**: - -- Unit: Integration test setup should work correctly -- Manual: `npm run test:integration` should run integration tests - -**Deliverables**: - -- `tests/integration/` directory structure -- `tests/utils/test-database.ts` -- `tests/utils/test-clients.ts` -- Integration test configuration - ---- - -### DONE - Task 7.3: Setup E2E Test Infrastructure - -**Goal**: Configure Maestro for end-to-end testing -**Scope**: E2E test structure, test data, flow definitions -**Tests**: - -- Unit: E2E test configuration should be valid -- Manual: `npm run test:e2e` should run E2E tests - -**Deliverables**: - -- `tests/e2e/` directory structure -- `tests/e2e/web/flows/` test files -- `tests/e2e/mobile/flows/` test files -- Maestro configuration - ---- - -### DONE - Task 7.4: Create Database Test Suite - -**Goal**: Set up database tests for RLS policies and constraints -**Scope**: SQL-based tests, policy validation, constraint testing -**Tests**: - -- Unit: Database tests should validate all policies -- Manual: `npm run test:db` should run database tests - -**Deliverables**: - -- `supabase/tests/user_profiles.test.sql` -- `supabase/tests/rls_policies.test.sql` -- `supabase/tests/storage_policies.test.sql` -- Database test runner configuration - ---- - -## DONE - Phase 8: Development Scripts & Tooling - -### DONE - Task 8.1: Create Development Scripts - -**Goal**: Build helper scripts for common development tasks -**Scope**: Setup scripts, database management, type generation -**Tests**: - -- Unit: Scripts should execute without errors -- Manual: Scripts should perform their intended functions - -**Deliverables**: - -- `scripts/setup-local.sh` -- `scripts/generate-types.sh` -- `scripts/reset-pr-database.sh` -- `scripts/check-db-status.sh` - ---- - -### DONE - Task 8.2: Setup Code Quality Tools - -**Goal**: Configure ESLint, Prettier, and TypeScript strict mode -**Scope**: Linting rules, formatting, type checking -**Tests**: - -- Unit: Code quality tools should catch issues -- Manual: `npm run lint` and `npm run type-check` should pass - -**Deliverables**: - -- ESLint configurations for all packages -- Prettier configuration -- TypeScript strict mode setup -- Pre-commit hooks with Husky - ---- - -### DONE - Task 8.3: Setup Package Scripts - -**Goal**: Create comprehensive npm scripts for all development tasks -**Scope**: Development, testing, building, deployment scripts -**Tests**: - -- Unit: All scripts should execute without errors -- Manual: Scripts should perform their intended functions - -**Deliverables**: - -- Root `package.json` with all scripts -- Package-specific scripts -- Development workflow scripts -- Build and deployment scripts - ---- - -## Phase 9: CI/CD Pipeline - -### DONE - Task 9.1: Create Test Workflow - -**Goal**: Set up GitHub Actions for running tests on PRs -**Scope**: Unit tests, integration tests, linting, type checking -**Tests**: - -- Unit: Workflow should run all tests successfully -- Manual: PR should trigger test workflow - -**Deliverables**: - -- `.github/workflows/test.yml` -- Test job configurations -- Coverage reporting -- Test result artifacts - ---- - -### DONE - Task 9.1b: Support Renaming the Project - -**Goal**: This project is released as a GitHub Template repo named BeakerStack, with the various identifiers like: -"Beaker Stack", "BeakerStack", "beakerstack", and "beaker-stack". We would like to make it easy to rename this -project to anything else, so it can be easily reused. The goal of this task os to create tools to allow "renaming" -the stack, updating any public strings or app IDs that are string based. And to confirm that when renamed the app -cleanly builds. - -**Scope**: - -- Audit all packages (`apps/web`, `apps/mobile`, `packages/*`) and configuration files for hard-coded project names, bundle IDs, display names, URLs, Supabase project references, and GitHub identifiers. -- Implement a reusable rename workflow (script + documentation) that accepts source and target names (e.g. camelCase, PascalCase, kebab-case) and applies consistent replacements. -- Update Expo/EAS configs, iOS bundle identifiers, Android application IDs, web metadata, Supabase config, CI/CD workflow files, and any docs that surface the project name. -- Provide guidance for regenerating platform-specific artifacts (e.g. `ios` and `android` directories if needed) after running the rename. - **Tests**: -- Unit: Add coverage for rename utilities (e.g. casing transforms, dry-run validation, detection of missing replacements). -- Manual: Use the rename command to switch the stack from “Beaker Stack” to a new sample name, rebuild all apps (`web`, `mobile`, backend services if applicable), and verify clean builds and correct naming in app metadata. - -**Deliverables**: - -- A Node script (e.g. `scripts/rename-project.mjs`) plus an accompanying npm script (`npm run rename -- --from "Demo App" --to "Beaker Stack"`). -- Unit tests that validate casing transforms, dry-run/strict behaviours, and detection of missed replacements. -- Updated configuration files reflecting the new project name defaults and automation coverage. -- Documentation in `docs/renaming.md` describing supported cases, required follow-up steps, and any limitations. - -### Task 9.2: Create PR Preview Workflow - -**Goal**: Set up automated PR preview deployments -**Scope**: - -- Provision and configure all external infrastructure required for preview environments -- Automate database reset/seeding and Supabase configuration for each preview -- Build and publish web app previews to temporary S3/CloudFront targets -- Build and publish mobile app previews via Expo EAS update channels -- Document every manual prerequisite so future contributors can reproduce the setup - -**Tests**: - -- Unit: Workflow should deploy PR previews successfully -- Unit: AWS/Supabase automation scripts should pass dry-run validation (e.g. `--no-execute-changeset`, `--dry-run`) -- Manual: PR should create preview environment end-to-end using fresh infrastructure -- Manual: Documentation steps should enable a new maintainer to provision required external services - -**Deliverables**: - -- `.github/workflows/pr-preview-environment.yml` -- `infra/aws/pr-preview-stack.yml` (CloudFormation or CDK synth output defining S3 bucket, CloudFront distribution, IAM roles, and supporting resources) -- `scripts/pr-preview/bootstrap-aws-stack.sh` (idempotent CLI helper that deploys/updates the AWS stack and exports required secrets) -- Database reset logic (`scripts/pr-preview/reset-preview-database.sh`) integrated with Supabase CLI or SQL migrations -- Web deployment automation (`scripts/pr-preview/deploy-web.sh`) targeting the preview S3/CloudFront resources -- Mobile EAS update automation (`scripts/pr-preview/deploy-mobile.sh`) including channel creation and cleanup routines -- Documentation at `docs/pr-preview-setup.md` detailing: - - Required AWS, Supabase, Expo/EAS accounts and permissions - - One-time provisioning steps (with CLI commands) before the workflow runs - - Secrets/parameters that must be added to GitHub Actions, Supabase, and Expo - - Troubleshooting tips and teardown instructions for preview environments - ---- - -### Task 9.3: Create Staging Deployment Workflow - -**Goal**: Set up automated staging deployments -**Scope**: Database migrations, web deployment, mobile updates -**Tests**: - -- Unit: Workflow should deploy to staging successfully -- Manual: Merge to develop should trigger staging deployment - -**Deliverables**: - -- `.github/workflows/deploy-staging.yml` -- Staging database management -- Staging environment deployment -- Deployment notifications - ---- - -### Task 9.4: Create Production Deployment Workflow - -**Goal**: Set up production deployment with safety checks -**Scope**: Database backups, production deployment, monitoring -**Tests**: - -- Unit: Workflow should deploy to production safely -- Manual: Merge to main should trigger production deployment - -**Deliverables**: - -- `.github/workflows/deploy-production.yml` -- Database backup procedures -- Production deployment logic -- Post-deployment monitoring - ---- - -## Phase 10: Documentation & Final Integration - -### Task 10.1: Create Development Documentation - -**Goal**: Write comprehensive development guide -**Scope**: Setup instructions, development workflow, troubleshooting -**Tests**: - -- Unit: Documentation should be accurate and complete -- Manual: New developer should be able to set up project using docs - -**Deliverables**: - -- `docs/DEVELOPMENT.md` -- `docs/DEPLOYMENT.md` -- `docs/API.md` -- Setup troubleshooting guide - ---- - -### Task 10.2: Create User Documentation - -**Goal**: Write user-facing documentation and help -**Scope**: User guides, FAQ, feature documentation -**Tests**: - -- Unit: Documentation should be clear and accurate -- Manual: Users should be able to use app features using docs - -**Deliverables**: - -- User guide for web app -- User guide for mobile app -- FAQ section -- Feature documentation - ---- - -### Task 10.3: Final Integration Testing - -**Goal**: Test complete user flows across both platforms -**Scope**: End-to-end testing, cross-platform sync, error handling -**Tests**: - -- Unit: All integration tests should pass -- Manual: Complete user flows should work on both platforms - -**Deliverables**: - -- Complete E2E test suite -- Cross-platform integration tests -- Error handling validation -- Performance testing - ---- - -### Task 10.4: Production Readiness Checklist - -**Goal**: Ensure MVP is ready for production deployment -**Scope**: Security review, performance optimization, monitoring setup -**Tests**: - -- Unit: All production readiness checks should pass -- Manual: App should be stable and performant in production - -**Deliverables**: - -- Security audit results -- Performance optimization -- Monitoring and alerting setup -- Production deployment checklist - ---- - -## Testing Strategy for Each Task - -### Unit Tests - -Each task should include unit tests that: - -- Test the specific functionality in isolation -- Use mocks for external dependencies -- Have clear test cases for success and failure scenarios -- Run quickly (< 1 second per test) - -### Manual Tests - -Each task should include manual tests that: - -- Can be run from command line or browser -- Verify the functionality works end-to-end -- Include steps to reproduce the test -- Have clear success/failure criteria - -### Integration Tests - -Tasks that involve multiple systems should include: - -- Tests that verify components work together -- Database integration tests where applicable -- Cross-platform functionality tests -- Real API calls where appropriate - -## Success Criteria - -Each task is considered complete when: - -1. ✅ All unit tests pass -2. ✅ All manual tests pass -3. ✅ Code follows project standards (linting, formatting) -4. ✅ TypeScript compilation succeeds -5. ✅ Documentation is updated if needed -6. ✅ Task deliverables are implemented and working - -## Dependencies - -Tasks should be completed in order, as later tasks depend on earlier ones: - -- Phase 1 (Foundation) must be completed before any other phases -- Phase 2 (Database) must be completed before Phase 3 (Auth) -- Phase 3 (Auth) must be completed before Phase 4 (Profile) -- And so on... - -## Estimated Timeline - -- **Phase 1-2**: 2-3 days (Foundation & Database) -- **Phase 3**: 3-4 days (Authentication) -- **Phase 4**: 2-3 days (Profile Management) -- **Phase 5**: 2-3 days (Platform Screens) -- **Phase 6**: 2-3 days (Avatar Upload) -- **Phase 7**: 2-3 days (Testing) -- **Phase 8**: 1-2 days (Scripts & Tooling) -- **Phase 9**: 2-3 days (CI/CD) -- **Phase 10**: 1-2 days (Documentation) - -**Total Estimated Time**: 17-26 days - -This timeline assumes working on one task at a time with proper testing between each task. diff --git a/docs/reference/github-actions-secrets.md b/docs/reference/github-actions-secrets.md index 75e6bec9..7642b938 100644 --- a/docs/reference/github-actions-secrets.md +++ b/docs/reference/github-actions-secrets.md @@ -35,6 +35,8 @@ This file lists repository **secrets** and **variables** the setup wizard can sy | `PREVIEW_STRIPE_WEBHOOK_SECRET` | no | preview | | `PREVIEW_BILLING_ALLOWED_ORIGINS` | yes | preview | | `PR_PREVIEW_CERTIFICATE_ARN` | no | preview | +| `CLOUDFRONT_SIGNING_KEY` | yes | preview | +| `CLOUDFRONT_SIGNING_KEY_ID` | yes | preview | | `EXPO_TOKEN` | no | expo | | `EXPO_PROJECT_ID` | no | expo | | `GOOGLE_SERVICES_PROJECT_NUMBER` | yes | google | diff --git a/docs/reviews/code-review-billing-v1-followup.md b/docs/reviews/code-review-billing-v1-followup.md deleted file mode 100644 index 1e99332f..00000000 --- a/docs/reviews/code-review-billing-v1-followup.md +++ /dev/null @@ -1,56 +0,0 @@ -# Follow-up code review: billing fixes (post `code-review-billing-v1`) - -**Review date:** 2026-04-27 -**Purpose:** Verify that the codebase changes address the concerns listed in [code-review-billing-v1.md](./code-review-billing-v1.md). - ---- - -## Summary - -| Original concern | Status | Notes | -| ------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **High — open redirect** (unvalidated `successUrl` / `cancelUrl` / `returnUrl`) | **Resolved** | `assertRedirectUrlAllowed()` in [`supabase/functions/_shared/billing-origins.ts`](../../supabase/functions/_shared/billing-origins.ts) validates `new URL(...).origin` against a allowlist before Stripe calls. Called from checkout and portal handlers in [`billing-stripe/index.ts`](../../supabase/functions/billing-stripe/index.ts). | -| **Medium — error detail leakage** | **Resolved** | Catch-all path returns only `{ error: 'stripe_error' }` (no raw `message` to the client). `RedirectValidationError` maps to `invalid_redirect_url`. Full errors are logged with `console.error`. | -| **Medium — CORS `*`** | **Resolved** | [`supabase/functions/_shared/cors.ts`](../../supabase/functions/_shared/cors.ts) uses `corsHeadersForRequest(req)`: reflects `Origin` when it is in the same allowlist; no `*` for browser requests with a matching origin; `*` only when `Origin` header is absent (typical for non-browser callers). | -| **Operational — `SUPABASE_ANON_KEY` for `billing-stripe`** | **Partially addressed** | The function still **requires** `SUPABASE_ANON_KEY` in env (explicit check at lines 36–41 of `billing-stripe/index.ts`). Deploy workflows do **not** pass it through `supabase secrets set`; that continues to rely on **Supabase-managed** Edge runtime defaults, which is the usual pattern. No code change required if the platform always injects the anon key; consider a one-line smoke test in runbooks after deploy. | -| **Naming — `downgrade_to_free` vs behavior** | **Resolved** | Action renamed to `schedule_cancel_to_free` in the Edge contract; [`useBillingStripeActions.ts`](../../packages/billing/src/hooks/useBillingStripeActions.ts) sends `schedule_cancel_to_free`. | -| **Design — client-trusted usage metering** | **Unchanged (by design)** | Still appropriate for soft limits; not a regression. | -| **Low — `resolvePlanId` PostgREST filter** | **Unchanged** | Still acceptable given Stripe-sourced price IDs. | -| **Low — webhook payload retention / backups** | **Unchanged** | Operational/policy concern only. | -| **Consistency — demo JWT in CI vs `supabase-cli-defaults`** | **Unchanged** | Low priority; not part of the billing fix wave. | - ---- - -## What was verified in code - -### Redirect allowlist - -- **`BILLING_ALLOWED_ORIGINS`** — comma-separated origins merged in `getBillingAllowedOrigins()` (documented in [`env.example`](../../env.example), [`supabase/functions/README.md`](../../supabase/functions/README.md)). -- **Local dev** — When `SUPABASE_URL` is loopback (`localhost`, `127.0.0.1`, or `[::1]`), common dev origins (Vite `5173`, Expo `8081`, `:3000`, including `http://[::1]:…`) are merged automatically so teams are not blocked without secrets. -- **Live Stripe keys** — If `STRIPE_SECRET_KEY` starts with `sk_live_`, `http:` redirect URLs are rejected (HTTPS-only for live). - -### CI/CD alignment - -- **`deploy-staging.yml`**, **`deploy-production.yml`**, and **`pr-preview-environment.yml`** pass **`PREVIEW_/STAGING_/PRODUCTION_BILLING_ALLOWED_ORIGINS`** into `supabase secrets set` as **`BILLING_ALLOWED_ORIGINS`**, alongside Stripe and Supabase URL/service-role secrets. - -### Tests / hooks - -- **`packages/billing`** tests expect `action: 'schedule_cancel_to_free'` in `useBillingStripeActions.test.tsx` (per repository grep). - -### Webhook function - -- **`stripe-webhook`** uses the same `corsHeadersForRequest` / `jsonResponse(..., req)` pattern; no raw error message in client JSON for processing failures (unchanged security posture for the main path). - ---- - -## Remaining small gaps (non-blocking) - -1. **Custom URL schemes (mobile)** — README mentions listing origins such as `myapp://`. Behavior depends on how `new URL(...)` parses the redirect string you pass from the client and whether `url.origin` matches what you put in `BILLING_ALLOWED_ORIGINS`. Worth one manual test per platform (Expo deep link) before go-live. - -Setup manifest sync for billing origins (`PREVIEW_/STAGING_/PRODUCTION_BILLING_ALLOWED_ORIGINS`) and IPv6 loopback dev URLs (`http://[::1]:…`) were added after this doc’s initial draft — see [`scripts/lib/setup-manifest.mjs`](../../scripts/lib/setup-manifest.mjs) and [`billing-origins.ts`](../../supabase/functions/_shared/billing-origins.ts). - ---- - -## Conclusion - -The **security-critical** items from the prior review (**redirect validation**, **sanitized errors**, **tighter CORS**) are **implemented coherently** and wired through CI secrets for hosted environments. The **API naming** concern for cancel-at-period-end is **fixed**. Optional **deep-link verification** per platform and **by-design** usage metering remain on the backlog—not regressions from the fixes. diff --git a/docs/reviews/code-review-billing-v1.md b/docs/reviews/code-review-billing-v1.md deleted file mode 100644 index fa72bfda..00000000 --- a/docs/reviews/code-review-billing-v1.md +++ /dev/null @@ -1,110 +0,0 @@ -# Code review: `billing-v1` branch vs `main` - -**Review date:** 2026-04-27 -**Comparison:** `main...billing-v1` (merge-base three-dot diff) -**Scale:** ~227 files changed, ~15k insertions / ~675 deletions - -This review focuses on **security** (including accidental secret exposure), **operational risk** in CI and Edge Functions, and **overall code quality**. It is not an exhaustive line-by-line read of every new test file. - ---- - -## Executive summary - -The branch delivers a **coherent billing v1**: a `packages/billing` library, Supabase schema + RLS, two Edge Functions (`billing-stripe`, `stripe-webhook`), web and mobile UI, sync tooling, and broad test coverage. **No real Stripe or cloud Supabase secrets appear committed**; `env.example` uses placeholders, and workflow references use `secrets.*` indirection. - -**Main gaps to address before production hardening:** (1) **redirect URL validation** on the checkout/portal Edge Function, (2) **information disclosure** in Stripe error responses, (3) **operational confirmation** that hosted Edge runtimes still expose `SUPABASE_ANON_KEY` to `billing-stripe` after `supabase secrets set` (4) **usage metering trust model** (client-driven RPC) if usage ever drives real charges. - ---- - -## Security audit - -### Strengths - -- **Webhook integrity:** `stripe-webhook` uses `constructEventAsync` with `STRIPE_WEBHOOK_SECRET`. Invalid signatures return 400; no dependency on JWT for that function (see `supabase/config.toml`: `verify_jwt = false` for `stripe-webhook`, `true` for `billing-stripe`). -- **User-scoped billing actions:** `billing-stripe` resolves the user from the **caller's JWT** via `authClient.auth.getUser()`, then uses the **service role** only for database operations tied to that `user.id`. Mutations (checkout, portal, subscription update, cancel) are scoped to the authenticated user. -- **RLS:** `billing_subscriptions`, usage tables, and `billing_invoices` use **select-own** policies; webhook and service paths use the service role. `billing_webhook_events` has **no** authenticated policies (service role only), which is appropriate for sensitive payloads. -- **Idempotent webhook logging:** Duplicate Stripe event IDs are handled; retries can reprocess failed events (`processed = false`). -- **JWT verification:** Customer-facing Stripe operations require JWT verification on `billing-stripe`, reducing anonymous abuse. - -### Findings (ordered by severity) - -| Severity | Topic | Details | -| ---------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **High** | Open redirect / phishing via Checkout & Customer Portal | `billing-stripe` passes `success_url`, `cancel_url`, and `return_url` from the **request body** into Stripe (`checkout.sessions.create`, `billingPortal.sessions.create`) with **no server-side allowlist** of origins or paths. A user with a valid session could call `functions.invoke` with attacker-controlled URLs (or a modified client could), steering post-payment redirects away from your app. **Recommendation:** Validate URLs against an allowlist (exact origins from env, e.g. `ALLOWED_CHECKOUT_ORIGINS`, or match `new URL(url).origin` to configured app origins). | -| **Medium** | Error detail leakage | On catch-all errors, `billing-stripe` returns `{ error: 'stripe_error', message: msg }` where `msg` is the raw Stripe/API message. That can expose internal identifiers or operational hints. Prefer a stable client-facing code plus logged server detail only. | -| **Medium** | CORS policy | `_shared/cors.ts` sets `Access-Control-Allow-Origin: *`. Supabase Edge Functions still enforce auth for `billing-stripe`; `stripe-webhook` is not browser-called. Risk is **low** for CSRF to the function (custom JSON body + user JWT), but `*` is broader than necessary if you ever expand CORS use. | -| **Low** | `resolvePlanId` filter construction | `stripe-webhook` builds a PostgREST `.or('stripe_price_id_monthly.eq.${id},...')` filter. Stripe price IDs are normally safe; if untrusted data could reach this string, it could be fragile. In practice the value comes from Stripe objects. Optional hardening: use `.or()` with separate `.eq` filters or parameterized RPC. | -| **Low** | Webhook payload storage | Full Stripe `event` objects are stored in `billing_webhook_events.payload` (PII and payment metadata). This is **correctly** not exposed to `authenticated` via RLS, but treat DB backups and admin access as **sensitive** and consider retention limits. | -| **Design** | Usage events are client-initiated | `billing_record_usage_event` is `SECURITY DEFINER` and callable by `authenticated` users, who can record usage for **themselves**. That is fine for “soft” limits; it is **not** a tamper-proof meter for revenue-grade billing. If overages or enforcement must be strict, move metering to trusted servers or signed server events. | - -### Webhook → DB edge cases (quality / correctness, not auth bypass) - -- `syncInvoiceRow` throws if no `billing_subscriptions` row exists for the Stripe customer, causing a 500 and `processed: false` on the event. That is a reasonable **retry** story but worth monitoring in production. -- `mapStripeStatus` defaults unknown Stripe statuses to `'active'`, which may be optimistic; worth revisiting as Stripe adds statuses. - ---- - -## Secrets, keys, and sensitive data - -### No evidence of committed live secrets in this branch - -- **`env.example`:** Placeholders only (`sk_test_…`, `whsec_…` patterns as documentation, not real keys). -- **Edge Functions:** Read secrets from `Deno.env` only. -- **GitHub workflows:** Use `${{ secrets.* }}` for Stripe, Supabase, and AWS; no inline tokens in the diff reviewed. -- **`.github/workflows/test.yml`:** Continues to embed the **public** Supabase CLI demo JWTs for local CI against `127.0.0.1:54321` (expected for this repo’s pattern; same class of “non-secret” as `tests/utils/supabase-cli-defaults.ts`). - -### Consistency check (low priority) - -- `test.yml` `SUPABASE_SERVICE_ROLE_KEY` signature differs from the `LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY` in `tests/utils/supabase-cli-defaults.ts` (third segment of the JWT). If CI ever uses helpers that require bit-identical keys, align with the **current** `supabase start` output. This predates or sits alongside the billing work; not introduced as a billing secret leak. - -### Documentation and skills - -- New Stripe-related **agent skills** under `.agents` / symlinks and `.claude` are **documentation and references**, not credentials. - ---- - -## Code quality and architecture - -### Positives - -- **Clear separation:** `packages/billing` holds provider, hooks, and UI building blocks; apps supply `defineBillingConfig` data and route shells (`BillingProviderLayout`). -- **Schema validation:** `productBillingConfigSchema` (Zod) in the provider reduces misconfiguration. -- **SQL design:** Migrations add indexes, `SECURITY DEFINER` RPCs with `search_path` pinned, and explicit `REVOKE`/`GRANT` for RPCs. Realtime on `billing_subscriptions` is documented and scoped. -- **Tests:** Extensive unit and component tests for billing hooks, presentational components, and pages; `packages/billing` integrated into coverage upload in `test.yml`. -- **Tooling:** `scripts/sync-billing-stripe.mjs` is documented and idempotent-friendly for Stripe price linkage. - -### Minor quality notes - -- **Naming:** `downgrade_to_free` in `billing-stripe` implements **cancel at period end** (`cancel_at_period_end: true`), not an immediate free plan. The client hook name `scheduleCancelToFree` matches behavior better than the action name; consider renaming the action for API clarity. -- **Stripe API version:** Pinned to `2023-10-16` consistently; plan periodic upgrades per Stripe’s release notes. -- **Duplication (resolved):** Duplicate migration SQL under `apps/mobile/supabase/migrations/` was removed; canonical migrations live only under repo-root `supabase/migrations/` — developers run Supabase CLI migration workflows from the repository root (see `apps/mobile/supabase/migrations/README.md`). - ---- - -## CI/CD and operations - -- **Deploy workflows** (`deploy-staging.yml`, `deploy-production.yml`, and PR preview) now **link** the project, **set** Stripe and Supabase-related secrets, and **deploy** `stripe-webhook` and `billing-stripe`. This matches `supabase/functions/README.md`. -- **Follow-up to verify in each environment:** After first deploy, confirm `billing-stripe` logs show `SUPABASE_ANON_KEY` present (Supabase hosted projects usually inject it; the README notes “confirm if calls fail”). The workflows set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` explicitly; if `ANON` were ever missing, authenticated `getUser()` would fail. -- **PR preview** sets preview Stripe secrets on the shared preview project—ensure Stripe **test** keys and webhook endpoints are scoped to that environment. - ---- - -## Testing and coverage - -- New `packages/billing` tests and web billing page tests improve confidence. -- `supabase/tests/billing.test.sql` checks RLS and core objects; consider extending pgTAP for `billing_invoices` RLS if you want parity with `billing_subscriptions` checks. - ---- - -## Recommended next steps (short list) - -1. Add **URL allowlisting** for `successUrl`, `cancelUrl`, and `returnUrl` in `billing-stripe` (and optionally require HTTPS in production). -2. **Sanitize** client-visible errors from the Edge Function; log full errors server-side only. -3. Document or automate **Edge secret** expectations (`SUPABASE_ANON_KEY` for `billing-stripe`) in runbooks and smoke-test after deploy. -4. If usage-based billing becomes revenue-critical, **relocate usage recording** to trusted backends or verified pipelines. - ---- - -## Conclusion - -The `billing-v1` branch is a **substantial, well-structured** billing foundation with **sound RLS, webhook verification, and authenticated Edge access**. The highest-impact security improvement is **validating redirect URLs** passed into Stripe. No committed repository secrets were identified in the reviewed material; continue to keep Stripe and Supabase keys in **GitHub Actions secrets** and Supabase **Edge secrets** only. diff --git a/docs/reviews/spec-compliance-audit-billing-v1.md b/docs/reviews/spec-compliance-audit-billing-v1.md deleted file mode 100644 index d59cee00..00000000 --- a/docs/reviews/spec-compliance-audit-billing-v1.md +++ /dev/null @@ -1,354 +0,0 @@ -# Spec-to-code compliance audit — billing-v1 vs `main` - -**Branch reviewed:** `billing-v1` -**Specifications:** `docs/specs/beakerstac-billing-v1.md`, `docs/specs/beakerstack-billing-ui-v1.md` -**Methodology:** Phases 1–5 completed as requested; report written after Phase 5. -**Constraint:** Analysis only — no code changes. - ---- - -## Phase 1 — Spec ingestion (atomic requirements) - -Each requirement is one verifiable statement. IDs are stable for traceability below. - -### Core billing (`beakerstac-billing-v1.md`) - -| ID | Requirement | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| REQ-001 | Stripe must remain the source of truth for subscription state; DB stays aligned via webhooks or Stripe-backed actions. | -| REQ-002 | Entitlements must be derived from plan configuration + subscription (no separately stored entitlement rows). | -| REQ-003 | Schema must support multiple products without structural changes per product. | -| REQ-004 | Usage limits must be enforceable by the app via module-provided checks (`getRemainingUsage` / `hasExceededLimit` pattern); module must not silently block actions alone. | -| REQ-005 | Free tier must be first-class with status distinct from paid Stripe subscriptions (`free`, no Stripe subscription id where applicable). | -| REQ-006 | Demo shortcuts must not live inside reusable billing logic except documented guardrails; demo upgrades via gated RPCs per appendix. | -| REQ-007 | Tables `billing_products`, `billing_plans`, `billing_subscriptions`, `billing_usage_events`, `billing_usage_aggregates`, `billing_webhook_events` exist with intended semantics (products/plans/subscriptions/usage/events/log). | -| REQ-008 | RLS must restrict subscriptions and usage rows so users only **select** their own rows; subscription mutations via trusted paths only. | -| REQ-009 | API surface includes entitlement/feature access semantics equivalent to `canUserAccessFeature(userId, productId, featureName)`. | -| REQ-010 | API surface includes current plan resolution equivalent to `getUserPlan(userId, productId)`. | -| REQ-011 | API surface includes `getFeatureValue` semantics for boolean or numeric limits. | -| REQ-012 | API surface includes `getRemainingUsage` returning used/limit/remaining/periodEnd for an event type. | -| REQ-013 | API surface includes `hasExceededLimit`. | -| REQ-014 | API surface includes `recordUsageEvent` including optional **metadata**. | -| REQ-015 | API surface includes `initiateCheckout` with optional `successUrl`, `cancelUrl`, **`trialDays`**. | -| REQ-016 | API surface includes customer portal URL retrieval (`getCustomerPortalUrl`). | -| REQ-017 | API surface includes `downgradeToFree` (cancel paid subscription at period end). | -| REQ-018 | API surface includes `cancelSubscriptionImmediately`. | -| REQ-019 | API surface includes `getPublicPlans` and `getPlan(planId)`. | -| REQ-020 | React **PricingTable** matches documented props (`productId`, `currentUserId`, `highlightPlanId`, `onCheckout`). | -| REQ-021 | React **UpgradePrompt** matches documented props (`productId`, `reason`, `suggestedPlanId`, `onUpgrade`). | -| REQ-022 | React **UsageIndicator** documents progress/visualization and updates as usage changes (including expanded UX where specified). | -| REQ-023 | React **SubscriptionStatus** exists per spec snippet. | -| REQ-024 | React **CustomerPortalLink** exists per spec snippet. | -| REQ-025 | React **FeatureGate** accepts product + feature identifier and fallback (`featureName` in spec snippet). | -| REQ-026 | Declarative billing config seeds DB idempotently (sync/setup script pattern). | -| REQ-027 | Missing subscription for a product must be corrected toward free tier (`ensure` semantics). | -| REQ-028 | Usage periods use billing period for paid users and **calendar month** for free tier users. | -| REQ-029 | Webhook handler processes listed lifecycle events including checkout completion, subscription updated/deleted, trial ending signal, invoice payment failed/succeeded (per spec lists). | -| REQ-030 | Webhooks verify Stripe signatures; events logged in `billing_webhook_events`; processing idempotent for retries. | -| REQ-031 | Module reads Stripe keys from environment (`STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`) per spec’s configuration section. | - -### Appendix / template alignment (`beakerstac-billing-v1.md` appendix) - -| ID | Requirement | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| REQ-032 | Template demo tier vocabulary is app-owned (Free/Pro/Max-style ids), not hardcoded inside `packages/billing`. | -| REQ-033 | `/billing-demo` route demonstrates three entitlement surfaces per appendix **or** equivalent documented migration path. | -| REQ-034 | Demo RPCs (`simulateUpgrade`-style, usage reset) are gated server-side; client demo flags are UX-only. | -| REQ-035 | Webhook testing documentation exists at **`apps/web/docs/billing-demo.md`** with Stripe CLI scenarios idempotency guidance (appendix explicit path). | - -### Billing UI v1 (`beakerstack-billing-ui-v1.md`) - -| ID | Requirement | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| REQ-036 | `/billing`, `/billing/usage`, `/billing/plans`, `/billing/invoices` exist under **ProtectedRoute**, wrapped once by **`BillingProvider`** at route-group level as illustrated. | -| REQ-037 | Each `/billing/*` page renders shared **`BillingTabs`** below title with four tabs (`NavLink`). | -| REQ-038 | **`UserMenu.web`** and **`UserMenu.native`** include **Billing** (`CreditCard`) linking to `/billing`, ordered Profile → Billing → Dashboard above Sign Out. | -| REQ-039 | **BillingOverviewPage** matches §3.1 structure (banner(s), current plan card behavior free vs paid, quick stats, optional recent invoices when present). | -| REQ-040 | **BillingUsagePage** matches §3.2 (reset-period semantics card, meter section with UsageIndicator expanded usage, limits rows, plan features rows). | -| REQ-041 | **BillingPlansPage** matches §3.3 (cadence toggle persisted in URL query; PlanCard grid; downgrade modal; constraint warnings disable downgrade when hard constraints fail). | -| REQ-042 | **BillingInvoicesPage** matches §3.4 (columns, Stripe-hosted links, load-more pagination **20** rows). | -| REQ-043 | Nine-state matrix behaviors render consistently across Overview / Plans / Invoices where applicable (Loading, No subscription→free ensure, Free, Paid active, Cancelled-pending + **reactivate**, Payment failed, Trial active, Trial ending, Post-downgrade-pending). | -| REQ-044 | **`billing_plans`** gains **`stripe_price_id_monthly`** / **`stripe_price_id_annual`**; **`billing_subscriptions`** records **`stripe_price_id`** for cadence resolution; **`billing_invoices`** exists with webhook-only writes. | -| REQ-045 | **`stripe-webhook`** handles **`invoice.created`**, **`invoice.finalized`**, **`invoice.paid`**, **`invoice.payment_failed`**, **`invoice.payment_succeeded`**, **`invoice.voided`** per §6 with idempotent upserts into **`billing_invoices`**. | -| REQ-046 | Invoice timing edge case per §6: if subscription lookup fails, **log** webhook row and reconcile later (must not necessarily fail entire webhook processing path). | -| REQ-047 | **`UsageIndicator`** gains **`expanded`** variant with label + reset text (`packages/billing`). **`SubscriptionStatusBadge`** exists (`packages/billing`). | -| REQ-048 | **`/billing-demo`** removed after parity; dashboard links **Manage billing** → `/billing`. | -| REQ-049 | Mobile parity for `/billing` routes ships per rollout §8 step 10 **or** explicitly deferred with mirror scaffolding — spec presents both. | - -### Ambiguities / assumptions - -1. **Naming drift:** Appendix mentions RPC **`ensure_free_subscription`** while core migration defines **`ensure_billing_subscription`** — treated as same intent unless repo standardizes names elsewhere. -2. **`stripe_price_id` column evolution:** Core DDL uses singular plan price column; UI appendix replaces with monthly/annual — interpreted as additive migration superseding core DDL snapshot (not an internal contradiction if specs read chronologically). -3. **`PricingTable` vs template `/billing/plans`:** Core component library vs UI spec **PlanCard** grid — both describe pricing UX at different layers; compliance judged separately (REQ-020 vs REQ-041). - ---- - -## Phase 2 — Code survey (inventory) - -### `packages/billing` - -- **Entry / exports:** `src/index.ts`, `src/web.ts`, `src/native.ts`, `src/client.ts` -- **Configuration / types:** `src/schema.ts` (`defineBillingConfig`, Zod schemas), `src/types.ts` -- **Provider:** `BillingProvider.tsx`, realtime subscription refresh on `billing_subscriptions` -- **Hooks:** `useBillingContext`, `useSubscription`, `usePlan`, `usePlanCatalog`, `useFeature`, `useUsage`, `useRecordUsage`, `useCheckout`, `useCustomerPortal`, `useBillingStripeActions`, `useInvoices`, `useBillingState`, `useBillingConfig` -- **Components:** `PricingTable.*`, `UpgradePrompt.*`, `UsageIndicator.*`, `SubscriptionStatus.*`, `SubscriptionStatusBadge.*`, `CustomerPortalLink.*`, `FeatureGate.*`, `BillingErrorBoundary.tsx` -- **Tests:** Vitest files alongside hooks/components (`*.test.tsx`), `schema.test.ts`, `entrypoints.test.ts`, etc. - -### Supabase (`supabase/`) - -- **Migrations:** `20250424120000_billing_v1.sql` (core tables, RLS, RPCs, realtime publication), `20260426000100_billing_demo_collections.sql`, `20260427120000_billing_ui_v1.sql` (monthly/annual prices + invoices + subscription price id) -- **Functions:** `stripe-webhook/index.ts`, `billing-stripe/index.ts`, `_shared/cors.ts` -- **Tests:** `supabase/tests/billing.test.sql` - -### Apps / tooling - -- **`apps/web`:** `BillingProviderLayout.tsx`, `pages/billing/*`, `components/billing/*`, `billing/beakerstackBillingConfig.ts`, docs `billing-testing.md`, deprecated stub `billing-demo.md` -- **`apps/mobile`:** `screens/BillingScreen.tsx`, `billing/beakerstackBillingConfig.ts`; DB migrations are only at repo root `supabase/migrations/` (see `apps/mobile/supabase/migrations/README.md`). -- **`scripts/sync-billing-stripe.mjs`**, `billing-sync.json` - -### `packages/shared` - -- Navigation (`UserMenu.web.tsx`, `UserMenu.native.tsx`), primitives added/enhanced (`Button`, `Modal`, `Skeleton`) supporting billing UI polish. - ---- - -## Phase 3 — Traceability (REQ → implementation locus) - -Locations cite **`billing-v1`** branch files. - -| REQ | Primary mapping | -| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| REQ-001 | `supabase/functions/stripe-webhook/index.ts` (Stripe-driven updates); `supabase/functions/billing-stripe/index.ts` (Stripe API mutations) | -| REQ-002 | `billing_plans.features` JSON + client hooks reading plans (`packages/billing/src/hooks/usePlan.ts`, `useFeature.ts`) | -| REQ-003 | `billing_products.id`, `billing_plans.product_id`, `billing_subscriptions.product_id` (`supabase/migrations/20250424120000_billing_v1.sql` L7–51) | -| REQ-004 | RPCs `billing_get_remaining_usage`, `billing_has_exceeded_limit` (`20250424120000_billing_v1.sql` L269–352); `useUsage` (`packages/billing/src/hooks/useUsage.ts` L36–94) | -| REQ-005 | `ensure_billing_subscription` (`20250424120000_billing_v1.sql` L167–214); provider calls RPC (`packages/billing/src/BillingProvider.tsx` L104–112) | -| REQ-006 | `billing_system_flags`, `billing_demo_*` RPCs gated (`20250424120000_billing_v1.sql` L358–449); demo collections gated (`20260426000100_billing_demo_collections.sql`) | -| REQ-007 | `20250424120000_billing_v1.sql` L7–88 (+ UI migration `20260427120000_billing_ui_v1.sql` for invoice table / split prices) | -| REQ-008 | Policies `20250424120000_billing_v1.sql` L112–136; invoices policy `20260427120000_billing_ui_v1.sql` L55–59 | -| REQ-009 | `useFeature` (`packages/billing/src/hooks/useFeature.ts`); boolean gates (`packages/billing/src/components/FeatureGate.web.tsx` L8–17) | -| REQ-010 | `usePlan` + `useSubscription` (`packages/billing/src/hooks/usePlan.ts` — file not fully quoted here; used by pages) | -| REQ-011 | `useFeature` numeric/boolean (`packages/billing/src/hooks/useFeature.ts` L22–29) | -| REQ-012 | RPC `billing_get_remaining_usage` (`20250424120000_billing_v1.sql` L269–326); `useUsage` (`packages/billing/src/hooks/useUsage.ts` L40–66) | -| REQ-013 | RPC `billing_has_exceeded_limit` (`20250424120000_billing_v1.sql` L333–348); consumer exposure via `useUsage.exceeded` (`packages/billing/src/hooks/useUsage.ts` L79–82) — **no standalone exported hook named `hasExceededLimit`** | -| REQ-014 | RPC accepts metadata (`20250424120000_billing_v1.sql` L220–252); client passes `{}` only (`packages/billing/src/hooks/useRecordUsage.ts` L31–38) | -| REQ-015 | `billing-stripe` checkout (`supabase/functions/billing-stripe/index.ts` L81–138); **no `trialDays` / trial_period_days wiring** | -| REQ-016 | `useCustomerPortal` (`packages/billing/src/hooks/useCustomerPortal.ts` L24–49); Edge `portal` action (`billing-stripe/index.ts` L140–164) | -| REQ-017 | `scheduleCancelToFree` → `downgrade_to_free` (`packages/billing/src/hooks/useBillingStripeActions.ts` L65–67`; `billing-stripe/index.ts` L218–239) | -| REQ-018 | Edge `cancel_immediately` (`billing-stripe/index.ts` L242–261`) — **not exposed via `useBillingStripeActions` / package hooks\*\* | -| REQ-019 | `usePlanCatalog` (`packages/billing/src/hooks/usePlanCatalog.ts` L25–31`) | -| REQ-020 | `PricingTable.web.tsx` (`packages/billing/src/components/PricingTable.web.tsx` L7–47`) vs spec props | -| REQ-021 | `UpgradePrompt.types.ts` uses **`targetTier`** (`packages/billing/src/components/UpgradePrompt.types.ts` L3–14`) vs **`suggestedPlanId`\*\* in core spec | -| REQ-022 | `UsageIndicator.web.tsx` variants (`packages/billing/src/components/UsageIndicator.web.tsx` L11–99); realtime sub refresh applies to **subscriptions only** (`BillingProvider.tsx` L119–153`) | -| REQ-023 | `SubscriptionStatus.web.tsx` / `.native.tsx` present under `packages/billing/src/components/` | -| REQ-024 | `CustomerPortalLink.web.tsx` / `.native.tsx` | -| REQ-025 | `FeatureGate.types.ts` prop **`feature`** (`packages/billing/src/components/FeatureGate.types.ts` L5–11`) vs **`featureName`\*\* snippet | -| REQ-026 | `scripts/sync-billing-stripe.mjs`; config `apps/web/src/billing/billing-sync.json` | -| REQ-027 | `BillingProvider.tsx` calls `ensure_billing_subscription` (`packages/billing/src/BillingProvider.tsx` L104–112`) | -| REQ-028 | `billing_usage_period` (`20250424120000_billing_v1.sql` L145–161`) | -| REQ-029 | `stripe-webhook/index.ts` switch (`supabase/functions/stripe-webhook/index.ts` L146–292`) | -| REQ-030 | Signature verification (`stripe-webhook/index.ts` L83–93`, L96–141); duplicate processed (`L106–114`) | -| REQ-031 | Edge Functions read **server** secrets (`stripe-webhook/index.ts` L68–76`; documented in `apps/web/docs/billing-testing.md` L7–14`) — **publishable key not referenced inside Edge handlers reviewed** | -| REQ-032 | `apps/web/src/billing/beakerstackBillingConfig.ts` supplies ids/copy | -| REQ-033 | `/billing/*` replaces demo folder (`apps/web/docs/billing-demo.md` L5–8`; routes `apps/web/src/App.tsx` L38–50`) | -| REQ-034 | `billing_demo_simulate_upgrade`, `billing_demo_reset_usage` (`20250424120000_billing_v1.sql` L374–449`) | -| REQ-035 | **`billing-testing.md`** hosts Stripe CLI guidance (`apps/web/docs/billing-testing.md` L43–65`); **`billing-demo.md`** redirects (`billing-demo.md` L1–16`) | -| REQ-036 | `apps/web/src/App.tsx` L38–50`; `BillingProviderLayout.tsx` wraps outlet | -| REQ-037 | `apps/web/src/components/billing/BillingTabs.web.tsx` L4–17`; pages include tabs (e.g. `BillingOverviewPage.tsx` L52–54`) | -| REQ-038 | `packages/shared/src/components/navigation/UserMenu.web.tsx` L76–97`; verify native separately | -| REQ-039 | `BillingOverviewPage.tsx`, `CurrentPlanCard.web.tsx`, `OverviewBanners` (`BillingOverviewPage.tsx` L106–149`) | -| REQ-040 | `BillingUsagePage.tsx` (imports show UsageIndicator, FeatureLimitRow, PlanFeatureRow, Banner, BillingTabs) | -| REQ-041 | `BillingPlansPage.tsx`, `PlanCard.web.tsx`, `CadenceToggle.web.tsx`, `ConfirmDowngradeModal.web.tsx`, `constraintBlockers.ts` | -| REQ-042 | `BillingInvoicesPage.tsx` + `InvoiceTable.web.tsx`; pagination via `useInvoices` (`packages/billing/src/hooks/useInvoices.ts` L13–24`) | -| REQ-043 | `useBillingState` (`packages/billing/src/hooks/useBillingState.ts` L30–77`); banners `BillingOverviewPage.tsx` L106–149`; **`downgrade_pending` never derived** (`useBillingState.ts` L8–10`) | -| REQ-044 | Migration `20260427120000_billing_ui_v1.sql`; webhook invoice handlers (`stripe-webhook/index.ts` L268–288`, `syncInvoiceRow` L339–392`) | -| REQ-045 | Cases match (`stripe-webhook/index.ts` L254–288`) | -| REQ-046 | `syncInvoiceRow` throws if no user (`stripe-webhook/index.ts` L350–354`) vs UI spec §6 edge-case wording | -| REQ-047 | `UsageIndicator.types.ts` variant **`expanded`** (`packages/billing/src/components/UsageIndicator.types.ts` L4–12`); `SubscriptionStatusBadge.\*` in package | -| REQ-048 | Dashboard link (`apps/web/src/pages/DashboardPage.tsx` L23–25`); `/billing-demo`route absent`App.tsx` | -| REQ-049 | `apps/mobile/src/screens/BillingScreen.tsx` exists — **parity depth not exhaustively audited here** | - ---- - -## Phase 4 — Gap classification - -Statuses: **MET**, **PARTIAL**, **DIVERGENT**, **MISSING**, **AMBIGUOUS**. - -### Executive summary (also §1 below) - -### Traceability table - -| REQ ID | Status | Code location | Note | -| ------- | --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| REQ-001 | MET | `stripe-webhook/index.ts` L146–247 | Stripe events drive subscription rows; checkout writes via upsert after Stripe retrieval | -| REQ-002 | MET | `20250424120000_billing_v1.sql` L14–24`, hooks | Features stored on plans; subscribers inherit via `plan_id` | -| REQ-003 | MET | `20250424120000_billing_v1.sql` L7–51 | Composite uniqueness `(user_id, product_id)` | -| REQ-004 | MET | RPC + `useUsage.exceeded` | App-layer enforcement supported | -| REQ-005 | MET | `ensure_billing_subscription` + `'free'` rows | Matches intended free-tier semantics | -| REQ-006 | MET | Demo RPC gating | Matches appendix defense-in-depth pattern | -| REQ-007 | MET | migrations | UI migration adjusts price columns per appendix | -| REQ-008 | MET | policies | Webhook paths use service role; tables locked down for anon/authenticated writes | -| REQ-009 | PARTIAL | `useFeature.ts` | Covers boolean gate; **numeric entitlement semantics require app-side counting** (appendix aligns); no standalone **`canUserAccessFeature`** export | -| REQ-010 | MET | `usePlan.ts` (provider-backed) | Equivalent for React consumers | -| REQ-011 | PARTIAL | `useFeature.ts` L22–29 | Numbers yield **`enabled: true`** always (`L26–27`) — weak match for “numeric limit” interpretation without separate counting | -| REQ-012 | MET | RPC + `useUsage.ts` | Returns period boundaries + limits | -| REQ-013 | PARTIAL | `billing_has_exceeded_limit` SQL + `useUsage` | RPC exists; **package lacks exported function/hook named like spec** | -| REQ-014 | PARTIAL | `useRecordUsage.ts` | RPC supports metadata; hook **fixes `{}`** (`L37`) | -| REQ-015 | MISSING | `billing-stripe/index.ts` L117–135 | **`trialDays`** / subscription trial not passed from plan DB fields | -| REQ-016 | MET | `useCustomerPortal.ts` | Opens portal URL via Edge | -| REQ-017 | MET | `billing-stripe/index.ts` L236–238 | Sets **`cancel_at_period_end`** | -| REQ-018 | MISSING | `billing-stripe/index.ts` L242–261 | Implemented server-side **only**; **no client hook** exposing immediate cancel | -| REQ-019 | PARTIAL | `usePlanCatalog.ts` | Public plans covered; **no dedicated `getPlan(planId)` helper** beyond catalog filtering | -| REQ-020 | DIVERGENT | `PricingTable.types.ts` L1–6`, `PricingTable.web.tsx` | Props **`onSelectPlan`, `highlightCurrent`** vs spec **`productId`, `currentUserId`, `highlightPlanId`, `onCheckout`** (`beakerstac-billing-v1.md` L359–367`) | -| REQ-021 | DIVERGENT | `UpgradePrompt.types.ts` | **`targetTier`** vs **`suggestedPlanId`** (`beakerstac-billing-v1.md` L376–384`) | -| REQ-022 | PARTIAL | `UsageIndicator.web.tsx`, `BillingProvider.tsx` | Visualization MET for indicators; **usage aggregates do not use realtime channel** — refresh mostly subscription-driven / explicit refresh paths | -| REQ-023 | MET | `SubscriptionStatus.*` | Present | -| REQ-024 | MET | `CustomerPortalLink.*` | Present | -| REQ-025 | DIVERGENT | `FeatureGate.types.ts` | Prop **`feature`** vs snippet **`featureName`** (`beakerstac-billing-v1.md` L428–434`) | -| REQ-026 | MET | `scripts/sync-billing-stripe.mjs` | Idempotent Stripe price linkage pattern implemented | -| REQ-027 | MET | `BillingProvider.tsx` L104–112 | Calls ensure RPC on auth | -| REQ-028 | MET | `billing_usage_period` SQL | Free calendar month vs subscription periods encoded | -| REQ-029 | MET | `stripe-webhook/index.ts` | Listed handlers implemented (`checkout.session.completed`, subscription updated/deleted, trial signal noop L250–252`, invoices L254–288`) | -| REQ-030 | MET | `stripe-webhook/index.ts` L83–141`, L106–114 | Signature verification + dedupe | -| REQ-031 | PARTIAL | Docs + Edge | **`STRIPE_PUBLISHABLE_KEY`** not consumed in reviewed Edge Functions — acceptable if interpreted as **client-only**, but strict literal REQ only partly evidenced in repo scope audited | -| REQ-032 | MET | App billing config | Product vocabulary lives in app layer | -| REQ-033 | MET | Docs + `/billing` routes | Appendix demo folder replaced; `/billing` exercises flows | -| REQ-034 | MET | SQL RPCs | Matches gated-template RPC design | -| REQ-035 | DIVERGENT | `billing-testing.md`, `billing-demo.md` | Appendix mandates **`apps/web/docs/billing-demo.md`** as primary CLI doc surface (`beakerstac-billing-v1.md` L837–843`); repo **consolidated into `billing-testing.md`** with **`billing-demo.md` deprecated stub** (`billing-demo.md` L1–16`) | -| REQ-036 | MET | `App.tsx` L38–50 | Matches ProtectedRoute + nested Billing layout pattern | -| REQ-037 | MET | `BillingTabs.web.tsx` | Tab routes implemented | -| REQ-038 | MET | `UserMenu.web.tsx` L76–97 | Order Profile → Billing → Dashboard; CreditCard used | -| REQ-039 | PARTIAL | `BillingOverviewPage.tsx` | Structure broadly matches; **cancelled-pending lacks explicit “reactivate” button** — copy routes users to portal (`BillingOverviewPage.tsx` L131–137`) vs UI spec matrix expecting **reactivate control** (`beakerstack-billing-ui-v1.md` L281`) | -| REQ-040 | MET | `BillingUsagePage.tsx` + billing components | Reset semantics + meter + limits surfaces implemented (verified via imports / structure in Phase 2 survey) | -| REQ-041 | MET | `BillingPlansPage.tsx`, constraints | Cadence query params + downgrade modal + blockers present in surveyed sections | -| REQ-042 | MET | `useInvoices.ts` default **pageSize 20** (`L13–14`) | Matches “next 20” intent | -| REQ-043 | PARTIAL | `useBillingState.ts`, Overview banners | **Nine-state matrix not fully implemented**: **`downgrade_pending` never emitted** (`useBillingState.ts` L8–10`, `deriveKind` L30–77` lacks downgrade scheduling state); cancelled-pending **reactivate affordance** differs | -| REQ-044 | MET | migrations + webhook | Monthly/annual columns + invoices table | -| REQ-045 | MET | `stripe-webhook/index.ts` | Invoice lifecycle upserts | -| REQ-046 | DIVERGENT | `stripe-webhook/index.ts` L350–354 | Spec: **log for reconciliation if missing subscription row** (`beakerstack-billing-ui-v1.md` L385`). Code **`throw new Error(...)`\*\* failing webhook processing | -| REQ-047 | MET | `UsageIndicator.types.ts`, badge components | Expanded variant + SubscriptionStatusBadge shipped | -| REQ-048 | MET | `DashboardPage.tsx`, `App.tsx` | Manage billing link; no `/billing-demo` route | -| REQ-049 | AMBIGUOUS | `apps/mobile/.../BillingScreen.tsx` | Spec allows follow-up; presence of screen suggests partial parity — **full matrix/visual parity not verified** | - ---- - -### Detailed findings by severity - -#### Blocking - -None identified as absolute ship-blockers solely from static reading; **closest operational risks**: - -1. **`syncInvoiceRow` throws without subscription-customer linkage** — undermines webhook reliability vs explicit reconciliation guidance. - -```350:354:supabase/functions/stripe-webhook/index.ts - if (!userId) { - throw new Error( - `No subscription row for Stripe customer ${customerId} (invoice ${invoice.id}); retry after checkout syncs.` - ); - } -``` - -Violates UI spec language: - -> “If no subscription exists yet (rare timing edge case), **log** to `billing_webhook_events` and let a later subscription-created event reconcile.” (`beakerstack-billing-ui-v1.md` L385`) - -#### Significant - -1. **`cancelSubscriptionImmediately` not exposed in `@beakerstack/billing` hooks** despite Edge implementation (`billing-stripe` **`cancel_immediately`**). - -```242:261:supabase/functions/billing-stripe/index.ts -async function handleCancel( - admin: ReturnType, - userId: string, - body: Body -): Promise { - ... - await stripe.subscriptions.cancel(sub.stripe_subscription_id); - return jsonResponse({ ok: true }); -} -``` - -Core spec public API lists **`cancelSubscriptionImmediately`** (`beakerstac-billing-v1.md` L308–311`). - -2. **Checkout ignores configured trials** — core spec ties trials to **`trialPeriodDays`** on plans and checkout-created subscriptions (`beakerstac-billing-v1.md` L539–542`). **`billing-stripe`Checkout.Session.create** includes **no`subscription_data.trial_period_days`** (`billing-stripe/index.ts` L117–135`). - -3. **`PricingTable` API diverges from core spec** — props and UX don’t match documented component contract (`beakerstac-billing-v1.md` L359–367`vs`PricingTable.types.ts`/`PricingTable.web.tsx`). - -4. **Billing UI state matrix omits “Post-downgrade-pending” / `downgrade_pending`** — explicitly acknowledged as unimplemented in source comments: - -```8:10:packages/billing/src/hooks/useBillingState.ts - * `downgrade_pending` is reserved for a future `scheduled_plan_id` / subscription schedule; v1 does not set it. -``` - -UI spec matrix row requires Overview banner + Plans indicators (`beakerstack-billing-ui-v1.md` L283–285`). - -5. **`recordUsageEvent` metadata plumbing incomplete at hook layer** — RPC accepts **`p_metadata`** (`20250424120000_billing_v1.sql` L223–252`), hook hardcodes `{}`: - -```31:38:packages/billing/src/hooks/useRecordUsage.ts - const { error: rpcErr } = await supabase.rpc( - 'billing_record_usage_event', - { - p_product_id: config.productId, - p_event_type: meterKey, - p_quantity: quantity, - p_metadata: {}, - } - ); -``` - -Core API signature includes **`metadata?: Record`** (`beakerstac-billing-v1.md` L271–278`). - -#### Minor - -1. **Prop naming drift:** `FeatureGate` **`feature`** vs **`featureName`** (`FeatureGate.types.ts` vs `beakerstac-billing-v1.md` L428–434`); **`UpgradePrompt`** **`targetTier`** vs **`suggestedPlanId`** (`UpgradePrompt.types.ts` vs core spec). - -2. **`hasExceededLimit` / `getPublicPlans` naming:** Behavior exists under RPC / **`usePlanCatalog`**, but not as documented standalone TS surface — portability for non-React consumers weaker than spec prose. - -3. **Documentation path drift:** Appendix insists **`billing-demo.md`** as CLI guide anchor (`beakerstac-billing-v1.md` L837`). Repo intentionally moved content: - -```1:4:apps/web/docs/billing-demo.md -# Billing demo (deprecated) - -> **Replaced by** production billing at **`/billing`** ... -``` - -4. **Cancelled subscription UX:** Overview banner tells users to reactivate via portal (`BillingOverviewPage.tsx` L131–137`) rather than providing an explicit **reactivate** CTA per UI table (`beakerstack-billing-ui-v1.md` L281`). - ---- - -## Phase 5 — Reverse check (code vs spec) - -Sample of **`billing-v1` vs `main`** changes **without** a direct REQ counterpart — categorized. - -### Infrastructure / scaffolding - -- **`packages/shared` primitives** (`Button`, `Modal`, `Skeleton`) — UI spec §10 open questions anticipated primitives for billing pages (`beakerstack-billing-ui-v1.md` L428–432`). -- **CI / deploy workflows** (`\.github/workflows/*.yml`) — operational wiring for Edge Functions and previews. -- **`packages/test-utils`, coverage scripts** (`scripts/merge-coverage.js`) — test hygiene. -- **Symlinked / mirrored skill docs** (`.agents`, `.augment`, `.claude` paths in diff stat) — repo housekeeping, not billing behavior. - -### Undocumented features (relative to specs) - -- **`update_subscription` / cadence switching** via **`billing-stripe`** (`billing-stripe/index.ts` L167–215`) — **extends** core checkout/downgrade API; aligns with UI cadence spec but not enumerated as named TS functions in core billing spec §Public API. -- **Realtime subscription refresh** (`BillingProvider.tsx` L119–153`) — improves UX beyond explicit spec text. - -### Possible scope creep / ancillary changes - -- **`FormButton` refactors** (`packages/shared/src/components/forms/FormButton.*`) — adjacent UI primitive churn beyond billing-only scope (may support Modal/Button adoption). -- **`skills-lock.json`, `.changeset`** — release/process artifacts. - ---- - -## 5 — Open questions and assumptions - -1. **Whether core spec “Public API” MUST be exported verbatim as functions** vs hooks/RPC — interpreted as **functional equivalence acceptable for React monorepo**, but non-React adopters would notice gaps (`hasExceededLimit`, `cancelSubscriptionImmediately`). -2. **`stripe.listen` documentation anchor file** — appendix vs repo consolidation — treat **`billing-testing.md`** as authoritative **unless** strict appendix compliance is required. -3. **Mobile parity depth** — `BillingScreen.tsx` exists; full four-route parity vs web was **not** line-verified for this audit. - ---- - -## 1 — Executive summary - -The **`billing-v1`** branch delivers the bulk of **BeakerStack Billing v1** and **Billing UI v1**: schema/RPCs in migrations, **`stripe-webhook`** and **`billing-stripe`** Edge Functions, a substantial **`@beakerstack/billing`** hook/component library, **`/billing`** routes with Overview/Usage/Plans/Invoices, cadence-aware Stripe checkout and subscription updates, invoice mirroring, pg tests, and docs consolidated under **`apps/web/docs/billing-testing.md`**. Strong alignment exists on **Stripe-as-source-of-truth**, **free-tier ensure RPC**, **usage period logic**, **RLS posture**, **invoice upserts**, and **PlanCard/BillingTabs** UX. - -Gaps cluster around **literal API/component contracts** in the core spec (**PricingTable props**, **`UpgradePrompt`/`FeatureGate` naming**, **`trialDays` checkout**, **`cancelSubscriptionImmediately` exposure**, **`recordUsage` metadata**), **UI state completeness** (**post-downgrade-pending**, richer cancelled-pending **reactivate** affordance), and **one webhook edge-case behavior** (**invoice sync throws** vs **log-and-reconcile**). Documentation location diverges from the appendix path (**`billing-demo.md`** vs **`billing-testing.md`**) by deliberate deprecation notes rather than omission of content. diff --git a/docs/specs/beakerstack-billing-ui-v1.md b/docs/specs/beakerstack-billing-ui-v1.md index 26ba8101..9bba240e 100644 --- a/docs/specs/beakerstack-billing-ui-v1.md +++ b/docs/specs/beakerstack-billing-ui-v1.md @@ -1,37 +1,39 @@ --- name: BeakerStack Billing UI v1 -overview: Polish the existing developer-demo billing surface into production-quality, B2C-flavored billing pages that match the existing visual language of the BeakerStack template. Adds /billing route family with Overview, Usage, Plans, and Invoices sub-pages. Extends the data model for monthly/annual cadence and invoice history. Stays scoped to a single route family without a global settings shell. +overview: Production-quality billing pages for the BeakerStack template. /billing route family (Overview, Usage, Plans, Invoices), monthly/annual cadence, invoice history. Shipped; see apps/web and packages/billing. todos: - id: routes-and-tabs content: Add /billing route family with BillingTabs sub-navigation, BillingProvider wrapping the route group, and avatar menu integration - status: pending + status: completed - id: db-additions content: Add stripe_price_id_monthly/annual to billing_plans, create billing_invoices table with RLS, extend webhook handler for invoice events - status: pending + status: completed - id: invoice-page content: Build BillingInvoicesPage with paginated table, status badges, and links to Stripe-hosted invoice/PDF URLs - status: pending + status: completed - id: overview-page content: Build BillingOverviewPage with current plan card, payment status banner, quick stats, and primary CTAs - status: pending + status: completed - id: usage-page content: Build BillingUsagePage with all meters, feature caps, and reset-date semantics rendered clearly - status: pending + status: completed - id: plans-page content: Build BillingPlansPage with monthly/annual toggle, three-up plan cards, current-plan affordance, constraint warnings on downgrade - status: pending + status: completed - id: state-matrix content: Implement the nine subscription states across all four pages with appropriate banners, badges, and inline messaging - status: pending + status: completed - id: components content: Add new shared components (PlanCard, PlanFeatureList, ConstraintWarning, InvoiceTable, BillingTabs) at the right boundary (packages/billing vs apps/web) - status: pending + status: completed isProject: false --- # BeakerStack Billing UI v1 -This spec polishes the existing developer-demo billing surface (`/billing-demo`) into production-quality billing pages that match the BeakerStack template's existing visual language. It adds a `/billing` route family with four sub-pages, extends the data model for monthly/annual subscription cadence and invoice history, and integrates with the existing avatar menu. It deliberately does NOT introduce a settings shell — billing is a sibling route to `/profile`, following the same page-shell pattern that already works. +> **Status:** Shipped in the BeakerStack template. Setup: [`docs/stripe-billing-setup.md`](../stripe-billing-setup.md). QA: [`apps/web/docs/billing-testing.md`](../../apps/web/docs/billing-testing.md). Core module: [`docs/specs/beakerstack-billing-v1.md`](./beakerstack-billing-v1.md). + +Production-quality billing pages matching the template's existing visual language. The `/billing` route family has four sub-pages; the data model supports monthly/annual cadence and invoice history. Billing is a sibling route to `/profile` (no global settings shell). ## 1. Visual conventions @@ -95,7 +97,7 @@ All four routes are protected via `ProtectedRoute.web`. All four are wrapped in ``` -The legacy `/billing-demo` route stays in place during migration and is removed once `/billing` ships. The "Billing demo" link on the dashboard is replaced with a "Manage billing" link to `/billing`. +The legacy `/billing-demo` route has been removed. The dashboard links **Manage billing** → `/billing`; the dashboard playground (`/dashboard`) exercises billing primitives for developers. ### Sub-navigation: BillingTabs @@ -402,18 +404,18 @@ These are explicitly out of scope. Document them in code comments where the temp - **Usage-based metered billing in Stripe.** Our metered features track usage in our DB and gate access; we do not report usage to Stripe Meters in v1. - **Multi-currency UI.** Display amounts in the currency stored on the invoice (Stripe-controlled). We don't convert or offer currency selection. -## 8. Migration and rollout - -1. Ship DB migration adding `stripe_price_id_monthly`/`annual` and `billing_invoices` table; backfill existing data. -2. Ship webhook handler updates; verify with Stripe CLI against local Edge Function. -3. Ship `packages/billing` additions (`SubscriptionStatusBadge`, `UsageIndicator` expanded variant). -4. Ship `apps/web/src/components/billing/` components. -5. Ship the four pages and routes; wire `BillingProvider` to the route group. -6. Update `UserMenu` (web + native) to add Billing item. -7. Replace dashboard's "Billing demo" link with "Manage billing" → `/billing`. -8. Verify all nine states render correctly using a combination of real Stripe test events (CLI) and `simulateUpgrade` for the demo modes that don't require Stripe traffic. -9. Remove `/billing-demo` route and component once parity is verified. -10. Mirror page structure to `apps/mobile` using existing native shell patterns; mobile parity can ship in a follow-up if web ships first. +## 8. Migration and rollout (completed) + +1. ~~Ship DB migration~~ — `stripe_price_id_monthly`/`annual`, `billing_invoices`, RLS. +2. ~~Webhook handler updates~~ — invoice events; verify via Stripe CLI ([`billing-testing.md`](../../apps/web/docs/billing-testing.md)). +3. ~~`packages/billing` additions~~ — badges, expanded `UsageIndicator`, shared components. +4. ~~`apps/web/src/components/billing/`~~ — shipped. +5. ~~Four pages and routes~~ — `BillingProvider` on route group. +6. ~~`UserMenu`~~ — Billing item (web + native where applicable). +7. ~~Dashboard link~~ — **Manage billing** → `/billing`. +8. ~~Nine-state matrix~~ — covered in billing-testing doc and unit tests. +9. ~~Remove `/billing-demo`~~ — removed; use `/billing`. +10. **Mobile parity** — web-first; native links to web for full `/billing` UI where needed (`BillingScreen` smoke surface). ## 9. Testing diff --git a/docs/specs/beakerstack-billing-v1.md b/docs/specs/beakerstack-billing-v1.md index 2706b8aa..1ab9fec2 100644 --- a/docs/specs/beakerstack-billing-v1.md +++ b/docs/specs/beakerstack-billing-v1.md @@ -7,6 +7,8 @@ _A module within BeakerStack, open source MIT_ **Version:** 1.0 Draft **Purpose:** Technical specification for minimal v1 billing features supporting B2C SaaS projects +> **Status:** Implemented in `@beakerstack/billing` and the BeakerStack template. This spec may lag the codebase in minor API naming; use [`apps/web/docs/billing-testing.md`](../../apps/web/docs/billing-testing.md) for operational QA. + --- ## Purpose @@ -814,9 +816,12 @@ Plan ids (e.g. `beakerstack_free`, `beakerstack_pro`, `beakerstack_max`) and Str **Rationale:** “Collection” / “saved item” maps directly to hierarchical caps (containers and items), reads as neutral B2C product surface (similar affordances to Readwise-style saving), and does not imply a specific Artificer shipping product. Alternates with the same entitlement shape: projects/tasks with “AI breakdown,” or notebooks/notes with “AI rewrite.” -### Three entitlement surfaces on one route +### Three entitlement surfaces (template) + +The template exercises all three shapes in two places: -All three shapes are reachable from a single **`/billing-demo`** route (or a settings section that hosts the same component tree). +- **Dashboard playground** (`/dashboard`) — annotated primitives for metered usage, numeric caps (collections/items), and boolean gates (see `apps/web/src/pages/DashboardPage.tsx`). +- **Production billing** (`/billing`, `/billing/usage`, …) — product-style pages per [beakerstack-billing-ui-v1.md](./beakerstack-billing-ui-v1.md). 1. **Metered action surface** — Wired to `hasExceededLimit`, `recordUsageEvent`, `UsageIndicator`, and `UpgradePrompt` at the boundary. Must visibly show **reset-date semantics**: Free = **calendar month**; paid = **billing period** (from `getRemainingUsage` / `periodEnd`). 2. **Numeric feature value surface** — Uses `getFeatureValue` for **containers per account** and **items per container**. Does **not** use the usage events table for those caps. Attempting to exceed limits shows an **inline** limit message with upgrade CTA. @@ -824,18 +829,18 @@ All three shapes are reachable from a single **`/billing-demo`** route (or a set ### Module vs app boundary -| Concern | `packages/billing` | `apps/web` / `apps/mobile` (template) | -| --------------------------------------------------------------------- | ------------------ | --------------------------------------- | -| Types; Zod schema for **config shape** | Yes | Provides concrete config | -| Client API; hooks; UI (web + native) | Yes | Wires routes, copy, ids | -| Stripe: checkout, portal, schedule cancel to free, cancel immediately | Yes | URLs, env, product id | -| Tier names, marketing copy, domain vocabulary | No | Yes | -| Feature keys, metered `eventType` strings | No | Yes | -| `simulateUpgrade` / demo usage reset | **Must not** | Yes (template-only) | -| Demo banner (“not real billing”) | No | Yes | -| `/billing-demo`, collections/items demo state | No | Yes (e.g. `apps/web/src/billing-demo/`) | +| Concern | `packages/billing` | `apps/web` / `apps/mobile` (template) | +| --------------------------------------------------------------------- | ------------------ | ---------------------------------------------- | +| Types; Zod schema for **config shape** | Yes | Provides concrete config | +| Client API; hooks; UI (web + native) | Yes | Wires routes, copy, ids | +| Stripe: checkout, portal, schedule cancel to free, cancel immediately | Yes | URLs, env, product id | +| Tier names, marketing copy, domain vocabulary | No | Yes | +| Feature keys, metered `eventType` strings | No | Yes | +| `simulateUpgrade` / demo usage reset | **Must not** | Yes (template-only RPCs) | +| Demo banner (“not real billing”) | No | Yes (dashboard + optional demo controls) | +| Collections/items demo state, dashboard playground | No | Yes (`apps/web/src/billing/`, dashboard pages) | -**Principle:** A consumer may delete `apps/web/src/billing-demo/` (and mobile equivalents) and retain a fully working `@beakerstack/billing`. Adapting the demo = rename domain + swap keys, **without** editing `packages/billing`. +**Principle:** A consumer may remove the dashboard playground and demo RPC wiring and retain a fully working `@beakerstack/billing`. Adapting the template = rename domain + swap keys, **without** editing `packages/billing`. ### `simulateUpgrade` and demo usage reset (template only) @@ -848,7 +853,7 @@ All three shapes are reachable from a single **`/billing-demo`** route (or a set **Primary approach:** [Stripe CLI](https://stripe.com/docs/stripe-cli) forwarding to the **local** `stripe-webhook` Edge Function, using real Stripe payload shapes and signature verification. -Document in **`apps/web/docs/billing-testing.md`** (canonical CLI walkthrough; **`apps/web/docs/billing-demo.md`** is a short index that points here), including exact commands for: +Document in **`apps/web/docs/billing-testing.md`** (canonical CLI walkthrough), including exact commands for: - `invoice.payment_failed` (past_due / dunning behavior) - `customer.subscription.trial_will_end` @@ -858,16 +863,12 @@ Document in **`apps/web/docs/billing-testing.md`** (canonical CLI walkthrough; * **Explicit non-goal for v1:** No admin **“simulate webhook”** UI in the template — avoids payload drift and scope creep. -### Implementation todos (BeakerStack repo) +### Implementation checklist (BeakerStack repo — shipped) -When executing in BeakerStack, track at least: - -- DB migration: billing tables, RLS, core RPCs (`record_usage_event`, reads, `ensure_free`), pgTAP; ship SQL only under **`supabase/migrations/` at the repo root** (same layout for web and mobile devs — run Supabase CLI migration commands from root). +- DB migration: billing tables, RLS, core RPCs (`record_usage_event`, reads, `ensure_free`), pgTAP under **`supabase/migrations/`** at repo root. - Edge Functions: `stripe-webhook`, `billing-stripe`; secrets and CI deploy. - `packages/billing`: generic module (no product vocabulary). -- Stripe sync script driven by **app-supplied** billing config. -- App: concrete Free/Pro/Max config; `billing-demo` route with three surfaces; mobile mirror. -- Template RPCs: `simulateUpgrade` + usage reset with env gating. -- Docs: `apps/web/docs/billing-testing.md` with Stripe CLI scenario walkthrough (`billing-demo.md` cross-links for legacy appendix references). - -The Cursor implementation plan file (`beakerstack_billing_v1_*.plan.md`) may carry the same content in execution form. +- Stripe sync script driven by **app-supplied** billing config (`npm run billing:sync-stripe`). +- App: Free/Pro/Max config; `/billing` route family; dashboard playground; mobile smoke screen. +- Template RPCs: demo upgrade + usage reset with server-side gating. +- Docs: [`docs/stripe-billing-setup.md`](../stripe-billing-setup.md), [`apps/web/docs/billing-testing.md`](../../apps/web/docs/billing-testing.md). diff --git a/docs/specs/beakerstack-dashboard-billing-demo-v1.md b/docs/specs/beakerstack-dashboard-billing-demo-v1.md deleted file mode 100644 index bdb9dc63..00000000 --- a/docs/specs/beakerstack-dashboard-billing-demo-v1.md +++ /dev/null @@ -1,259 +0,0 @@ ---- -name: BeakerStack Dashboard Billing Demo v1 -overview: Replace the empty dashboard with an annotated playground that exercises every billing primitive in a clearly-labeled, developer-facing format. Each section demonstrates one capability of @beakerstack/billing with intro copy, working controls, visible state, and a code reference. Demo controls live at the bottom in an explicit "app layer, not package" section. Deliberately reads as a sandbox, not a fake product. -todos: - - id: dashboard-shell - content: Replace the existing dashboard placeholder with the new playground layout, framing copy, and section structure - status: pending - - id: metered-section - content: Build the metered usage section with UsageIndicator, simulate button, and visible AI summarize result area - status: pending - - id: numeric-caps-section - content: Build the numeric caps section with collections list, add-collection button, per-collection items list, and add-item buttons - status: pending - - id: boolean-gates-section - content: Build the boolean feature gates section with FeatureGate examples for Feature A and Feature B - status: pending - - id: demo-controls - content: Build the demo controls section at the bottom, gated to VITE_BILLING_DEMO_MODE, with plan switcher and usage reset - status: pending - - id: ai-summarize-result - content: Wire the metered action to produce a visible result (lorem ipsum or Claude API call) into a result area - status: pending -isProject: false ---- - -# BeakerStack Dashboard Billing Demo v1 - -The empty dashboard becomes an annotated playground that exercises every primitive in `@beakerstack/billing`. The polished `/billing/*` pages already prove the stack can produce real-feeling product UI; the dashboard's job is different — it teaches developers how the building blocks compose. The format is deliberately sandbox-like: labeled sections, intro copy, obvious controls, code references. It must NOT look like a fake product. - -## 1. Page-level framing - -Replace the current dashboard placeholder content. Keep the existing `AppHeader.web` and the `min-h-screen bg-gray-50` page-shell pattern that already wraps authenticated routes. Centered container at `max-w-[800px]` matching profile page width. - -**Top of page (above all sections):** - -- Page title: "Welcome to BeakerStack" (`text-2xl font-bold`) -- Lede paragraph: 2-3 sentences explaining what this dashboard is. Suggested copy: - - > This dashboard is a sandbox for exercising the billing primitives in `@beakerstack/billing` directly. Each section below demonstrates one capability with working controls and code references. For the polished, production-style billing UI, visit [Billing](/billing). - -- Inline links row below the lede: - - "View polished billing pages →" → `/billing` - - "Read the integration guide →" → external docs link (use `#` placeholder if no doc exists yet, with a TODO comment) - -Sections render below this framing in the order specified in §3. - -## 2. Section component pattern - -Build a reusable `` component (lives in `apps/web/src/components/dashboard/`) used by every section. Props: - -```ts -type DashboardDemoSectionProps = { - title: string; // section heading - demonstrates: string; // short list of API names being shown - description: string; // 2-3 sentence intro copy - codeReference: string; // single-line monospace code reference - children: React.ReactNode; // the actual interactive controls - variant?: 'default' | 'demo-mode'; // demo-mode gets a distinct visual treatment -}; -``` - -Visual treatment: - -- White card on `bg-gray-50` page background, matching existing card pattern (`rounded-xl border border-gray-200 bg-white p-6`). -- Title: `text-lg font-semibold`. -- "Demonstrates:" label in `text-xs uppercase tracking-wide text-gray-500`, followed by the API names in `text-sm text-gray-700 font-mono`. -- Description: `text-sm text-gray-600 mt-2`. -- Children render in a content area below description with `mt-4`. -- Code reference rendered at the bottom of the card in a small `text-xs font-mono text-gray-500` line, prefixed with `// ` to make it visually distinct as a code comment. -- Sections separated by `space-y-6` in the parent container. -- `demo-mode` variant uses a dashed border (`border-dashed border-gray-300`) and adds a small "Demo mode only" badge in the top-right corner to visually distinguish it from the package-level sections. - -## 3. Sections (in render order) - -### 3.1 Metered usage - -``` -title: "Metered usage" -demonstrates: "useUsage, useRecordUsage, UsageIndicator" -description: "The AI summarize action is metered. Free tier allows 30 per - month; Pro 500; Max unlimited. Usage resets monthly for free - users and per billing period for paid users." -codeReference: 'useRecordUsage("ai_summarize")' -``` - -**Children:** - -1. `` showing current usage, cap, and reset date. -2. A primary button: "Simulate AI summarize". On click: - - Calls `useRecordUsage("ai_summarize")` callback. - - If at cap, button shows disabled state with "Limit reached" text and a small "Upgrade" link to `/billing/plans`. - - On success, appends a fake AI summary result to a result area below the button (see below). -3. **Result area:** below the button, a bordered box with `bg-gray-50` showing the result of the most recent simulate action. Initial state: empty, with placeholder text "Click 'Simulate AI summarize' to generate a result." Each click prepends a new result with a timestamp. - -**AI summarize result content:** generate a short lorem-ipsum-style fake summary string (3-5 lines) to make the action feel real without making a real API call by default. Optionally, if `VITE_DEMO_USE_REAL_AI=true` is set, call the Claude API via an Edge Function to generate a real summary. Default to fake content so the demo works without API keys configured. Keep the last 3 results visible; older ones drop off. - -### 3.2 Numeric feature caps - -``` -title: "Numeric feature caps" -demonstrates: "useFeature with numeric values, hierarchical container caps" -description: "Free tier allows 2 collections with 3 items per collection. - Pro allows unlimited collections with 25 items each. Max - allows unlimited both. Caps are enforced via useFeature - returning a numeric value rather than going through usage - events." -codeReference: 'useFeature("max_collections")' -``` - -**Children:** - -1. Header row: "Collections: N of [limit]" where [limit] is the numeric cap or "∞" for unlimited. Inline button: "Add collection" (primary). Disabled at cap with "Limit reached" tooltip. -2. List of collections (existing data model carries over). Each collection rendered as a small card with: - - Collection ID (truncated UUID or short label) - - "Items: M of [limit]" - - Inline "Add item" button (smaller, secondary). Disabled at cap. - - Inline "Delete" button (small, destructive icon-only) — needed so users can resolve constraint warnings on the Plans page by deleting collections. -3. If user has zero collections, show empty state: "No collections yet. Click 'Add collection' to start." in `text-sm text-gray-500`. - -**Behavior:** - -- Adding a collection creates a new entry in the existing `collections` table (or whatever was used in `/billing-demo`) with a generated ID. -- Adding an item creates an entry in the items table linked to that collection. -- Deleting a collection cascades to its items. -- The cap displays update reactively as the user adds/removes; the existing `useFeature` hook should already drive this if the provider re-fetches on mutation. If not, document a manual refresh in code. - -### 3.3 Boolean feature gates - -``` -title: "Boolean feature gates" -demonstrates: "FeatureGate component, useFeature for boolean features" -description: "Feature A unlocks at Pro and above. Feature B unlocks at Max - only. Each section below shows the FeatureGate behavior at - your current plan: enabled features render their content; - disabled features render the fallback." -codeReference: '' -``` - -**Children:** - -Two stacked sub-blocks, one per feature. Each sub-block contains: - -``` -Feature A (requires Pro) -[FeatureGate renders one of:] - Enabled state: "✓ Feature A is enabled for your plan." (green text, check icon) - Disabled state: A small inline upgrade prompt — "Feature A requires Pro." - with "Upgrade →" link to /billing/plans -``` - -Same pattern for Feature B (requires Max). Use the existing `FeatureGate` component from `packages/billing` with a custom `fallback` prop containing the upgrade prompt JSX. - -Below the two sub-blocks, render a single line showing the imperative-style equivalent for developers who prefer that pattern: - -> Imperative equivalent: `const { enabled } = useFeature("feature_a")` → currently `true`/`false` - -This mirrors the live state of the gate above and demonstrates that both declarative and imperative patterns are available. - -### 3.4 Demo controls (variant: `demo-mode`) - -``` -title: "Demo controls" -demonstrates: "App-layer demo affordances (NOT in @beakerstack/billing)" -description: "These controls exist only when VITE_BILLING_DEMO_MODE=true and - demo_billing_mode is enabled in the database. They live in the - app layer using a service-role RPC and are explicitly NOT part - of @beakerstack/billing. Production apps should remove this - section." -codeReference: 'await supabase.rpc("simulate_upgrade", { plan_id })' -``` - -**Render condition:** entire section hidden unless `import.meta.env.VITE_BILLING_DEMO_MODE === "true"`. When demo mode is off, the section does not render at all (not even greyed out — completely absent). - -**Children:** - -1. Current plan display: "Current plan: [Free/Pro/Max]" in `text-sm`. -2. Plan switcher: three buttons in a row — "Switch to Free", "Switch to Pro", "Switch to Max". Active plan's button is disabled. Each button calls the `simulate_upgrade` RPC. -3. Reset usage button (separate, secondary styling): "Reset all usage counters". Calls a `reset_usage` RPC that zeros `billing_usage_aggregates` for the current user. -4. Inline note in `text-xs text-gray-500`: "These actions take effect immediately and bypass Stripe. Do not deploy to production." - -**Visual distinction:** the `demo-mode` variant of `` uses dashed borders and the "Demo mode only" badge to make this section visibly different from the three above. The reader should immediately understand "this is the test panel, not part of the framework." - -## 4. Layout summary - -``` -┌─────────────────────────────────────────────────────┐ -│ AppHeader (existing) │ -├─────────────────────────────────────────────────────┤ -│ │ -│ Welcome to BeakerStack │ -│ [lede paragraph] │ -│ [view polished billing →] [integration guide →] │ -│ │ -│ ┌─ Metered usage ─────────────────────────────────┐ │ -│ │ DEMONSTRATES: useUsage, useRecordUsage, … │ │ -│ │ [description] │ │ -│ │ [UsageIndicator] │ │ -│ │ [Simulate button] [Result area] │ │ -│ │ // useRecordUsage("ai_summarize") │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Numeric feature caps ──────────────────────────┐ │ -│ │ … (similar structure) │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Boolean feature gates ─────────────────────────┐ │ -│ │ … (similar structure) │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ╔═ Demo controls (dashed border, demo-mode only) ═╗ │ -│ ║ … (plan switcher, reset usage) ║ │ -│ ╚═════════════════════════════════════════════════╝ │ -│ │ -└─────────────────────────────────────────────────────┘ -``` - -## 5. Component inventory - -| Component | Location | Purpose | -| ------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `` | `apps/web/src/components/dashboard/` | Reusable section wrapper with title, demonstrates, description, code reference, optional demo-mode variant | -| `` | `apps/web/src/components/dashboard/` | Section 3.1 contents | -| `` | `apps/web/src/components/dashboard/` | Section 3.2 contents | -| `` | `apps/web/src/components/dashboard/` | Section 3.3 contents | -| `` | `apps/web/src/components/dashboard/` | Section 3.4 contents, env-gated | -| `` | `apps/web/src/components/dashboard/` | The result area showing the last 3 fake/real summaries with timestamps | - -All components use existing `packages/billing` hooks and components for state and gating. No new package-level primitives required for this spec. - -## 6. Mobile parity - -Mirror the same structure on `apps/mobile/src/screens/DashboardScreen.tsx` using native equivalents. The native version uses a vertical scroll container, native `` for buttons, and the existing `.native.tsx` versions of billing components. Mobile demo controls live at the bottom of the scroll, same as web. - -If mobile parity is non-trivial because some primitives lack native variants, ship web first and document the mobile follow-up. - -## 7. Migration - -1. Implement the new dashboard playground. -2. Verify all sections render and interact correctly across Free, Pro, and Max plans (use demo controls to switch). -3. Verify demo controls section is fully absent when `VITE_BILLING_DEMO_MODE` is unset. -4. Remove the legacy `/billing-demo` route and `BillingDemoPage` component. -5. Update any in-app links that pointed to `/billing-demo` (likely just the dashboard placeholder, which is being replaced anyway). - -## 8. Non-goals - -- No analytics or tracking on the demo controls. -- No persistence of fake AI summarize results across sessions; in-memory only. -- No real Claude API integration unless `VITE_DEMO_USE_REAL_AI=true` is explicitly set; default is fake lorem ipsum. -- No additional billing primitives in `packages/billing` for this spec — the dashboard composes existing primitives. -- No styling system or design tokens introduced here; reuse existing Tailwind patterns from profile/billing pages. -- No "tour" or onboarding overlay; the section copy IS the tour. - -## 9. Open questions for the agent to flag, not decide - -- Does the existing `useFeature` hook return numeric values directly, or does it require a separate accessor? Match whatever exists; do not add new hook signatures for this spec. -- Is there an existing `simulate_upgrade` RPC from earlier work? Reuse if present; if not, propose its schema in the PR. Same for `reset_usage`. -- Where does the lorem-ipsum fake summary content come from? Suggest a small `apps/web/src/lib/fakeAi.ts` module with 5-10 canned summary strings rotated by a counter or random pick. - -These are flagged so the agent surfaces decisions in the PR rather than guessing. diff --git a/infra/aws/functions/PRPathRouter.js b/infra/aws/functions/PRPathRouter.js index e1887550..e6ace610 100644 --- a/infra/aws/functions/PRPathRouter.js +++ b/infra/aws/functions/PRPathRouter.js @@ -3,6 +3,24 @@ function handler(event) { var uri = request.uri || '/'; var previewPrefixBase = '%%PREVIEW_PREFIX%%'; + // Return a synthetic robots.txt before any S3 routing to avoid the + // CloudFront HTML error-page fallback (no file at bucket root → 404 → + // HTML) that causes Lighthouse to fail the robots.txt validity check. + // + // Disallow the deploy origin by default, then explicitly Allow the PR + // prefix so Lighthouse (and Google) treat /pr-/... as crawlable. + // Longest-prefix matching means /pr-152/ is allowed while stray apex + // paths remain blocked. Uses previewPrefixBase (not a hardcoded "pr-") + // so custom PREVIEW_PREFIX stacks stay correct. + if (uri === '/robots.txt') { + return { + statusCode: 200, + statusDescription: 'OK', + headers: { 'content-type': { value: 'text/plain' } }, + body: 'User-agent: *\nDisallow: /\nAllow: /' + previewPrefixBase + '\n', + }; + } + var prPathPattern = new RegExp('^/(' + previewPrefixBase + '\\d+)(/.*)?$'); var match = uri.match(prPathPattern); diff --git a/infra/aws/functions/PreviewAuthFunction.js b/infra/aws/functions/PreviewAuthFunction.js new file mode 100644 index 00000000..036d24c5 --- /dev/null +++ b/infra/aws/functions/PreviewAuthFunction.js @@ -0,0 +1,58 @@ +// CloudFront Function: reads pre-computed signed cookie values from query params +// and returns a synthetic 302 that sets all three CloudFront signed cookies. +// This runs on /_preview-auth — the path that has NO TrustedKeyGroups, so +// unauthenticated users can reach it to acquire their access cookies. +// +// NOTE: this file is the canonical source. The inline FunctionCode block in +// infra/aws/pr-preview-stack.yml must be kept identical. Both are deployed; +// the stack inline copy is what CloudFormation actually provisions. +function handler(event) { + var request = event.request; + var qs = request.querystring; + var policy = qs.policy ? qs.policy.value : null; + var sig = qs.sig ? qs.sig.value : null; + var kid = qs.kid ? qs.kid.value : null; + + if (!policy || !sig || !kid) { + return { + statusCode: 400, + statusDescription: 'Bad Request', + headers: { 'content-type': { value: 'text/plain' } }, + body: 'Missing required parameters: policy, sig, kid.', + }; + } + + // Restrict dest to a local path to prevent open redirect. + // decodeURIComponent throws on malformed % sequences — fall back to '/'. + // Also block protocol-relative URLs like //evil.com which startsWith('/') but are external. + var dest; + try { + dest = qs.dest ? decodeURIComponent(qs.dest.value) : '/'; + } catch (e) { + dest = '/'; + } + if (!dest.startsWith('/') || dest.startsWith('//')) dest = '/'; + + return { + statusCode: 302, + statusDescription: 'Found', + headers: { + location: { value: dest }, + 'cache-control': { value: 'no-store, no-cache, must-revalidate' }, + }, + cookies: { + 'CloudFront-Policy': { + value: policy, + attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800', + }, + 'CloudFront-Signature': { + value: sig, + attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800', + }, + 'CloudFront-Key-Pair-Id': { + value: kid, + attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800', + }, + }, + }; +} diff --git a/infra/aws/pr-preview-stack.yml b/infra/aws/pr-preview-stack.yml index 40c6587b..f28944bc 100644 --- a/infra/aws/pr-preview-stack.yml +++ b/infra/aws/pr-preview-stack.yml @@ -2,7 +2,8 @@ AWSTemplateFormatVersion: '2010-09-09' Description: > Three-environment infrastructure stack (production, staging, preview). Provisions S3 storage, CloudFront distributions, logging, DNS records, and - CloudFront Function for path-based PR preview routing on deploy.beakerstack.com. + CloudFront Functions for path-based PR preview routing and signed-cookie + access control on deploy.beakerstack.com and staging.beakerstack.com. Metadata: AWS::CloudFormation::Interface: @@ -23,6 +24,18 @@ Metadata: default: Preview Settings Parameters: - PreviewPrefix + - Label: + default: Access Control + Parameters: + - PreviewAccessControl + - StagingAccessControl + - CloudFrontSigningPublicKeyId + - Label: + default: Supabase Origins (CSP) + Parameters: + - ProductionSupabaseUrl + - StagingSupabaseUrl + - PreviewSupabaseUrl ParameterLabels: DomainName: default: Root domain (e.g. beakerstack.com) @@ -38,6 +51,18 @@ Metadata: default: CloudFront log retention (days) PreviewPrefix: default: Prefix for preview deployments (e.g. pr-) + PreviewAccessControl: + default: Access control mode for PR preview (deploy) distribution + StagingAccessControl: + default: Access control mode for staging distribution + CloudFrontSigningPublicKeyId: + default: CloudFront public key ID for signed-cookie validation (leave blank when using public mode) + ProductionSupabaseUrl: + default: Production Supabase bare origin (e.g. https://abc.supabase.co) + StagingSupabaseUrl: + default: Staging Supabase bare origin (e.g. https://def.supabase.co) + PreviewSupabaseUrl: + default: PR preview Supabase bare origin (e.g. https://ghi.supabase.co) Parameters: DomainName: @@ -68,10 +93,56 @@ Parameters: Type: String Default: pr- Description: Prefix used for preview folders (e.g. pr-123) + PreviewAccessControl: + Type: String + AllowedValues: [public, signed-cookies] + Default: public + Description: > + Set to signed-cookies to require CloudFront signed cookies for the PR preview + (deploy) distribution. Run scripts/pr-preview/setup-signed-cookies.sh first. + StagingAccessControl: + Type: String + AllowedValues: [public, signed-cookies] + Default: public + Description: > + Set to signed-cookies to require CloudFront signed cookies for the staging + distribution. Run scripts/pr-preview/setup-signed-cookies.sh first. + CloudFrontSigningPublicKeyId: + Type: String + Default: '' + Description: > + ID of the CloudFront public key used to validate signed cookies. Required when + PreviewAccessControl or StagingAccessControl is signed-cookies. + ProductionSupabaseUrl: + Type: String + Default: '' + Description: > + Bare origin of the production Supabase project (e.g. https://abc.supabase.co). + Added to img-src in the CSP to allow Supabase Storage profile images. + Sourced from PRODUCTION_SUPABASE_URL GitHub secret — path suffix stripped by bootstrap script. + StagingSupabaseUrl: + Type: String + Default: '' + Description: > + Bare origin of the staging Supabase project (e.g. https://def.supabase.co). + Added to img-src in the CSP to allow Supabase Storage profile images. + Sourced from STAGING_SUPABASE_URL GitHub secret — path suffix stripped by bootstrap script. + PreviewSupabaseUrl: + Type: String + Default: '' + Description: > + Bare origin of the PR preview Supabase project (e.g. https://ghi.supabase.co). + Added to img-src in the CSP to allow Supabase Storage profile images. + Sourced from PREVIEW_SUPABASE_URL GitHub secret — path suffix stripped by bootstrap script. Conditions: UseProvidedLogsBucketName: !Not [!Equals [!Ref LogsBucketName, '']] EncryptBuckets: !Equals [!Ref EnableS3BucketEncryption, 'true'] + EnablePreviewSignedCookies: !Equals [!Ref PreviewAccessControl, signed-cookies] + EnableStagingSignedCookies: !Equals [!Ref StagingAccessControl, signed-cookies] + EnableAnySignedCookies: !Or + - !Condition EnablePreviewSignedCookies + - !Condition EnableStagingSignedCookies Resources: # Shared logs bucket for all CloudFront distributions @@ -359,12 +430,131 @@ Resources: return request; } + # Cookie setter for the /_preview-auth bootstrap path. + # Reads pre-computed signed cookie values from query params and returns a 302 + # that sets all three CloudFront signed cookies. This function has no crypto; + # cookie values are pre-computed by CI using the private signing key. + PreviewAuthFunction: + Type: AWS::CloudFront::Function + Properties: + Name: !Sub '${AWS::StackName}-PreviewAuth' + AutoPublish: true + FunctionConfig: + Comment: Sets CloudFront signed cookies from pre-computed query param values + Runtime: cloudfront-js-2.0 + FunctionCode: | + function handler(event) { + var request = event.request; + var qs = request.querystring; + var policy = qs.policy ? qs.policy.value : null; + var sig = qs.sig ? qs.sig.value : null; + var kid = qs.kid ? qs.kid.value : null; + if (!policy || !sig || !kid) { + return { + statusCode: 400, + statusDescription: 'Bad Request', + headers: { 'content-type': { value: 'text/plain' } }, + body: 'Missing required parameters: policy, sig, kid.' + }; + } + var dest; + try { dest = qs.dest ? decodeURIComponent(qs.dest.value) : '/'; } catch (e) { dest = '/'; } + if (!dest.startsWith('/') || dest.startsWith('//')) dest = '/'; + return { + statusCode: 302, + statusDescription: 'Found', + headers: { + location: { value: dest }, + 'cache-control': { value: 'no-store, no-cache, must-revalidate' } + }, + cookies: { + 'CloudFront-Policy': { value: policy, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Signature': { value: sig, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' }, + 'CloudFront-Key-Pair-Id': { value: kid, attributes: 'HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800' } + } + }; + } + + # Explicit no-cache policy for the /_preview-auth cookie-setter path. + # Uses a custom resource rather than the CachingDisabled managed policy UUID to avoid + # any account-level availability issues with AWS-managed policy IDs. + PreviewAuthCachePolicy: + Type: AWS::CloudFront::CachePolicy + Properties: + CachePolicyConfig: + Name: !Sub '${AWS::StackName}-preview-auth-no-cache' + Comment: Disables caching on the /_preview-auth cookie-setter path + DefaultTTL: 0 + MinTTL: 0 + MaxTTL: 1 + ParametersInCacheKeyAndForwardedToOrigin: + EnableAcceptEncodingGzip: false + EnableAcceptEncodingBrotli: false + CookiesConfig: + CookieBehavior: none + HeadersConfig: + HeaderBehavior: none + QueryStringsConfig: + QueryStringBehavior: none + + # Key group associating the provisioned CloudFront public key with distributions. + # Created only when at least one distribution is in signed-cookies mode. + # The public key itself is provisioned outside CloudFormation by + # scripts/pr-preview/setup-signed-cookies.sh. + PreviewSigningKeyGroup: + Type: AWS::CloudFront::KeyGroup + Condition: EnableAnySignedCookies + Properties: + KeyGroupConfig: + Name: !Sub '${AWS::StackName}-preview-signing-keys' + Comment: Signing key group for PR preview and staging signed-cookie access + Items: + - !Ref CloudFrontSigningPublicKeyId + + # Custom response headers policy — replicates the AWS managed SecurityHeadersPolicy + # (67f7725c-6f97-4210-82d7-5512b31e9d03) and adds Content-Security-Policy-Report-Only + # for Phase 1 violation monitoring before enforcement (issue #144). + # Phase 2 (issue #153): move CSP to SecurityHeadersConfig.ContentSecurityPolicy. + BeakerStackResponseHeadersPolicy: + Type: AWS::CloudFront::ResponseHeadersPolicy + Properties: + ResponseHeadersPolicyConfig: + Name: !Sub 'BeakerStack-SecurityHeaders-${AWS::StackName}' + SecurityHeadersConfig: + StrictTransportSecurity: + AccessControlMaxAgeSec: 63072000 + IncludeSubdomains: true + Preload: true + Override: true + ContentTypeOptions: + Override: true + FrameOptions: + FrameOption: SAMEORIGIN + Override: true + ReferrerPolicy: + ReferrerPolicy: same-origin + Override: true + XSSProtection: + ModeBlock: true + Protection: true + Override: true + CustomHeadersConfig: + Items: + - Header: Content-Security-Policy-Report-Only + # img-src lists specific Supabase project origins rather than the *.supabase.co + # wildcard so only our own storage buckets are permitted, not arbitrary Supabase + # tenants. Origins are sourced from per-environment GitHub secrets and injected + # via CloudFormation parameters — empty values are harmless (CSP ignores whitespace). + Value: !Sub "default-src 'self'; script-src 'self' 'sha256-FIEUlQEkz8Wc0NMOe90xjaENbkODxpO7+UD6KIqw/Ac='; connect-src 'self' https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://placehold.co ${ProductionSupabaseUrl} ${StagingSupabaseUrl} ${PreviewSupabaseUrl}; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + Override: true + # Production CloudFront Distribution (beakerstack.com) ProdDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true + HttpVersion: http2and3 Comment: !Sub 'Production distribution for ${DomainName}' DefaultRootObject: index.html Aliases: @@ -385,7 +575,7 @@ Resources: ViewerProtocolPolicy: redirect-to-https Compress: true CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized - ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # SecurityHeadersPolicy + ResponseHeadersPolicyId: !Ref BeakerStackResponseHeadersPolicy # SPA fallback for client-side routing FunctionAssociations: - EventType: viewer-request @@ -411,6 +601,7 @@ Resources: Properties: DistributionConfig: Enabled: true + HttpVersion: http2and3 Comment: !Sub 'Staging distribution for ${DomainName}' DefaultRootObject: index.html Aliases: @@ -423,6 +614,21 @@ Resources: Bucket: !GetAtt LogsBucket.DomainName Prefix: cloudfront/staging/ IncludeCookies: false + # /_preview-auth: cookie setter — must have no TrustedKeyGroups so unauthenticated + # users can reach it. PreviewAuthFunction returns a synthetic 302 with signed cookies. + CacheBehaviors: + - PathPattern: /_preview-auth + AllowedMethods: [GET, HEAD] + CachedMethods: [GET, HEAD] + TargetOriginId: StagingOrigin + ViewerProtocolPolicy: redirect-to-https + Compress: false + # No-cache policy — synthetic responses from CF Functions are not cached, but + # an explicit policy prevents any accidental cache sharing. + CachePolicyId: !Ref PreviewAuthCachePolicy + FunctionAssociations: + - EventType: viewer-request + FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN DefaultCacheBehavior: AllowedMethods: [GET, HEAD, OPTIONS] CachedMethods: [GET, HEAD] @@ -430,11 +636,15 @@ Resources: ViewerProtocolPolicy: redirect-to-https Compress: true CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized - ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # SecurityHeadersPolicy + ResponseHeadersPolicyId: !Ref BeakerStackResponseHeadersPolicy # SPA fallback for client-side routing FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt SPAFallbackFunction.FunctionMetadata.FunctionARN + TrustedKeyGroups: !If + - EnableStagingSignedCookies + - - !Ref PreviewSigningKeyGroup + - !Ref AWS::NoValue Origins: - Id: StagingOrigin DomainName: !GetAtt StagingBucket.RegionalDomainName @@ -457,6 +667,7 @@ Resources: Properties: DistributionConfig: Enabled: true + HttpVersion: http2and3 Comment: !Sub 'Preview/Deploy distribution for ${DomainName} with path-based PR routing' DefaultRootObject: index.html Aliases: @@ -469,6 +680,21 @@ Resources: Bucket: !GetAtt LogsBucket.DomainName Prefix: cloudfront/deploy/ IncludeCookies: false + # /_preview-auth: cookie setter — must have no TrustedKeyGroups so unauthenticated + # users can reach it. PreviewAuthFunction returns a synthetic 302 with signed cookies. + CacheBehaviors: + - PathPattern: /_preview-auth + AllowedMethods: [GET, HEAD] + CachedMethods: [GET, HEAD] + TargetOriginId: DeployOrigin + ViewerProtocolPolicy: redirect-to-https + Compress: false + # No-cache policy — synthetic responses from CF Functions are not cached, but + # an explicit policy prevents any accidental cache sharing. + CachePolicyId: !Ref PreviewAuthCachePolicy + FunctionAssociations: + - EventType: viewer-request + FunctionARN: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN DefaultCacheBehavior: AllowedMethods: [GET, HEAD, OPTIONS] CachedMethods: [GET, HEAD] @@ -476,11 +702,15 @@ Resources: ViewerProtocolPolicy: redirect-to-https Compress: true CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized - ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # SecurityHeadersPolicy - # Use PRPathRouter function for path-based routing + ResponseHeadersPolicyId: !Ref BeakerStackResponseHeadersPolicy + # PRPathRouter handles /pr-N/* → S3 prefix routing FunctionAssociations: - EventType: viewer-request FunctionARN: !GetAtt PRPathRouterFunction.FunctionMetadata.FunctionARN + TrustedKeyGroups: !If + - EnablePreviewSignedCookies + - - !Ref PreviewSigningKeyGroup + - !Ref AWS::NoValue Origins: - Id: DeployOrigin DomainName: !GetAtt DeployBucket.RegionalDomainName @@ -622,6 +852,17 @@ Outputs: Value: !GetAtt PRPathRouterFunction.FunctionMetadata.FunctionARN Export: Name: !Sub '${AWS::StackName}-PRPathRouterFunctionArn' + PreviewAuthFunctionArn: + Description: ARN of the preview auth cookie-setter CloudFront function + Value: !GetAtt PreviewAuthFunction.FunctionMetadata.FunctionARN + Export: + Name: !Sub '${AWS::StackName}-PreviewAuthFunctionArn' + PreviewSigningKeyGroupId: + Description: CloudFront key group ID used for signed-cookie validation (empty when access control is public) + Condition: EnableAnySignedCookies + Value: !Ref PreviewSigningKeyGroup + Export: + Name: !Sub '${AWS::StackName}-PreviewSigningKeyGroupId' PreviewPrefixOutput: Description: Prefix used for PR preview deployments Value: !Ref PreviewPrefix diff --git a/lighthouserc.js b/lighthouserc.js new file mode 100644 index 00000000..0ede51de --- /dev/null +++ b/lighthouserc.js @@ -0,0 +1,31 @@ +'use strict'; + +// Lighthouse CI configuration for PR preview environments. +// The target URL is supplied at runtime via --collect.url (see the lighthouse job in +// pr-preview-environment.yml). This file controls assertion thresholds and upload target. +// +// All assertions use 'warn' severity — scores are surfaced as informational GitHub status +// checks, not hard commit blockers. Tighten to 'error' if you want failing scores to block merge. + +module.exports = { + ci: { + collect: { + numberOfRuns: 3, + settings: { + // Emulate a mid-range mobile device (Lighthouse default). Switch to + // { preset: 'desktop' } here if the app is desktop-first. + }, + }, + assert: { + assertions: { + 'categories:performance': ['warn', { minScore: 0.7 }], + 'categories:accessibility': ['warn', { minScore: 0.9 }], + 'categories:best-practices': ['warn', { minScore: 0.9 }], + 'categories:seo': ['warn', { minScore: 0.8 }], + }, + }, + upload: { + target: 'temporary-public-storage', + }, + }, +}; diff --git a/package-lock.json b/package-lock.json index cc9b2070..3077916a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint-config-prettier": "^9.1.0", "husky": "^8.0.3", "jest": "^29.7.0", + "lint-staged": "^15.5.2", "nyc": "^17.1.0", "prettier": "^3.1.0", "stripe": "^14.21.0", @@ -12688,6 +12689,120 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -14341,6 +14456,19 @@ "node": ">=4" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -14869,6 +14997,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -16383,6 +16518,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -19633,120 +19781,641 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", "dev": true, "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "node_modules/lint-staged/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, - "node_modules/lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "path-key": "^4.0.0" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/logkitty": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", - "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-fragments": "^0.2.1", - "dayjs": "^1.8.15", - "yargs": "^15.1.0" + "mimic-fn": "^4.0.0" }, - "bin": { - "logkitty": "bin/logkitty.js" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" } }, "node_modules/logkitty/node_modules/cliui": { @@ -20530,6 +21199,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -22085,6 +22767,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -23674,6 +24369,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -24677,6 +25379,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index 781ddcfb..b05f16f1 100644 --- a/package.json +++ b/package.json @@ -132,9 +132,10 @@ "prepare": "husky install", "postinstall": "patch-package || true", "rename": "node ./scripts/rename-project.mjs", + "setup:project-label-bridge": "node scripts/github/setup-project-label-bridge.mjs", "docs:actions-secrets": "node ./scripts/generate-actions-secrets-doc.mjs", "docs:linkcheck": "npx --yes markdown-link-check@3.12.2 README.md QUICKSTART.md -c .markdown-link-check.json", - "billing:sync-stripe": "node scripts/sync-billing-stripe.mjs --config apps/web/src/billing/billing-sync.json", + "billing:sync-stripe": "node scripts/sync-billing-stripe.mjs --config packages/billing/src/presentation/billing-sync.json", "stripe:ensure-webhook": "node scripts/ensure-stripe-webhook-endpoint.mjs", "billing:apply-plans": "tsx scripts/apply-billing-plans-from-config.ts" }, @@ -142,8 +143,8 @@ "@babel/plugin-transform-runtime": "^7.28.3", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.31.0", - "@types/jest": "^29.5.8", "@supabase/supabase-js": "^2.38.0", + "@types/jest": "^29.5.8", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -153,6 +154,7 @@ "eslint-config-prettier": "^9.1.0", "husky": "^8.0.3", "jest": "^29.7.0", + "lint-staged": "^15.5.2", "nyc": "^17.1.0", "prettier": "^3.1.0", "stripe": "^14.21.0", @@ -209,5 +211,14 @@ }, "uuid": "14.0.0", "diff": "^8.0.2" + }, + "lint-staged": { + "*.{ts,tsx,js,mjs,cjs}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md}": [ + "prettier --write" + ] } } diff --git a/packages/billing/README.md b/packages/billing/README.md index d7016071..819b2268 100644 --- a/packages/billing/README.md +++ b/packages/billing/README.md @@ -11,6 +11,8 @@ Reusable **Supabase + Stripe** billing: entitlements (plan `features` / `usage_l This package does **not** embed product tier names, demo RPCs, or domain vocabulary — those live in the app (see `apps/web/src/billing/`). +Design reference: [`docs/specs/beakerstack-billing-v1.md`](../../docs/specs/beakerstack-billing-v1.md) and [`docs/specs/beakerstack-billing-ui-v1.md`](../../docs/specs/beakerstack-billing-ui-v1.md). Setup and QA: [`docs/stripe-billing-setup.md`](../../docs/stripe-billing-setup.md), [`apps/web/docs/billing-testing.md`](../../apps/web/docs/billing-testing.md). + ## Config typing ```ts diff --git a/packages/billing/package.json b/packages/billing/package.json index d9edb7a9..7f5e9d5a 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -10,7 +10,8 @@ ".": "./src/index.ts", "./client": "./src/client.ts", "./web": "./src/web.ts", - "./native": "./src/native.ts" + "./native": "./src/native.ts", + "./presentation": "./src/presentation/index.ts" }, "scripts": { "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", diff --git a/packages/billing/src/BillingConfigProvider.test.tsx b/packages/billing/src/BillingConfigProvider.test.tsx new file mode 100644 index 00000000..aa79547e --- /dev/null +++ b/packages/billing/src/BillingConfigProvider.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { BillingConfigProvider } from './BillingConfigProvider.js'; +import { useBillingConfig } from './hooks/useBillingConfig.js'; +import { defineBillingConfig } from './schema.js'; + +const testConfig = defineBillingConfig({ + productId: 'test', + displayName: 'Test Product', + plans: [ + { + id: 'test_free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + features: {}, + usageLimits: {}, + }, + ], +}); + +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +describe('BillingConfigProvider', () => { + it('provides config via useBillingConfig', () => { + const { result } = renderHook(() => useBillingConfig(), { wrapper }); + expect(result.current.productId).toBe('test'); + expect(result.current.displayName).toBe('Test Product'); + }); + + it('exposes plans array from config', () => { + const { result } = renderHook(() => useBillingConfig(), { wrapper }); + expect(result.current.plans).toHaveLength(1); + expect(result.current.plans[0].id).toBe('test_free'); + }); + + it('throws when used outside BillingConfigProvider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => renderHook(() => useBillingConfig())).toThrow( + 'useBillingConfig must be used within BillingProvider' + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/billing/src/BillingConfigProvider.tsx b/packages/billing/src/BillingConfigProvider.tsx new file mode 100644 index 00000000..b8f0b208 --- /dev/null +++ b/packages/billing/src/BillingConfigProvider.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { BillingConfigReactContext } from './context.js'; +import type { ProductBillingConfig } from './schema.js'; + +export interface BillingConfigProviderProps { + config: C; + children: ReactNode; +} + +export function BillingConfigProvider({ + config, + children, +}: BillingConfigProviderProps) { + return ( + + {children} + + ); +} diff --git a/packages/billing/src/BillingProvider.test.tsx b/packages/billing/src/BillingProvider.test.tsx index f64d0b28..3114ff1b 100644 --- a/packages/billing/src/BillingProvider.test.tsx +++ b/packages/billing/src/BillingProvider.test.tsx @@ -150,6 +150,32 @@ describe('BillingProvider', () => { expect(db.channel).toHaveBeenCalled(); }); + it('does not throw when window exists without location (React Native)', async () => { + const realWindow = globalThis.window; + const windowProxy = new Proxy(realWindow, { + get(target, prop, receiver) { + if (prop === 'location') return undefined; + return Reflect.get(target, prop, receiver); + }, + }); + vi.stubGlobal('window', windowProxy); + + try { + auth.state.session = { user: { id: 'u-rn' } }; + db.maybeSingle.mockResolvedValue({ data: null, error: null }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('u-rn'); + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it('rejects invalid config with the same schema BillingProvider uses', () => { expect(() => productBillingConfigSchema.parse({ diff --git a/packages/billing/src/BillingProvider.tsx b/packages/billing/src/BillingProvider.tsx index 4b902c6e..37501a0d 100644 --- a/packages/billing/src/BillingProvider.tsx +++ b/packages/billing/src/BillingProvider.tsx @@ -10,7 +10,7 @@ import { BillingConfigReactContext, BillingReactContext } from './context.js'; import { billingError, mapUnknownError } from './errors.js'; import type { ProductBillingConfig } from './schema.js'; import { productBillingConfigSchema } from './schema.js'; -import type { BillingContextValue, SubscriptionRow } from './types.js'; +import type { BillingContextValue, Plan, SubscriptionRow } from './types.js'; export type BillingProviderProps

= { supabase: SupabaseClient; @@ -43,6 +43,11 @@ export function BillingProvider

({ const [subscriptionError, setSubscriptionError] = useState | null>(null); + const [plan, setPlan] = useState(null); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState | null>(null); const channelRef = useRef | null>(null); const loadSubscription = useCallback( @@ -73,6 +78,48 @@ export function BillingProvider

({ await loadSubscription(userId); }, [userId, loadSubscription]); + useEffect(() => { + if (subscriptionLoading) { + setPlanLoading(true); + return; + } + if (!subscription?.plan_id) { + setPlan(null); + setPlanLoading(false); + return; + } + let cancelled = false; + void (async () => { + setPlanLoading(true); + setPlanError(null); + try { + const { data, error: qErr } = await supabase + .from('billing_plans') + .select('*') + .eq('id', subscription.plan_id) + .maybeSingle(); + if (qErr) throw qErr; + if (!cancelled) { + setPlan( + data + ? ({ + ...data, + features: data.features as Plan['features'], + } as Plan) + : null + ); + } + } catch (e) { + if (!cancelled) setPlanError(mapUnknownError(e)); + } finally { + if (!cancelled) setPlanLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [supabase, subscription?.plan_id, subscriptionLoading]); + useEffect(() => { let cancelled = false; void (async () => { @@ -155,7 +202,10 @@ export function BillingProvider

({ /** After Stripe Checkout, the row is written by `stripe-webhook` (async). Poll briefly so the UI updates even if Realtime lags or the user lands before the webhook finishes. */ useEffect(() => { if (typeof window === 'undefined' || !userId) return; - const sp = new URLSearchParams(window.location.search); + // React Native / Hermes often defines `window` without `window.location`. + const search = window.location?.search ?? ''; + if (!search) return; + const sp = new URLSearchParams(search); if (sp.get('checkout') !== 'success') return; let attempts = 0; @@ -178,6 +228,9 @@ export function BillingProvider

({ subscriptionLoading, subscriptionError, refreshSubscription, + plan, + planLoading, + planError, checkoutSuccessUrl, checkoutCancelUrl, portalReturnUrl, @@ -191,6 +244,9 @@ export function BillingProvider

({ subscriptionLoading, subscriptionError, refreshSubscription, + plan, + planLoading, + planError, checkoutSuccessUrl, checkoutCancelUrl, portalReturnUrl, diff --git a/packages/billing/src/components/CadenceToggle.native.test.tsx b/packages/billing/src/components/CadenceToggle.native.test.tsx new file mode 100644 index 00000000..52499990 --- /dev/null +++ b/packages/billing/src/components/CadenceToggle.native.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Plan } from '../types.js'; +import { CadenceToggle } from './CadenceToggle.native.js'; + +vi.mock('../presentation/billingSyncDisplay.js', () => ({ + cadenceAnnualSavingsFromPlans: vi.fn(() => ({ kind: 'months', months: 2 })), + formatCadenceToggleSavingsBadge: vi.fn(() => '2 Months Free'), +})); + +const paidPlan = (id: string): Plan => ({ + id, + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 2, +}); + +describe('CadenceToggle (native)', () => { + it('renders monthly and annual with savings badge', () => { + const onCadenceChange = vi.fn(); + render( + + ); + expect(screen.getByText('Monthly')).toBeTruthy(); + expect(screen.getByText('Annually')).toBeTruthy(); + expect(screen.getByText('2 Months Free')).toBeTruthy(); + }); + + it('calls onCadenceChange when annual is pressed', () => { + const onCadenceChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Annually')); + expect(onCadenceChange).toHaveBeenCalledWith('annual'); + }); +}); diff --git a/packages/billing/src/components/CadenceToggle.native.tsx b/packages/billing/src/components/CadenceToggle.native.tsx new file mode 100644 index 00000000..6f7d3e5d --- /dev/null +++ b/packages/billing/src/components/CadenceToggle.native.tsx @@ -0,0 +1,144 @@ +import type { ReactElement } from 'react'; +import { useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { + cadenceAnnualSavingsFromPlans, + formatCadenceToggleSavingsBadge, +} from '../presentation/billingSyncDisplay.js'; +import { billingUi } from './billingUiTokens.js'; +import type { CadenceToggleProps } from './CadenceToggle.types.js'; + +/** + * Monthly / annual cadence pill (parity with web `CadenceToggle.web`). + * Controlled via `cadence` + `onCadenceChange`. + */ +export function CadenceToggle({ + cadence, + onCadenceChange, + plans, + style, +}: CadenceToggleProps): ReactElement { + const savings = useMemo(() => cadenceAnnualSavingsFromPlans(plans), [plans]); + const annualBadgeText = useMemo( + () => formatCadenceToggleSavingsBadge(savings), + [savings] + ); + const isMonthly = cadence === 'monthly'; + const isAnnual = cadence === 'annual'; + + return ( + + + onCadenceChange('monthly')} + style={[styles.segment, isMonthly && styles.segmentSelected]} + accessibilityRole='button' + accessibilityState={{ selected: isMonthly }} + > + + Monthly + + + onCadenceChange('annual')} + style={[styles.annualSegment, isAnnual && styles.segmentSelected]} + accessibilityRole='button' + accessibilityState={{ selected: isAnnual }} + accessibilityLabel={ + annualBadgeText ? `Annually, ${annualBadgeText}` : 'Annually' + } + > + + Annually + + {annualBadgeText ? ( + + {annualBadgeText} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + alignItems: 'center', + }, + track: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + borderColor: billingUi.gray200, + backgroundColor: billingUi.white, + padding: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 1, + }, + segment: { + borderRadius: 9999, + paddingHorizontal: 16, + paddingVertical: 6, + }, + annualSegment: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 9999, + paddingHorizontal: 12, + paddingVertical: 6, + minHeight: 36, + }, + segmentSelected: { + backgroundColor: billingUi.indigo600, + }, + segmentLabel: { + fontSize: 14, + fontWeight: '500', + color: billingUi.gray600, + }, + segmentLabelSelected: { + color: billingUi.white, + }, + savingsBadge: { + marginLeft: 8, + borderRadius: 9999, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 2, + }, + savingsBadgeOnUnselected: { + borderColor: billingUi.amber600, + backgroundColor: billingUi.amber400, + }, + savingsBadgeOnSelected: { + borderColor: '#fbbf24', + backgroundColor: billingUi.amber200, + }, + savingsBadgeText: { + fontSize: 12, + fontWeight: '700', + color: billingUi.amber950, + }, +}); diff --git a/packages/billing/src/components/CadenceToggle.types.ts b/packages/billing/src/components/CadenceToggle.types.ts new file mode 100644 index 00000000..8150c250 --- /dev/null +++ b/packages/billing/src/components/CadenceToggle.types.ts @@ -0,0 +1,12 @@ +import type { StyleProp, ViewStyle } from 'react-native'; +import type { Plan } from '../types.js'; + +export type BillingCadence = 'monthly' | 'annual'; + +export type CadenceToggleProps = { + cadence: BillingCadence; + onCadenceChange: (cadence: BillingCadence) => void; + /** Catalog plans used to compute the annual savings pill (e.g. "2 Months Free"). */ + plans: Plan[]; + style?: StyleProp; +}; diff --git a/packages/billing/src/components/CustomerPortalLink.native.test.tsx b/packages/billing/src/components/CustomerPortalLink.native.test.tsx index 646cfa86..703b325d 100644 --- a/packages/billing/src/components/CustomerPortalLink.native.test.tsx +++ b/packages/billing/src/components/CustomerPortalLink.native.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; +import * as RN from 'react-native'; import { CustomerPortalLink } from './CustomerPortalLink.native.js'; import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; @@ -10,15 +11,26 @@ vi.mock('../hooks/useCustomerPortal.js', () => ({ describe('CustomerPortalLink (native)', () => { beforeEach(() => { + vi.spyOn(RN.Linking, 'openURL').mockResolvedValue(undefined as never); + }); + + it('wraps string children in Text and opens portal URL via Linking on press', async () => { + const openPortal = vi.fn().mockResolvedValue('https://portal'); vi.mocked(useCustomerPortal).mockReturnValue({ - openPortal: vi.fn().mockResolvedValue('https://portal'), + openPortal, pending: false, error: null, }); + render(Portal); + fireEvent.click(screen.getByText('Portal')); + expect(openPortal).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(RN.Linking.openURL).toHaveBeenCalledWith('https://portal'); + }); }); - it('wraps string children in Text and calls openPortal on press', () => { - const openPortal = vi.fn().mockResolvedValue('https://portal'); + it('does not call Linking when openPortal returns null', async () => { + const openPortal = vi.fn().mockResolvedValue(null); vi.mocked(useCustomerPortal).mockReturnValue({ openPortal, pending: false, @@ -26,6 +38,9 @@ describe('CustomerPortalLink (native)', () => { }); render(Portal); fireEvent.click(screen.getByText('Portal')); - expect(openPortal).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(openPortal).toHaveBeenCalled(); + }); + expect(RN.Linking.openURL).not.toHaveBeenCalled(); }); }); diff --git a/packages/billing/src/components/CustomerPortalLink.native.tsx b/packages/billing/src/components/CustomerPortalLink.native.tsx index cbc2b67e..754c49a4 100644 --- a/packages/billing/src/components/CustomerPortalLink.native.tsx +++ b/packages/billing/src/components/CustomerPortalLink.native.tsx @@ -1,21 +1,45 @@ import type { ReactElement } from 'react'; +import { useState } from 'react'; import { Pressable, Text } from 'react-native'; +import { mapUnknownError } from '../errors.js'; import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; import type { ProductBillingConfig } from '../schema.js'; +import { openExternalUrl } from '../utils/openExternalUrl.native.js'; import type { CustomerPortalLinkProps } from './CustomerPortalLink.types.js'; export function CustomerPortalLink

({ children, style, }: CustomerPortalLinkProps): ReactElement { - const { openPortal, pending } = useCustomerPortal

(); + const { openPortal, pending, error: portalError } = useCustomerPortal

(); + const [openError, setOpenError] = useState(null); + return ( - void openPortal()} - > - {typeof children === 'string' ? {children} : children} - + <> + { + void (async () => { + setOpenError(null); + try { + const url = await openPortal(); + if (url) { + await openExternalUrl(url); + } + } catch (e) { + setOpenError(mapUnknownError(e).message); + } + })(); + }} + > + {typeof children === 'string' ? {children} : children} + + {openError && !portalError ? ( + + {openError} + + ) : null} + ); } diff --git a/packages/billing/src/components/FeatureAvailabilityIcon.native.tsx b/packages/billing/src/components/FeatureAvailabilityIcon.native.tsx new file mode 100644 index 00000000..64541af7 --- /dev/null +++ b/packages/billing/src/components/FeatureAvailabilityIcon.native.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { billingUi } from './billingUiTokens.js'; + +/** Green checkmark (matches web lucide Check at ~16px). */ +export function FeatureCheckIcon(): ReactElement { + return ( + + + + ); +} + +/** Grey X (matches web lucide X at ~16px). */ +export function FeatureXIcon(): ReactElement { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + iconBox: { + width: 16, + height: 16, + marginTop: 2, + alignItems: 'center', + justifyContent: 'center', + }, + check: { + fontSize: 14, + lineHeight: 16, + fontWeight: '700', + color: billingUi.green600, + }, + x: { + fontSize: 13, + lineHeight: 16, + fontWeight: '400', + color: billingUi.gray300, + }, +}); diff --git a/packages/billing/src/components/PlanFeatureList.native.test.tsx b/packages/billing/src/components/PlanFeatureList.native.test.tsx new file mode 100644 index 00000000..aa07fc7f --- /dev/null +++ b/packages/billing/src/components/PlanFeatureList.native.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Plan } from '../types.js'; +import { PlanFeatureList } from './PlanFeatureList.native.js'; + +vi.mock('../hooks/useBillingConfig.js', () => ({ + useBillingConfig: () => ({ + productId: 'beakerstack', + planFeatureRows: [ + { + id: 'a', + featureKey: 'feature_a', + kind: 'boolean', + label: 'Feature A', + }, + { + id: 'b', + featureKey: 'feature_b', + kind: 'boolean', + label: 'Feature B', + }, + ], + }), +})); + +const plan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { feature_a: true, feature_b: false }, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 2, +}; + +describe('PlanFeatureList (native)', () => { + it("renders What's included with feature labels (not Yes/No)", () => { + render(); + expect(screen.getByText("What's included")).toBeTruthy(); + expect(screen.getByText('Feature A')).toBeTruthy(); + expect(screen.getByText('Feature B')).toBeTruthy(); + expect(screen.queryByText('Yes')).toBeNull(); + expect(screen.queryByText('No')).toBeNull(); + }); +}); diff --git a/packages/billing/src/components/PlanFeatureList.native.tsx b/packages/billing/src/components/PlanFeatureList.native.tsx new file mode 100644 index 00000000..94bd3c66 --- /dev/null +++ b/packages/billing/src/components/PlanFeatureList.native.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useBillingConfig } from '../hooks/useBillingConfig.js'; +import { + mergePlanFeatureRows, + planFeatureLine, +} from '../presentation/planPresentation.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { + FeatureCheckIcon, + FeatureXIcon, +} from './FeatureAvailabilityIcon.native.js'; +import { billingUi } from './billingUiTokens.js'; +import type { PlanFeatureListProps } from './PlanFeatureList.types.js'; + +/** + * “What’s included” list with green checks / grey X (parity with web `PlanFeatureList.web`). + */ +export function PlanFeatureList

({ + plan, + style, +}: PlanFeatureListProps

): ReactElement { + const billingConfig = useBillingConfig

(); + const rows = mergePlanFeatureRows(billingConfig); + + return ( + + What's included + + {rows.map((row, index) => { + const { ok, text } = planFeatureLine(plan, row); + return ( + + {ok ? : } + {text} + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 14, + fontWeight: '500', + color: billingUi.gray900, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + rowSpacing: { + marginBottom: 6, + }, + rowText: { + flex: 1, + marginLeft: 8, + fontSize: 14, + lineHeight: 20, + color: billingUi.gray600, + }, +}); diff --git a/packages/billing/src/components/PlanFeatureList.types.ts b/packages/billing/src/components/PlanFeatureList.types.ts new file mode 100644 index 00000000..57a0177d --- /dev/null +++ b/packages/billing/src/components/PlanFeatureList.types.ts @@ -0,0 +1,12 @@ +import type { StyleProp, ViewStyle } from 'react-native'; +import type { Plan } from '../types.js'; +import type { ProductBillingConfig } from '../schema.js'; + +export type PlanFeatureListProps< + _P extends ProductBillingConfig = ProductBillingConfig, +> = { + plan: Plan; + style?: StyleProp; + /** Reserved for future public pricing copy differences. */ + mode?: 'authenticated' | 'public'; +}; diff --git a/packages/billing/src/components/PricingTable.native.tsx b/packages/billing/src/components/PricingTable.native.tsx index fb10eaad..aea3fcee 100644 --- a/packages/billing/src/components/PricingTable.native.tsx +++ b/packages/billing/src/components/PricingTable.native.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { usePlan } from '../hooks/usePlan.js'; import { usePlanCatalog } from '../hooks/usePlanCatalog.js'; import type { ProductBillingConfig } from '../schema.js'; @@ -28,19 +28,22 @@ export function PricingTable

({ ); } + // Use plain Views (not FlatList): this component is often nested inside a + // parent ScrollView (e.g. mobile billing screen), and same-orientation + // VirtualizedList + ScrollView breaks windowing and triggers RN dev errors. return ( - item.id} - renderItem={({ item: p }) => { + + {plans.map(p => { const isCurrent = (highlightCurrent && currentPlan?.id === p.id) || (highlightPlanId != null && highlightPlanId !== '' && p.id === highlightPlanId); return ( - + {p.display_name} {(p.price_cents / 100).toFixed(2)} USD / {p.billing_period} @@ -57,8 +60,8 @@ export function PricingTable

({ ) : null} ); - }} - /> + })} + ); } diff --git a/packages/billing/src/components/UpgradePrompt.native.tsx b/packages/billing/src/components/UpgradePrompt.native.tsx index 653293ef..e036f56a 100644 --- a/packages/billing/src/components/UpgradePrompt.native.tsx +++ b/packages/billing/src/components/UpgradePrompt.native.tsx @@ -1,7 +1,10 @@ import type { ReactElement } from 'react'; +import { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; +import { mapUnknownError } from '../errors.js'; import { useCheckout } from '../hooks/useCheckout.js'; import type { ProductBillingConfig } from '../schema.js'; +import { launchStripeCheckout } from '../utils/launchStripeCheckout.native.js'; import type { UpgradePromptProps } from './UpgradePrompt.types.js'; export function UpgradePrompt

({ @@ -12,14 +15,19 @@ export function UpgradePrompt

({ style, }: UpgradePromptProps): ReactElement { const planId = suggestedPlanId ?? targetTier; - const { startCheckout, pending, error } = useCheckout

(); + const { startCheckout, pending, error: checkoutError } = useCheckout

(); + const [openError, setOpenError] = useState(null); const onUpgrade = async () => { if (!planId) return; - const r = await startCheckout(planId); - if (r?.checkoutUrl) { - const { Linking } = await import('react-native'); - await Linking.openURL(r.checkoutUrl); + setOpenError(null); + try { + const ok = await launchStripeCheckout(startCheckout, planId); + if (!ok && !checkoutError) { + setOpenError('Could not start checkout'); + } + } catch (e) { + setOpenError(mapUnknownError(e).message); } }; @@ -27,10 +35,14 @@ export function UpgradePrompt

({ return <>{children({ onUpgrade, pending })}; } + const displayError = openError ?? checkoutError?.message; + return ( {reason} - {error ? {error.message} : null} + {displayError ? ( + {displayError} + ) : null} void onUpgrade()}> {pending ? '…' : 'Upgrade'} diff --git a/packages/billing/src/components/UsageIndicator.native.tsx b/packages/billing/src/components/UsageIndicator.native.tsx index a28f1ee3..b78c8bea 100644 --- a/packages/billing/src/components/UsageIndicator.native.tsx +++ b/packages/billing/src/components/UsageIndicator.native.tsx @@ -3,6 +3,7 @@ import { StyleSheet, Text, View } from 'react-native'; import { useUsage } from '../hooks/useUsage.js'; import type { ProductBillingConfig } from '../schema.js'; import type { UsageIndicatorProps } from './UsageIndicator.types.js'; +import { billingUi } from './billingUiTokens.js'; export function UsageIndicator

( props: UsageIndicatorProps

@@ -70,14 +71,22 @@ export function UsageIndicator

( const styles = StyleSheet.create({ text: { fontSize: 14 }, - label: { fontSize: 14, fontWeight: '600', color: '#111827' }, - description: { fontSize: 12, color: '#6B7280', marginTop: 2 }, - caption: { fontSize: 12, marginTop: 4 }, + label: { fontSize: 14, fontWeight: '500', color: billingUi.gray900 }, + description: { fontSize: 12, color: billingUi.gray500, marginTop: 2 }, + /** Matches web `mt-2` + `text-sm text-gray-600` on expanded caption */ + caption: { + fontSize: 14, + marginTop: 8, + color: billingUi.gray600, + lineHeight: 20, + }, + /** Matches web `mt-2` before progress bar */ track: { + marginTop: 8, height: 8, - backgroundColor: '#e5e7eb', - borderRadius: 4, + backgroundColor: billingUi.gray200, + borderRadius: 999, overflow: 'hidden', }, - fill: { height: 8, backgroundColor: '#4f46e5' }, + fill: { height: 8, backgroundColor: billingUi.indigo600, borderRadius: 999 }, }); diff --git a/packages/billing/src/components/UsageIndicator.web.tsx b/packages/billing/src/components/UsageIndicator.web.tsx index b90c588e..02dcc7d5 100644 --- a/packages/billing/src/components/UsageIndicator.web.tsx +++ b/packages/billing/src/components/UsageIndicator.web.tsx @@ -49,27 +49,26 @@ export function UsageIndicator

( data-testid='usage-indicator-expanded' > {label && ( -

{label}
+
+ {label} +
)} {description && ( -

{description}

+

+ {description} +

)} {limit != null && ( -
+
)} -
{capLine}
+
+ {capLine} +
); } diff --git a/packages/billing/src/components/__tests__/PricingTable.web.test.tsx b/packages/billing/src/components/__tests__/PricingTable.web.test.tsx index 1a2e1794..1d9b8be3 100644 --- a/packages/billing/src/components/__tests__/PricingTable.web.test.tsx +++ b/packages/billing/src/components/__tests__/PricingTable.web.test.tsx @@ -3,28 +3,47 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PricingTable } from '../PricingTable.web'; +const MOCK_PLANS = [ + { + id: 'plan_free', + product_id: 'prod_test', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { ai_summarize: false }, + usage_limits: { collections: 3 }, + trial_period_days: 0, + is_public: true, + display_order: 0, + }, + { + id: 'plan_pro', + product_id: 'prod_test', + display_name: 'Pro', + description: 'Full access', + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: 'price_pro_monthly', + stripe_price_id_annual: null, + stripe_product_id: 'prod_pro', + features: { ai_summarize: true }, + usage_limits: { collections: 100 }, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, +]; + vi.mock('../../hooks/usePlanCatalog.js', () => ({ - usePlanCatalog: () => ({ - plans: [ - { - id: 'plan_free', - display_name: 'Free', - price_cents: 0, - billing_period: 'free', - }, - { - id: 'plan_pro', - display_name: 'Pro', - price_cents: 1900, - billing_period: 'monthly', - }, - ], - loading: false, - }), + usePlanCatalog: () => ({ plans: MOCK_PLANS, loading: false, error: null }), })); vi.mock('../../hooks/usePlan.js', () => ({ - usePlan: () => ({ data: { id: 'plan_pro' } }), + usePlan: () => ({ data: MOCK_PLANS[1] }), })); describe('PricingTable', () => { diff --git a/packages/billing/src/components/billingUiTokens.ts b/packages/billing/src/components/billingUiTokens.ts new file mode 100644 index 00000000..01fcf2f3 --- /dev/null +++ b/packages/billing/src/components/billingUiTokens.ts @@ -0,0 +1,16 @@ +/** Shared billing UI colors aligned with web Tailwind tokens. */ +export const billingUi = { + indigo600: '#4f46e5', + gray200: '#e5e7eb', + gray300: '#d1d5db', + gray400: '#9ca3af', + gray500: '#6b7280', + gray600: '#4b5563', + gray900: '#111827', + white: '#ffffff', + green600: '#16a34a', + amber200: '#fde68a', + amber400: '#fbbf24', + amber600: '#d97706', + amber950: '#451a03', +} as const; diff --git a/packages/billing/src/entrypoints.test.ts b/packages/billing/src/entrypoints.test.ts index 473720fe..e45e8e9b 100644 --- a/packages/billing/src/entrypoints.test.ts +++ b/packages/billing/src/entrypoints.test.ts @@ -22,5 +22,14 @@ describe('package entrypoints', () => { const mod = await import('./native.js'); expect(mod.FeatureGate).toBeTypeOf('function'); expect(mod.PricingTable).toBeTypeOf('function'); + expect(mod.CadenceToggle).toBeTypeOf('function'); + expect(mod.PlanFeatureList).toBeTypeOf('function'); + }); + + it('exports presentation helpers', async () => { + const mod = await import('./presentation/index.js'); + expect(mod.formatDate).toBeTypeOf('function'); + expect(mod.computeDowngradeBlockers).toBeTypeOf('function'); + expect(mod.annualListCentsFromSync).toBeTypeOf('function'); }); }); diff --git a/packages/billing/src/errors.test.ts b/packages/billing/src/errors.test.ts index 99b27f8a..8e5d0b00 100644 --- a/packages/billing/src/errors.test.ts +++ b/packages/billing/src/errors.test.ts @@ -40,6 +40,38 @@ describe('mapUnknownError', () => { expect(e.message).toBe('timeout'); }); + it('extracts message from PostgREST-style error objects', () => { + const e = mapUnknownError({ + code: 'P0001', + message: 'Usage limit exceeded for this meter', + details: null, + hint: null, + }); + expect(e.kind).toBe('unknown'); + expect(e.message).toBe('Usage limit exceeded for this meter'); + }); + + it('falls back to code and details when message is missing', () => { + const e = mapUnknownError({ + code: '42883', + details: 'function does not exist', + }); + expect(e.message).toContain('42883'); + expect(e.message).toContain('function does not exist'); + }); + + it('maps undefined without throwing (JSON.stringify(undefined) is not a string)', () => { + const e = mapUnknownError(undefined); + expect(e.kind).toBe('unknown'); + expect(typeof e.message).toBe('string'); + expect(() => e.message.toLowerCase()).not.toThrow(); + }); + + it('maps symbols and functions to a string message', () => { + expect(typeof mapUnknownError(Symbol('x')).message).toBe('string'); + expect(typeof mapUnknownError(() => {}).message).toBe('string'); + }); + it('passes through BillingError', () => { const inner = billingError('stripe', 'bad'); const e = mapUnknownError(inner); diff --git a/packages/billing/src/errors.ts b/packages/billing/src/errors.ts index fb26d4f1..eb566b91 100644 --- a/packages/billing/src/errors.ts +++ b/packages/billing/src/errors.ts @@ -20,6 +20,43 @@ export function billingError( return { kind, message, cause }; } +function messageFromUnknown(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === 'object') { + const o = err as Record; + if (typeof o['message'] === 'string' && o['message'].trim()) + return o['message']; + if ( + typeof o['error_description'] === 'string' && + o['error_description'].trim() + ) { + return o['error_description']; + } + if (typeof o['msg'] === 'string' && o['msg'].trim()) return o['msg']; + if (typeof o['code'] === 'string') { + const parts = [o['code']]; + if (typeof o['details'] === 'string' && o['details'].trim()) + parts.push(o['details']); + if (typeof o['hint'] === 'string' && o['hint'].trim()) + parts.push(o['hint']); + return parts.join(' · '); + } + } + if (typeof err === 'string') return err; + if (typeof err === 'bigint') return err.toString(); + try { + const j = JSON.stringify(err); + if (typeof j === 'string') return j; + } catch { + /* e.g. cyclic structure, or stringify rejects the value */ + } + try { + return String(err); + } catch { + return 'Unknown error'; + } +} + export function mapUnknownError(err: unknown): BillingError { if ( err && @@ -29,7 +66,7 @@ export function mapUnknownError(err: unknown): BillingError { ) { return err as BillingError; } - const msg = err instanceof Error ? err.message : String(err); + const msg = messageFromUnknown(err); const lower = msg.toLowerCase(); if ( lower.includes('jwt') || diff --git a/packages/billing/src/hooks/useBillingStripeActions.ts b/packages/billing/src/hooks/useBillingStripeActions.ts index 12e74a1e..5add5ea5 100644 --- a/packages/billing/src/hooks/useBillingStripeActions.ts +++ b/packages/billing/src/hooks/useBillingStripeActions.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { mapUnknownError } from '../errors.js'; import type { BillingError } from '../errors.js'; import type { ProductBillingConfig } from '../schema.js'; +import { parseBillingFunctionError } from '../utils/parseBillingFunctionError.js'; import { useBillingContext } from './useBillingContext.js'; /** @@ -33,7 +34,7 @@ export function useBillingStripeActions< setPending(true); setError(null); try { - const { error: fnErr } = await supabase.functions.invoke( + const { data, error: fnErr } = await supabase.functions.invoke( stripeFunctionName, { body: { @@ -42,7 +43,7 @@ export function useBillingStripeActions< }, } ); - if (fnErr) throw fnErr; + if (fnErr) throw parseBillingFunctionError(data, fnErr); await refreshSubscription(); return true; } catch (e) { diff --git a/packages/billing/src/hooks/useCheckout.test.tsx b/packages/billing/src/hooks/useCheckout.test.tsx index 383121fd..ad1a65b6 100644 --- a/packages/billing/src/hooks/useCheckout.test.tsx +++ b/packages/billing/src/hooks/useCheckout.test.tsx @@ -68,6 +68,22 @@ describe('useCheckout', () => { await waitFor(() => expect(result.current.error).not.toBeNull()); }); + it('surfaces structured error body from failed invoke', async () => { + invoke.mockResolvedValue({ + data: { + error: 'invalid_redirect_url', + hint: 'Add origin to BILLING_ALLOWED_ORIGINS', + }, + error: new Error('FunctionsHttpError'), + }); + const { result } = renderHook(() => useCheckout()); + const r = await result.current.startCheckout('plan_free'); + expect(r).toBeNull(); + await waitFor(() => + expect(result.current.error?.message).toContain('invalid_redirect_url') + ); + }); + it('includes trialDays in invoke body when provided', async () => { invoke.mockResolvedValue({ data: { checkoutUrl: 'https://stripe.test/session' }, diff --git a/packages/billing/src/hooks/useCheckout.ts b/packages/billing/src/hooks/useCheckout.ts index 35d6ba01..02172750 100644 --- a/packages/billing/src/hooks/useCheckout.ts +++ b/packages/billing/src/hooks/useCheckout.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { billingError, mapUnknownError } from '../errors.js'; import type { BillingError } from '../errors.js'; import type { ProductBillingConfig } from '../schema.js'; +import { parseBillingFunctionError } from '../utils/parseBillingFunctionError.js'; import { useBillingContext } from './useBillingContext.js'; export function useCheckout< @@ -50,7 +51,7 @@ export function useCheckout< { body } ); if (fnErr) { - throw fnErr; + throw parseBillingFunctionError(data, fnErr); } const checkoutUrl = (data as { checkoutUrl?: string })?.checkoutUrl; if (!checkoutUrl) { diff --git a/packages/billing/src/hooks/useCustomerPortal.ts b/packages/billing/src/hooks/useCustomerPortal.ts index 72019d55..992d9e94 100644 --- a/packages/billing/src/hooks/useCustomerPortal.ts +++ b/packages/billing/src/hooks/useCustomerPortal.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { billingError, mapUnknownError } from '../errors.js'; import type { BillingError } from '../errors.js'; import type { ProductBillingConfig } from '../schema.js'; +import { parseBillingFunctionError } from '../utils/parseBillingFunctionError.js'; import { useBillingContext } from './useBillingContext.js'; export function useCustomerPortal< @@ -35,7 +36,7 @@ export function useCustomerPortal< }, } ); - if (fnErr) throw fnErr; + if (fnErr) throw parseBillingFunctionError(data, fnErr); const url = (data as { url?: string })?.url; if (!url) { throw billingError( diff --git a/packages/billing/src/hooks/usePlan.test.tsx b/packages/billing/src/hooks/usePlan.test.tsx index 2072ced0..d2acb103 100644 --- a/packages/billing/src/hooks/usePlan.test.tsx +++ b/packages/billing/src/hooks/usePlan.test.tsx @@ -1,97 +1,61 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import type { SubscriptionRow } from '../types.js'; +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; import { baseBillingContextExtras, testPlan } from '../test/billingFixtures.js'; import { usePlan } from './usePlan.js'; +import type { Plan } from '../types.js'; +import type { BillingError } from '../errors.js'; -const hp = vi.hoisted(() => { - const maybeSingle = vi.fn(); - const supabase = { - from: vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ maybeSingle })), - })), - })), - }; - const subscription: SubscriptionRow = { - id: 'sub_1', - user_id: 'user_1', - product_id: 'test_product', - plan_id: 'plan_free', - stripe_customer_id: null, - stripe_subscription_id: 'sub_x', - stripe_price_id: 'price_monthly', - status: 'active', - current_period_start: null, - current_period_end: null, - cancel_at_period_end: false, - pending_target_plan_id: null, - canceled_at: null, - trial_start: null, - trial_end: null, - }; - return { maybeSingle, supabase, subscription, subscriptionLoading: false }; -}); +const hp = vi.hoisted(() => ({ + plan: null as Plan | null, + planLoading: false, + planError: null as BillingError | null, +})); vi.mock('./useBillingContext.js', () => ({ useBillingContext: () => ({ ...baseBillingContextExtras(), - supabase: - hp.supabase as unknown as import('@supabase/supabase-js').SupabaseClient, - subscription: hp.subscription, - subscriptionLoading: hp.subscriptionLoading, + plan: hp.plan, + planLoading: hp.planLoading, + planError: hp.planError, }), })); describe('usePlan', () => { - beforeEach(() => { - hp.maybeSingle.mockReset(); - hp.subscriptionLoading = false; - hp.subscription = { - ...hp.subscription, - plan_id: 'plan_free', - }; - }); - - it('stays idle while subscription is loading', () => { - hp.subscriptionLoading = true; + it('returns plan data from context', () => { + hp.plan = testPlan(); + hp.planLoading = false; + hp.planError = null; const { result } = renderHook(() => usePlan()); - expect(result.current.loading).toBe(true); - expect(hp.supabase.from).not.toHaveBeenCalled(); + expect(result.current.data?.id).toBe('plan_free'); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); }); - it('clears plan when subscription has no plan_id', async () => { - hp.subscription = { ...hp.subscription, plan_id: '' }; + it('returns loading state while plan is loading', () => { + hp.plan = null; + hp.planLoading = true; + hp.planError = null; const { result } = renderHook(() => usePlan()); - await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.loading).toBe(true); expect(result.current.data).toBeNull(); - expect(hp.supabase.from).not.toHaveBeenCalled(); }); - it('loads plan from billing_plans', async () => { - const row = { ...testPlan(), features: {} }; - hp.maybeSingle.mockResolvedValue({ data: row, error: null }); + it('returns null plan when not available', () => { + hp.plan = null; + hp.planLoading = false; + hp.planError = null; const { result } = renderHook(() => usePlan()); - await waitFor(() => expect(result.current.data?.id).toBe('plan_free')); - expect(hp.supabase.from).toHaveBeenCalledWith('billing_plans'); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); }); - it('maps query errors', async () => { - hp.maybeSingle.mockResolvedValue({ - data: null, - error: { message: 'query failed' }, - }); - const { result } = renderHook(() => usePlan()); - await waitFor(() => expect(result.current.error).not.toBeNull()); - expect(result.current.error?.kind).toBe('unknown'); - }); - - it('sets plan null when query returns no row', async () => { - hp.maybeSingle.mockResolvedValue({ data: null, error: null }); + it('surfaces plan errors from context', () => { + hp.plan = null; + hp.planLoading = false; + hp.planError = { kind: 'unknown', message: 'plan fetch failed' }; const { result } = renderHook(() => usePlan()); - await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error?.message).toBe('plan fetch failed'); expect(result.current.data).toBeNull(); - expect(result.current.error).toBeNull(); }); }); diff --git a/packages/billing/src/hooks/usePlan.ts b/packages/billing/src/hooks/usePlan.ts index 20b7fb22..529654a7 100644 --- a/packages/billing/src/hooks/usePlan.ts +++ b/packages/billing/src/hooks/usePlan.ts @@ -1,5 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; -import { mapUnknownError } from '../errors.js'; +import { useMemo } from 'react'; import type { BillingError } from '../errors.js'; import type { ProductBillingConfig } from '../schema.js'; import type { Plan } from '../types.js'; @@ -12,60 +11,9 @@ export function usePlan< loading: boolean; error: BillingError | null; } { - const { supabase, subscription, subscriptionLoading } = - useBillingContext

(); - const [plan, setPlan] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (subscriptionLoading) { - setLoading(true); - return; - } - if (!subscription?.plan_id) { - setPlan(null); - setLoading(false); - return; - } - let cancelled = false; - void (async () => { - setLoading(true); - setError(null); - try { - const { data, error: qErr } = await supabase - .from('billing_plans') - .select('*') - .eq('id', subscription.plan_id) - .maybeSingle(); - if (qErr) throw qErr; - if (!cancelled) { - setPlan( - data - ? ({ - ...data, - features: data.features as Plan['features'], - } as Plan) - : null - ); - } - } catch (e) { - if (!cancelled) setError(mapUnknownError(e)); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [supabase, subscription?.plan_id, subscriptionLoading]); - + const { plan, planLoading, planError } = useBillingContext

(); return useMemo( - () => ({ - data: plan, - loading: loading || subscriptionLoading, - error, - }), - [plan, loading, subscriptionLoading, error] + () => ({ data: plan, loading: planLoading, error: planError }), + [plan, planLoading, planError] ); } diff --git a/packages/billing/src/hooks/useUsage.ts b/packages/billing/src/hooks/useUsage.ts index ff29d43e..ecbf0e9d 100644 --- a/packages/billing/src/hooks/useUsage.ts +++ b/packages/billing/src/hooks/useUsage.ts @@ -83,31 +83,40 @@ export function useUsage< useEffect(() => { if (!userId || typeof supabase.channel !== 'function') return; const filter = `user_id=eq.${userId}`; - const ch = supabase - .channel( - `billing_usage_aggregates:${config.productId}:${userId}:${meterKey}` - ) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'billing_usage_aggregates', - filter, - }, - payload => { - const row = (payload.new ?? payload.old) as - | { product_id?: string; event_type?: string } - | undefined; - if ( - row?.product_id === config.productId && - row?.event_type === meterKey - ) { - void fetchUsageRef.current(); - } + const ch = supabase.channel( + `billing_usage_aggregates:${config.productId}:${userId}:${meterKey}` + ); + + // Guard against StrictMode double-effect and remount races: if the channel + // is already subscribed (removeChannel is async so cleanup may lag), skip + // re-attaching handlers — adding .on() after subscribe() throws. + if (ch.state === 'joined' || ch.state === 'joining') { + return () => { + void supabase.removeChannel(ch); + }; + } + + ch.on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'billing_usage_aggregates', + filter, + }, + payload => { + const row = (payload.new ?? payload.old) as + | { product_id?: string; event_type?: string } + | undefined; + if ( + row?.product_id === config.productId && + row?.event_type === meterKey + ) { + void fetchUsageRef.current(); } - ) - .subscribe(); + } + ).subscribe(); + return () => { void supabase.removeChannel(ch); }; diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts index 8014c82b..d79b72e9 100644 --- a/packages/billing/src/index.ts +++ b/packages/billing/src/index.ts @@ -28,6 +28,10 @@ export { BillingProvider, type BillingProviderProps, } from './BillingProvider.js'; +export { + BillingConfigProvider, + type BillingConfigProviderProps, +} from './BillingConfigProvider.js'; export type { Plan, PlanId, diff --git a/packages/billing/src/native.ts b/packages/billing/src/native.ts index fe0b6838..e735780f 100644 --- a/packages/billing/src/native.ts +++ b/packages/billing/src/native.ts @@ -7,3 +7,16 @@ export { SubscriptionStatus } from './components/SubscriptionStatus.native.js'; export { CustomerPortalLink } from './components/CustomerPortalLink.native.js'; export { SubscriptionStatusBadge } from './components/SubscriptionStatusBadge.native.js'; export type { SubscriptionStatusBadgeProps } from './components/SubscriptionStatusBadge.native.js'; +export { CadenceToggle } from './components/CadenceToggle.native.js'; +export type { + CadenceToggleProps, + BillingCadence, +} from './components/CadenceToggle.types.js'; +export { PlanFeatureList } from './components/PlanFeatureList.native.js'; +export { + FeatureCheckIcon, + FeatureXIcon, +} from './components/FeatureAvailabilityIcon.native.js'; +export type { PlanFeatureListProps } from './components/PlanFeatureList.types.js'; +export { openExternalUrl } from './utils/openExternalUrl.native.js'; +export { launchStripeCheckout } from './utils/launchStripeCheckout.native.js'; diff --git a/apps/web/src/billing/billing-sync.json b/packages/billing/src/presentation/billing-sync.json similarity index 100% rename from apps/web/src/billing/billing-sync.json rename to packages/billing/src/presentation/billing-sync.json diff --git a/apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts b/packages/billing/src/presentation/billingSyncDisplay.mockedSync.test.ts similarity index 87% rename from apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts rename to packages/billing/src/presentation/billingSyncDisplay.mockedSync.test.ts index 88eeae88..74a2f781 100644 --- a/apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts +++ b/packages/billing/src/presentation/billingSyncDisplay.mockedSync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import type { Plan } from '@beakerstack/billing'; +import type { Plan } from '../types.js'; const basePaidPlan = (over: Partial & Pick): Plan => ({ product_id: 'beakerstack', @@ -21,16 +21,16 @@ const basePaidPlan = (over: Partial & Pick): Plan => ({ describe('billingSyncDisplay with mocked billing-sync.json', () => { beforeEach(() => { vi.resetModules(); - vi.doUnmock('../billing-sync.json'); + vi.doUnmock('./billing-sync.json'); }); afterEach(() => { - vi.doUnmock('../billing-sync.json'); + vi.doUnmock('./billing-sync.json'); vi.resetModules(); }); it('planAnnualSavingsCopy returns percent when annual discount is not whole months', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -43,7 +43,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { ], }, })); - const { planAnnualSavingsCopy } = await import('../billingSyncDisplay'); + const { planAnnualSavingsCopy } = await import('./billingSyncDisplay.js'); expect(planAnnualSavingsCopy('frac', 1000)).toEqual({ kind: 'percent', pct: 13, @@ -51,7 +51,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }); it('planAnnualSavingsCopy returns none when rounded percent is zero', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -64,12 +64,12 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { ], }, })); - const { planAnnualSavingsCopy } = await import('../billingSyncDisplay'); + const { planAnnualSavingsCopy } = await import('./billingSyncDisplay.js'); expect(planAnnualSavingsCopy('tiny', 10000)).toEqual({ kind: 'none' }); }); it('cadenceAnnualSavingsFromPlans returns months_range when month-free counts differ', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -90,7 +90,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }, })); const { cadenceAnnualSavingsFromPlans } = - await import('../billingSyncDisplay'); + await import('./billingSyncDisplay.js'); const plans: Plan[] = [ basePaidPlan({ id: 'mo_lo', display_order: 1 }), basePaidPlan({ id: 'mo_hi', display_order: 2 }), @@ -102,7 +102,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }); it('cadenceAnnualSavingsFromPlans returns single percent when all plans agree on percent savings', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -123,7 +123,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }, })); const { cadenceAnnualSavingsFromPlans } = - await import('../billingSyncDisplay'); + await import('./billingSyncDisplay.js'); const plans: Plan[] = [ basePaidPlan({ id: 'pct_a', price_cents: 1000, display_order: 1 }), basePaidPlan({ id: 'pct_b', price_cents: 2000, display_order: 2 }), @@ -135,7 +135,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }); it('cadenceAnnualSavingsFromPlans returns percent_range when percent savings spread is wide', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -156,7 +156,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }, })); const { cadenceAnnualSavingsFromPlans } = - await import('../billingSyncDisplay'); + await import('./billingSyncDisplay.js'); const plans: Plan[] = [ basePaidPlan({ id: 'wide_a', price_cents: 1000, display_order: 1 }), basePaidPlan({ id: 'wide_b', price_cents: 1000, display_order: 2 }), @@ -168,7 +168,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }); it('cadenceAnnualSavingsFromPlans averages percent when spread is at most one point', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -189,7 +189,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }, })); const { cadenceAnnualSavingsFromPlans } = - await import('../billingSyncDisplay'); + await import('./billingSyncDisplay.js'); const plans: Plan[] = [ basePaidPlan({ id: 'tight_a', price_cents: 1000, display_order: 1 }), basePaidPlan({ id: 'tight_b', price_cents: 1000, display_order: 2 }), @@ -201,7 +201,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }); it('cadenceAnnualSavingsFromPlans falls back to annual percent range when mix of months and percent', async () => { - vi.doMock('../billing-sync.json', () => ({ + vi.doMock('./billing-sync.json', () => ({ default: { plans: [ { @@ -222,7 +222,7 @@ describe('billingSyncDisplay with mocked billing-sync.json', () => { }, })); const { cadenceAnnualSavingsFromPlans } = - await import('../billingSyncDisplay'); + await import('./billingSyncDisplay.js'); const plans: Plan[] = [ basePaidPlan({ id: 'mix_m', price_cents: 1000, display_order: 1 }), basePaidPlan({ id: 'mix_p', price_cents: 1000, display_order: 2 }), diff --git a/apps/web/src/billing/__tests__/billingSyncDisplay.test.ts b/packages/billing/src/presentation/billingSyncDisplay.test.ts similarity index 98% rename from apps/web/src/billing/__tests__/billingSyncDisplay.test.ts rename to packages/billing/src/presentation/billingSyncDisplay.test.ts index 9485dbba..13a3ca3f 100644 --- a/apps/web/src/billing/__tests__/billingSyncDisplay.test.ts +++ b/packages/billing/src/presentation/billingSyncDisplay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { Plan } from '@beakerstack/billing'; +import type { Plan } from '../types.js'; import { annualListCentsFromSync, annualSavingsPercentForPlan, @@ -9,7 +9,7 @@ import { formatSavingsCalloutFromCopy, monthlyListCentsFromSync, planAnnualSavingsCopy, -} from '../billingSyncDisplay'; +} from './billingSyncDisplay.js'; describe('billingSyncDisplay', () => { it('monthlyListCentsFromSync uses sync JSON when plan exists', () => { diff --git a/apps/web/src/billing/billingSyncDisplay.ts b/packages/billing/src/presentation/billingSyncDisplay.ts similarity index 99% rename from apps/web/src/billing/billingSyncDisplay.ts rename to packages/billing/src/presentation/billingSyncDisplay.ts index 1141a6e5..745524df 100644 --- a/apps/web/src/billing/billingSyncDisplay.ts +++ b/packages/billing/src/presentation/billingSyncDisplay.ts @@ -1,4 +1,4 @@ -import type { Plan } from '@beakerstack/billing'; +import type { Plan } from '../types.js'; import billingSync from './billing-sync.json'; type SyncPrice = { diff --git a/apps/web/src/billing/__tests__/constraintBlockers.test.ts b/packages/billing/src/presentation/constraintBlockers.test.ts similarity index 97% rename from apps/web/src/billing/__tests__/constraintBlockers.test.ts rename to packages/billing/src/presentation/constraintBlockers.test.ts index 39f7f7f0..ec218201 100644 --- a/apps/web/src/billing/__tests__/constraintBlockers.test.ts +++ b/packages/billing/src/presentation/constraintBlockers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { Plan } from '@beakerstack/billing'; -import type { ProductBillingConfig } from '@beakerstack/billing'; -import { computeDowngradeBlockers } from '../constraintBlockers'; +import type { Plan } from '../types.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { computeDowngradeBlockers } from './constraintBlockers.js'; const plan = (over: Partial): Plan => ({ id: 'id', diff --git a/apps/web/src/billing/constraintBlockers.ts b/packages/billing/src/presentation/constraintBlockers.ts similarity index 83% rename from apps/web/src/billing/constraintBlockers.ts rename to packages/billing/src/presentation/constraintBlockers.ts index be700446..a6a6b328 100644 --- a/apps/web/src/billing/constraintBlockers.ts +++ b/packages/billing/src/presentation/constraintBlockers.ts @@ -1,12 +1,12 @@ -import type { Plan } from '@beakerstack/billing'; -import type { ProductBillingConfig } from '@beakerstack/billing'; +import type { ProductBillingConfig } from '../schema.js'; +import type { Plan } from '../types.js'; import { applyTemplate, booleanFeatureLabel, exclusiveBooleanFeaturePlanName, mergeDowngradeConstraintCopy, mergePlanFeatureRows, -} from './planPresentation'; +} from './planPresentation.js'; export type DowngradeBlockersResult = { /** Collections over cap, items-per-collection over cap, meter over cap — block the CTA. */ @@ -39,8 +39,9 @@ export function computeDowngradeBlockers( const copy = mergeDowngradeConstraintCopy(billingConfig); const hard: string[] = []; const soft: string[] = []; + const targetPlanName = targetPlan.display_name ?? targetPlan.id; - const tCap = targetPlan.features.containers_per_account_max as + const tCap = targetPlan.features['containers_per_account_max'] as | number | undefined; if (tCap != null && tCap >= 0 && options.collectionCount > tCap) { @@ -48,13 +49,13 @@ export function computeDowngradeBlockers( applyTemplate(copy.collectionsOverCap, { current: options.collectionCount, cap: tCap, - targetPlan: targetPlan.display_name, + targetPlan: targetPlanName, deleteCount: options.collectionCount - tCap, }) ); } - const itemsCap = targetPlan.features.items_per_container_max as + const itemsCap = targetPlan.features['items_per_container_max'] as | number | undefined; if ( @@ -66,18 +67,18 @@ export function computeDowngradeBlockers( applyTemplate(copy.itemsPerCollectionOverCap, { maxItems: options.maxItemsInAnyCollection, cap: itemsCap, - targetPlan: targetPlan.display_name, + targetPlan: targetPlanName, }) ); } - const lim = targetPlan.usage_limits.ai_summarize as number | undefined; + const lim = targetPlan.usage_limits['ai_summarize'] as number | undefined; if (lim != null && lim >= 0 && options.aiUsedThisPeriod > lim) { hard.push( applyTemplate(copy.meterOverCap, { used: options.aiUsedThisPeriod, limit: lim, - targetPlan: targetPlan.display_name, + targetPlan: targetPlanName, }) ); } @@ -96,7 +97,7 @@ export function computeDowngradeBlockers( applyTemplate(copy.booleanFeatureLoss, { featureLabel, exclusivePlanName, - targetPlanName: targetPlan.display_name, + targetPlanName, }) ); } diff --git a/apps/web/src/billing/formatters.ts b/packages/billing/src/presentation/formatters.ts similarity index 100% rename from apps/web/src/billing/formatters.ts rename to packages/billing/src/presentation/formatters.ts diff --git a/packages/billing/src/presentation/index.ts b/packages/billing/src/presentation/index.ts new file mode 100644 index 00000000..216051cd --- /dev/null +++ b/packages/billing/src/presentation/index.ts @@ -0,0 +1,28 @@ +export { formatMoneyCents, formatDate, formatMonthYear } from './formatters.js'; +export { + DEFAULT_PLAN_FEATURE_ROWS, + mergePlanFeatureRows, + mergeDowngradeConstraintCopy, + applyTemplate, + planFeatureLine, + booleanFeatureLabel, + exclusiveBooleanFeaturePlanName, + mergeUsageMeterCopy, + mergeUsageLimitsCopy, +} from './planPresentation.js'; +export { + computeDowngradeBlockers, + type DowngradeBlockersResult, +} from './constraintBlockers.js'; +export { + monthlyListCentsFromSync, + annualListCentsFromSync, + planAnnualSavingsCopy, + annualSavingsPercentForPlan, + formatSavingsCalloutFromCopy, + formatCadenceToggleSavingsBadge, + cadenceAnnualSavingsFromPlans, + formatCadenceAnnualButtonLabel, + type PlanSavingsCopy, + type CadenceSavingsLabel, +} from './billingSyncDisplay.js'; diff --git a/apps/web/src/billing/__tests__/planPresentation.test.ts b/packages/billing/src/presentation/planPresentation.test.ts similarity index 98% rename from apps/web/src/billing/__tests__/planPresentation.test.ts rename to packages/billing/src/presentation/planPresentation.test.ts index 4a8bfd87..faa112e0 100644 --- a/apps/web/src/billing/__tests__/planPresentation.test.ts +++ b/packages/billing/src/presentation/planPresentation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Plan } from '@beakerstack/billing'; -import type { ProductBillingConfig } from '@beakerstack/billing'; +import type { Plan } from '../types.js'; +import type { ProductBillingConfig } from '../schema.js'; import { applyTemplate, booleanFeatureLabel, @@ -11,7 +11,7 @@ import { mergeUsageLimitsCopy, mergeUsageMeterCopy, planFeatureLine, -} from '../planPresentation'; +} from './planPresentation.js'; const basePlan = (over: Partial): Plan => ({ id: 'p1', diff --git a/apps/web/src/billing/planPresentation.ts b/packages/billing/src/presentation/planPresentation.ts similarity index 87% rename from apps/web/src/billing/planPresentation.ts rename to packages/billing/src/presentation/planPresentation.ts index 0c40d83e..8d740beb 100644 --- a/apps/web/src/billing/planPresentation.ts +++ b/packages/billing/src/presentation/planPresentation.ts @@ -2,8 +2,8 @@ import type { DowngradeConstraintCopy, PlanFeatureRowConfig, ProductBillingConfig, -} from '@beakerstack/billing'; -import type { Plan } from '@beakerstack/billing'; +} from '../schema.js'; +import type { Plan } from '../types.js'; /** Default “What’s included” rows when `planFeatureRows` is omitted in config. */ export const DEFAULT_PLAN_FEATURE_ROWS: PlanFeatureRowConfig[] = [ @@ -70,9 +70,10 @@ export function mergeDowngradeConstraintCopy( } export function applyTemplate( - template: string, + template: string | undefined, vars: Record ): string { + if (!template) return ''; return template.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] !== undefined && vars[key] !== null ? String(vars[key]) : '' ); @@ -144,7 +145,10 @@ export function mergeUsageMeterCopy( ...DEFAULT_USAGE_METER_COPY, }; for (const [k, v] of Object.entries(config.usageMeterCopy ?? {})) { - out[k] = { ...out[k], ...v }; + const prev = out[k]; + const label = v.label ?? prev?.label ?? k; + const description = v.description ?? prev?.description; + out[k] = description !== undefined ? { label, description } : { label }; } return out; } @@ -152,5 +156,12 @@ export function mergeUsageMeterCopy( export function mergeUsageLimitsCopy( config: ProductBillingConfig ): typeof DEFAULT_USAGE_LIMITS_COPY { - return { ...DEFAULT_USAGE_LIMITS_COPY, ...(config.usageLimitsCopy ?? {}) }; + const d = config.usageLimitsCopy ?? {}; + return { + collectionsRowName: + d.collectionsRowName ?? DEFAULT_USAGE_LIMITS_COPY.collectionsRowName, + itemsRowName: d.itemsRowName ?? DEFAULT_USAGE_LIMITS_COPY.itemsRowName, + collectionsFootnote: + d.collectionsFootnote ?? DEFAULT_USAGE_LIMITS_COPY.collectionsFootnote, + }; } diff --git a/packages/billing/src/test/billingFixtures.ts b/packages/billing/src/test/billingFixtures.ts index e494ef29..124ec2b7 100644 --- a/packages/billing/src/test/billingFixtures.ts +++ b/packages/billing/src/test/billingFixtures.ts @@ -69,6 +69,9 @@ export function baseBillingContextExtras() { userId: 'user_1' as string | null, subscriptionError: null, refreshSubscription: (): Promise => Promise.resolve(), + plan: testPlan(), + planLoading: false, + planError: null, checkoutSuccessUrl: 'https://example.com/success', checkoutCancelUrl: 'https://example.com/cancel', portalReturnUrl: 'https://example.com/portal', diff --git a/packages/billing/src/types.ts b/packages/billing/src/types.ts index edffcaad..73798820 100644 --- a/packages/billing/src/types.ts +++ b/packages/billing/src/types.ts @@ -80,6 +80,9 @@ export type BillingContextValue< subscriptionLoading: boolean; subscriptionError: BillingError | null; refreshSubscription: () => Promise; + plan: Plan | null; + planLoading: boolean; + planError: BillingError | null; checkoutSuccessUrl: string; checkoutCancelUrl: string; portalReturnUrl: string; diff --git a/packages/billing/src/utils/launchStripeCheckout.native.ts b/packages/billing/src/utils/launchStripeCheckout.native.ts new file mode 100644 index 00000000..7791af1e --- /dev/null +++ b/packages/billing/src/utils/launchStripeCheckout.native.ts @@ -0,0 +1,22 @@ +import { openExternalUrl } from './openExternalUrl.native.js'; + +type StartCheckout = ( + planId: string, + cadence?: 'monthly' | 'annual', + trialDays?: number +) => Promise<{ checkoutUrl: string } | null>; + +/** + * Start Stripe Checkout via Edge Function, then open the session URL in the browser. + */ +export async function launchStripeCheckout( + startCheckout: StartCheckout, + planId: string, + cadence: 'monthly' | 'annual' = 'monthly', + trialDays?: number +): Promise { + const result = await startCheckout(planId, cadence, trialDays); + if (!result?.checkoutUrl) return false; + await openExternalUrl(result.checkoutUrl); + return true; +} diff --git a/packages/billing/src/utils/openExternalUrl.native.test.ts b/packages/billing/src/utils/openExternalUrl.native.test.ts new file mode 100644 index 00000000..f7467542 --- /dev/null +++ b/packages/billing/src/utils/openExternalUrl.native.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import * as RN from 'react-native'; +import { openExternalUrl } from './openExternalUrl.native.js'; + +describe('openExternalUrl (native)', () => { + beforeEach(() => { + vi.spyOn(RN.Linking, 'canOpenURL').mockResolvedValue(true as never); + vi.spyOn(RN.Linking, 'openURL').mockResolvedValue(undefined as never); + }); + + it('opens URL when supported', async () => { + await openExternalUrl('https://checkout.stripe.test/session'); + expect(RN.Linking.canOpenURL).toHaveBeenCalledWith( + 'https://checkout.stripe.test/session' + ); + expect(RN.Linking.openURL).toHaveBeenCalledWith( + 'https://checkout.stripe.test/session' + ); + }); + + it('throws when URL cannot be opened', async () => { + vi.mocked(RN.Linking.canOpenURL).mockResolvedValue(false as never); + await expect( + openExternalUrl('https://checkout.stripe.test/session') + ).rejects.toMatchObject({ + kind: 'stripe', + message: expect.not.stringContaining('cs_'), + }); + }); +}); diff --git a/packages/billing/src/utils/openExternalUrl.native.ts b/packages/billing/src/utils/openExternalUrl.native.ts new file mode 100644 index 00000000..8247d284 --- /dev/null +++ b/packages/billing/src/utils/openExternalUrl.native.ts @@ -0,0 +1,26 @@ +import { Linking } from 'react-native'; +import { billingError } from '../errors.js'; + +function redactedUrlForMessage(url: string): string { + try { + const u = new URL(url); + return u.host ? `${u.protocol}//${u.host}` : u.protocol; + } catch { + return 'external link'; + } +} + +/** + * Open an https (or custom-scheme) URL in the system browser / handler. + */ +export async function openExternalUrl(url: string): Promise { + const canOpen = await Linking.canOpenURL(url); + if (!canOpen) { + throw billingError( + 'stripe', + `Unable to open ${redactedUrlForMessage(url)}`, + url + ); + } + await Linking.openURL(url); +} diff --git a/packages/billing/src/utils/parseBillingFunctionError.test.ts b/packages/billing/src/utils/parseBillingFunctionError.test.ts new file mode 100644 index 00000000..474326b4 --- /dev/null +++ b/packages/billing/src/utils/parseBillingFunctionError.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { parseBillingFunctionError } from './parseBillingFunctionError.js'; + +describe('parseBillingFunctionError', () => { + it('uses error and hint from function JSON body', () => { + const err = parseBillingFunctionError( + { + error: 'invalid_redirect_url', + hint: 'Add origin to BILLING_ALLOWED_ORIGINS', + }, + new Error('FunctionsHttpError') + ); + expect(err.kind).toBe('stripe'); + expect(err.message).toContain('invalid_redirect_url'); + expect(err.message).toContain('BILLING_ALLOWED_ORIGINS'); + }); + + it('falls back to invoke error when body has no error field', () => { + const err = parseBillingFunctionError(null, new Error('network down')); + expect(err.message).toContain('network down'); + }); +}); diff --git a/packages/billing/src/utils/parseBillingFunctionError.ts b/packages/billing/src/utils/parseBillingFunctionError.ts new file mode 100644 index 00000000..7ec5a9e6 --- /dev/null +++ b/packages/billing/src/utils/parseBillingFunctionError.ts @@ -0,0 +1,33 @@ +import { billingError, mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; + +/** Body shape returned by `billing-stripe` on 4xx responses. */ +type BillingStripeErrorBody = { + error?: string; + hint?: string; +}; + +/** + * Prefer structured Edge Function JSON (`error` / `hint`) over generic invoke errors. + */ +export function parseBillingFunctionError( + data: unknown, + fnErr: unknown +): BillingError { + if (data && typeof data === 'object') { + const body = data as BillingStripeErrorBody; + if (typeof body.error === 'string' && body.error.trim()) { + const hint = + typeof body.hint === 'string' && body.hint.trim() + ? body.hint.trim() + : undefined; + return billingError( + 'stripe', + hint ? `${body.error}: ${hint}` : body.error, + fnErr ?? body + ); + } + } + if (fnErr) return mapUnknownError(fnErr); + return billingError('stripe', 'Billing request failed'); +} diff --git a/packages/shared-tests/__tests__/components/navigation/AppHeader.test.tsx b/packages/shared-tests/__tests__/components/navigation/AppHeader.test.tsx index 98fc3dfa..d7f0ef22 100644 --- a/packages/shared-tests/__tests__/components/navigation/AppHeader.test.tsx +++ b/packages/shared-tests/__tests__/components/navigation/AppHeader.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import '@testing-library/jest-dom'; import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.web'; @@ -27,6 +27,18 @@ const createMockSupabaseClient = (): SupabaseClient => { } as unknown as SupabaseClient; }; +function renderAppHeader(mockClient = createMockSupabaseClient()) { + return render( + + + + + + + + ); +} + describe('AppHeader (Web)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -145,4 +157,54 @@ describe('AppHeader (Web)', () => { const titleLink = screen.getByText(BRANDING.displayName).closest('a'); expect(titleLink).toHaveAttribute('href', '/'); }); + + it('header is sticky at the top of the viewport', () => { + renderAppHeader(); + const header = screen.getByRole('banner'); + expect(header.className).toContain('sticky'); + expect(header.className).toContain('top-0'); + expect(header.className).toContain('z-50'); + }); + + it('adds shadow class after scrolling past threshold', () => { + renderAppHeader(); + const header = screen.getByRole('banner'); + expect(header.className).not.toContain('shadow-sm'); + + act(() => { + Object.defineProperty(window, 'scrollY', { + value: 50, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event('scroll')); + }); + + expect(header.className).toContain('shadow-sm'); + }); + + it('removes shadow class when scrolled back to top', () => { + renderAppHeader(); + const header = screen.getByRole('banner'); + + act(() => { + Object.defineProperty(window, 'scrollY', { + value: 50, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event('scroll')); + }); + expect(header.className).toContain('shadow-sm'); + + act(() => { + Object.defineProperty(window, 'scrollY', { + value: 0, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event('scroll')); + }); + expect(header.className).not.toContain('shadow-sm'); + }); }); diff --git a/packages/shared-tests/__tests__/hooks/useAuth.test.ts b/packages/shared-tests/__tests__/hooks/useAuth.test.ts index 214a8d68..c2739d06 100644 --- a/packages/shared-tests/__tests__/hooks/useAuth.test.ts +++ b/packages/shared-tests/__tests__/hooks/useAuth.test.ts @@ -101,6 +101,26 @@ describe('useAuth', () => { }); }); + it('should set user and session directly after signIn resolves, before onAuthStateChange fires', async () => { + const { mockClient, mockUser, mockSession } = createMockSupabaseClient(); + const { result } = renderHook(() => useAuth(mockClient)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.signIn('test@example.com', 'password123'); + }); + + // user/session must be populated immediately when signIn resolves so that + // callers who navigate() right after see a non-null user in ProtectedRoute + // (guards against the race condition where only onAuthStateChange set them). + expect(result.current.user).toEqual(mockUser); + expect(result.current.session).toEqual(mockSession); + expect(result.current.loading).toBe(false); + }); + it('should handle sign up with email and password', async () => { const { mockClient } = createMockSupabaseClient(); const { result } = renderHook(() => useAuth(mockClient)); diff --git a/packages/shared/src/components/auth/ProtectedRoute.web.tsx b/packages/shared/src/components/auth/ProtectedRoute.web.tsx index 803206e1..ea0e9492 100644 --- a/packages/shared/src/components/auth/ProtectedRoute.web.tsx +++ b/packages/shared/src/components/auth/ProtectedRoute.web.tsx @@ -20,10 +20,10 @@ export function ProtectedRoute({ // Show loading state while checking authentication if (auth.loading) { return ( -

+
-

Loading...

+

Loading...

); diff --git a/packages/shared/src/components/forms/FormError.web.tsx b/packages/shared/src/components/forms/FormError.web.tsx index b3392166..8aa7170e 100644 --- a/packages/shared/src/components/forms/FormError.web.tsx +++ b/packages/shared/src/components/forms/FormError.web.tsx @@ -14,7 +14,7 @@ export function FormError({ message, className = '' }: FormErrorProps) { return (
@@ -34,7 +34,9 @@ export function FormError({ message, className = '' }: FormErrorProps) {
-

{message}

+

+ {message} +

diff --git a/packages/shared/src/components/forms/FormInput.web.tsx b/packages/shared/src/components/forms/FormInput.web.tsx index 5c5acdad..7efe23cf 100644 --- a/packages/shared/src/components/forms/FormInput.web.tsx +++ b/packages/shared/src/components/forms/FormInput.web.tsx @@ -36,14 +36,16 @@ export function FormInput({ const inputId = `form-input-${label.toLowerCase().replace(/\s+/g, '-')}`; const hasError = !!error; - const baseInputClasses = `w-full px-3 py-2 border rounded-md text-sm transition-colors ${ + const baseInputClasses = `w-full px-3 py-2 border rounded-md text-sm transition-colors dark:text-gray-100 ${ hasError - ? 'border-red-500 bg-red-50 focus:border-red-600 focus:ring-red-500' - : 'border-gray-300 bg-white focus:border-primary-500 focus:ring-primary-500' + ? 'border-red-500 bg-red-50 dark:border-red-400 dark:bg-red-900/20 focus:border-red-600 focus:ring-red-500' + : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-700 focus:border-primary-500 focus:ring-primary-500' } ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`; const labelClasses = `block text-sm font-medium mb-1 ${ - hasError ? 'text-red-700' : 'text-gray-700' + hasError + ? 'text-red-700 dark:text-red-400' + : 'text-gray-700 dark:text-gray-300' }`; return ( diff --git a/packages/shared/src/components/layout/ContentContainer.web.tsx b/packages/shared/src/components/layout/ContentContainer.web.tsx new file mode 100644 index 00000000..db9ea135 --- /dev/null +++ b/packages/shared/src/components/layout/ContentContainer.web.tsx @@ -0,0 +1,29 @@ +import type { ElementType, ReactNode } from 'react'; +import { + LAYOUT_WIDTH, + type LayoutWidthVariant, +} from '../../config/layoutWidth'; + +function joinClasses(...classes: (string | undefined)[]): string { + return classes.filter(Boolean).join(' '); +} + +export interface ContentContainerProps { + variant?: LayoutWidthVariant; + className?: string; + as?: ElementType; + children?: ReactNode; +} + +export function ContentContainer({ + variant = 'content', + className, + as: Component = 'div', + children, +}: ContentContainerProps) { + return ( + + {children} + + ); +} diff --git a/packages/shared/src/components/navigation/AppHeader.web.tsx b/packages/shared/src/components/navigation/AppHeader.web.tsx index 04eff68c..4ccb663b 100644 --- a/packages/shared/src/components/navigation/AppHeader.web.tsx +++ b/packages/shared/src/components/navigation/AppHeader.web.tsx @@ -1,9 +1,11 @@ +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import type { SupabaseClient } from '@supabase/supabase-js'; import { useAuthContext } from '../../contexts/AuthContext'; import { useProfileContext } from '../../contexts/ProfileContext'; import { UserMenu } from './UserMenu.web'; import { BRANDING } from '../../config/branding'; +import { ContentContainer } from '../layout/ContentContainer.web'; export interface AppHeaderProps { supabaseClient: SupabaseClient; @@ -16,6 +18,16 @@ export interface AppHeaderProps { export function AppHeader({ supabaseClient: _supabaseClient }: AppHeaderProps) { const auth = useAuthContext(); const profile = useProfileContext(); + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const onScroll = () => { + const next = window.scrollY > 8; + setScrolled(prev => (prev === next ? prev : next)); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); // Extract base path from current location (e.g., /pr-9 from /pr-9/login) // This handles path-based PR previews where the app is served from /pr-/ @@ -31,8 +43,14 @@ export function AppHeader({ supabaseClient: _supabaseClient }: AppHeaderProps) { const basePath = getBasePath(); return ( -
-
+
+
{/* Left side: App icon and title */}
@@ -73,7 +91,7 @@ export function AppHeader({ supabaseClient: _supabaseClient }: AppHeaderProps) { )}
-
-
+ + ); } diff --git a/packages/shared/src/components/navigation/UserMenu.native.tsx b/packages/shared/src/components/navigation/UserMenu.native.tsx index 040af092..90b690e3 100644 --- a/packages/shared/src/components/navigation/UserMenu.native.tsx +++ b/packages/shared/src/components/navigation/UserMenu.native.tsx @@ -38,7 +38,6 @@ export interface UserMenuProps { */ export function UserMenu({ user, profile, navigation }: UserMenuProps) { const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); const avatarRef = useRef(null); const [avatarLayout, setAvatarLayout] = useState({ x: 0, @@ -87,7 +86,7 @@ export function UserMenu({ user, profile, navigation }: UserMenuProps) { return ( <> - + - - Sign Out - + Sign Out @@ -223,7 +220,4 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#374151', }, - menuItemDangerText: { - color: '#374151', // Keep same color as web for consistency - }, }); diff --git a/packages/shared/src/components/navigation/UserMenu.web.tsx b/packages/shared/src/components/navigation/UserMenu.web.tsx index eee1163b..e567c6d3 100644 --- a/packages/shared/src/components/navigation/UserMenu.web.tsx +++ b/packages/shared/src/components/navigation/UserMenu.web.tsx @@ -89,7 +89,7 @@ export function UserMenu({ user, profile }: UserMenuProps) { onClick={() => setIsOpen(false)} className='flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700' > - + Billing = { primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 border border-transparent', secondary: - 'bg-white text-gray-900 border border-gray-200 hover:bg-gray-50 focus:ring-gray-400', + 'bg-white text-gray-900 border border-gray-200 hover:bg-gray-50 focus:ring-gray-400 dark:bg-gray-800 dark:text-gray-100 dark:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-500', destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 border border-transparent', ghost: - 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400 border border-transparent', + 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400 border border-transparent dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:focus:ring-gray-500', }; /** diff --git a/packages/shared/src/components/primitives/Modal.web.tsx b/packages/shared/src/components/primitives/Modal.web.tsx index 4f2e4a92..859e5424 100644 --- a/packages/shared/src/components/primitives/Modal.web.tsx +++ b/packages/shared/src/components/primitives/Modal.web.tsx @@ -159,7 +159,7 @@ export function Modal({ ref={panelRef} tabIndex={-1} className={[ - 'relative w-full rounded-xl border border-gray-200 bg-white p-6 shadow-xl focus:outline-none', + 'relative w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-xl focus:outline-none', sizeToMax[size], className, ] @@ -169,14 +169,17 @@ export function Modal({ > {title && (
-

+

{title}

{showCloseButton && ( @@ -203,7 +205,7 @@ export function AvatarUpload({ type='button' onClick={handleRemove} disabled={uploading} - className='px-3 py-1.5 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed' + className='px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-gray-700 border border-red-300 dark:border-red-500 rounded-md hover:bg-red-50 dark:hover:bg-red-900/30 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed' > Remove @@ -212,7 +214,7 @@ export function AvatarUpload({ {/* Progress Bar */} {uploading && progress > 0 && ( -
+
{error.message}
} + {error && ( +
+ {error.message} +
+ )} {/* Help Text */} -

JPEG, PNG, or WebP. Max 2MB.

+

+ JPEG, PNG, or WebP. Max 2MB. +

diff --git a/packages/shared/src/components/profile/ProfileAvatar.web.tsx b/packages/shared/src/components/profile/ProfileAvatar.web.tsx index 66b191fb..134ce403 100644 --- a/packages/shared/src/components/profile/ProfileAvatar.web.tsx +++ b/packages/shared/src/components/profile/ProfileAvatar.web.tsx @@ -68,7 +68,7 @@ export function ProfileAvatar({ target.style.display = 'none'; const parent = target.parentElement; if (parent) { - parent.innerHTML = `
${getInitials()}
`; + parent.innerHTML = `
${getInitials()}
`; } }} /> @@ -77,7 +77,7 @@ export function ProfileAvatar({ return (
diff --git a/packages/shared/src/components/profile/ProfileEditor.web.tsx b/packages/shared/src/components/profile/ProfileEditor.web.tsx index 27bfdd4e..3d6d0e43 100644 --- a/packages/shared/src/components/profile/ProfileEditor.web.tsx +++ b/packages/shared/src/components/profile/ProfileEditor.web.tsx @@ -133,8 +133,10 @@ export function ProfileEditor({ if (!currentUser) { return ( -
-

+

+

Please sign in to edit your profile.

@@ -143,15 +145,21 @@ export function ProfileEditor({ if (loading && !profileData) { return ( -
-

Loading profile...

+
+

+ Loading profile... +

); } return (
-

Edit Profile

+

+ Edit Profile +

-

No profile data available.

+
+

+ No profile data available. +

); } @@ -34,12 +38,16 @@ export function ProfileHeader({
-

+

{displayName}

- {username &&

{username}

} + {username && ( +

+ {username} +

+ )} {hasMetadata && ( -
+
{email && (
{profile.bio && ( -

{profile.bio}

+

+ {profile.bio} +

)}
); diff --git a/packages/shared/src/components/profile/ProfileStats.web.tsx b/packages/shared/src/components/profile/ProfileStats.web.tsx index 92c59268..6a26441e 100644 --- a/packages/shared/src/components/profile/ProfileStats.web.tsx +++ b/packages/shared/src/components/profile/ProfileStats.web.tsx @@ -58,12 +58,12 @@ export function ProfileStats({ profile, className = '' }: ProfileStatsProps) { return (
{memberSince && ( -
+
Member since: {memberSince}
)} {completion > 0 && ( -
+
Profile completion: {completion}%
)} diff --git a/packages/shared/src/config/layoutWidth.ts b/packages/shared/src/config/layoutWidth.ts new file mode 100644 index 00000000..18a62abd --- /dev/null +++ b/packages/shared/src/config/layoutWidth.ts @@ -0,0 +1,7 @@ +/** Canonical max-width + horizontal padding for web page chrome and shells. */ +export const LAYOUT_WIDTH = { + content: 'max-w-content mx-auto px-4 sm:px-6 lg:px-8', + prose: 'max-w-prose mx-auto px-4 sm:px-6 lg:px-8', +} as const; + +export type LayoutWidthVariant = keyof typeof LAYOUT_WIDTH; diff --git a/packages/shared/src/config/legal.ts b/packages/shared/src/config/legal.ts index 8e0db029..deeef714 100644 --- a/packages/shared/src/config/legal.ts +++ b/packages/shared/src/config/legal.ts @@ -1,7 +1,7 @@ import { BRANDING } from './branding'; export const LEGAL_CONFIG = { - brandName: BRANDING.pascalName, + brandName: BRANDING.displayName, brandUrl: 'BeakerStack.com', legalEntityName: 'Artificer Innovations, LLC', contactEmail: 'contact@artificerinnovations.com', diff --git a/packages/shared/src/generated/policies.ts b/packages/shared/src/generated/policies.ts index 49781cf2..e55f5d14 100644 --- a/packages/shared/src/generated/policies.ts +++ b/packages/shared/src/generated/policies.ts @@ -3,11 +3,11 @@ export const POLICIES: Record = { terms: - '

Terms of Service

\n

Our Terms of Service were last updated on September 7, 2025.

\n

These Terms of Service apply to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by BeakerStack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Please read them carefully before using our website or purchasing our services and products.

\n

Article 1: General

\n

By using this website (the "Site"), you agree to be bound by these Terms of Service and to use the Site in accordance with these Terms, our Privacy Policy, and any additional terms and conditions that may apply to specific sections of the Site or to products and services available through the Site from Artificer Innovations, LLC ("Company", "we", "our", or "us").

\n

Accessing the Site, in any manner, whether automated or otherwise, constitutes use of the Site and your agreement to be bound by these Terms of Service.

\n

Article 1a: Age Limit

\n

This Site is not directed to children under the age of 13, and we do not knowingly collect personal information from children under 13.

\n

Article 2: Services and Products

\n

Consulting Services

\n

Artificer Innovations, LLC provides advisory and consulting services. By engaging our services, you agree to:

\n
    \n
  • Provide accurate and complete information necessary for service delivery
  • \n
  • Comply with all applicable laws and regulations
  • \n
  • Respect confidentiality agreements and intellectual property rights
  • \n
  • Pay all fees as agreed upon in our service agreements
  • \n
\n

Digital Products

\n

We offer digital products including but not limited to the 100x Developers Playbook and other educational materials. By purchasing digital products, you agree to:

\n
    \n
  • Use the products for personal or authorized business purposes only
  • \n
  • Not redistribute, resell, or share access to the products
  • \n
  • Respect all copyright and intellectual property rights
  • \n
\n

Article 3: Placing Orders

\n

By placing an order for digital products through the Site, you warrant that you are legally capable of entering into binding contracts and that you are at least 18 years old.

\n

Article 4: Pricing and Payment

\n

Pricing

\n

All prices are indicated in U.S. dollars. Prices for services and products may be changed at any time; however, the price applied to an order will be the one stated at the time of purchase or service agreement.

\n

Payment Terms

\n
    \n
  • Digital Products: Payment is due immediately at the time of order
  • \n
  • Consulting Services: Payment terms are specified in individual service agreements
  • \n
  • Pre-orders: Payment is due at the time of pre-order placement
  • \n
\n

Payment can be made through any of the payment methods we have available, such as Visa, MasterCard, American Express, or online payment methods (for example, PayPal).

\n

Payment cards (credit or debit) are subject to validation checks and authorization by your card issuer. If we do not receive the required authorization, we will not be liable for failure to complete your order or provide access to the digital product.

\n

Article 5: Your Information

\n

If you wish to place an order for digital products or engage our consulting services, you may be asked to supply certain information relevant to your order or engagement, including, without limitation, your name, email address, billing address, and payment information.

\n

You represent and warrant that:

\n
    \n
  • You have the legal right to use any credit or debit card(s) or any other payment method(s) in connection with any order
  • \n
  • The information you supply to us is true, correct, and complete
  • \n
  • You will maintain the accuracy of such information
  • \n
\n

By submitting such information, you grant us the right to provide the information to payment processing third parties for purposes of facilitating the completion of your order.

\n

Article 6: Delivery and Access

\n

Digital Products

\n

Delivery of digital products is made electronically, either through direct download from the Site or to the email address you provide at the time of purchase. Please ensure your email address is accurate to avoid delivery issues.

\n

Once access to the digital product has been provided, the responsibility for downloading, storing, and securing the product is yours. If you experience any difficulty accessing your purchase, please contact us at contact@artificerinnovations.com and we will assist you in receiving your product.

\n

Consulting Services

\n

Service delivery terms are specified in individual service agreements. We will work with you to establish appropriate timelines and deliverables for each engagement.

\n

Article 7: Intellectual Property

\n

All content on this website, including but not limited to text, graphics, logos, images, and software, is the property of Artificer Innovations, LLC or its content suppliers and is protected by copyright and other intellectual property laws.

\n

Our digital products are protected by copyright and other intellectual property rights. You may not:

\n
    \n
  • Copy, distribute, or resell our digital products
  • \n
  • Share access credentials with others
  • \n
  • Use our content for commercial purposes without written permission
  • \n
\n

Article 8: Limitation of Liability

\n

To the fullest extent permitted by law, Artificer Innovations, LLC shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Site or our services and products.

\n

Article 9: Changes to These Terms

\n

We may update these Terms of Service from time to time. Any changes will be effective immediately upon posting on the Site. Your continued use of the Site after changes are posted constitutes your acceptance of the updated Terms.

\n

We will notify users of material changes via email or through a notice on our website.

\n

Article 10: Governing Law

\n

These Terms of Service shall be governed by and construed in accordance with the laws of the State of Washington, without regard to its conflict of law provisions.

\n

Article 11: Contact Information

\n

For questions about these Terms of Service, please contact us at contact@artificerinnovations.com.

\n
\n

These Terms of Service are effective as of September 7, 2025, and apply to all users of our website and purchasers of our services and products.

\n', + '

Terms of Service

\n

Our Terms of Service were last updated on September 7, 2025.

\n

These Terms of Service apply to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by Beaker Stack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Please read them carefully before using our website or purchasing our services and products.

\n

Article 1: General

\n

By using this website (the "Site"), you agree to be bound by these Terms of Service and to use the Site in accordance with these Terms, our Privacy Policy, and any additional terms and conditions that may apply to specific sections of the Site or to products and services available through the Site from Artificer Innovations, LLC ("Company", "we", "our", or "us").

\n

Accessing the Site, in any manner, whether automated or otherwise, constitutes use of the Site and your agreement to be bound by these Terms of Service.

\n

Article 1a: Age Limit

\n

This Site is not directed to children under the age of 13, and we do not knowingly collect personal information from children under 13.

\n

Article 2: Services and Products

\n

Consulting Services

\n

Artificer Innovations, LLC provides advisory and consulting services. By engaging our services, you agree to:

\n
    \n
  • Provide accurate and complete information necessary for service delivery
  • \n
  • Comply with all applicable laws and regulations
  • \n
  • Respect confidentiality agreements and intellectual property rights
  • \n
  • Pay all fees as agreed upon in our service agreements
  • \n
\n

Digital Products

\n

We offer digital products including but not limited to the 100x Developers Playbook and other educational materials. By purchasing digital products, you agree to:

\n
    \n
  • Use the products for personal or authorized business purposes only
  • \n
  • Not redistribute, resell, or share access to the products
  • \n
  • Respect all copyright and intellectual property rights
  • \n
\n

Article 3: Placing Orders

\n

By placing an order for digital products through the Site, you warrant that you are legally capable of entering into binding contracts and that you are at least 18 years old.

\n

Article 4: Pricing and Payment

\n

Pricing

\n

All prices are indicated in U.S. dollars. Prices for services and products may be changed at any time; however, the price applied to an order will be the one stated at the time of purchase or service agreement.

\n

Payment Terms

\n
    \n
  • Digital Products: Payment is due immediately at the time of order
  • \n
  • Consulting Services: Payment terms are specified in individual service agreements
  • \n
  • Pre-orders: Payment is due at the time of pre-order placement
  • \n
\n

Payment can be made through any of the payment methods we have available, such as Visa, MasterCard, American Express, or online payment methods (for example, PayPal).

\n

Payment cards (credit or debit) are subject to validation checks and authorization by your card issuer. If we do not receive the required authorization, we will not be liable for failure to complete your order or provide access to the digital product.

\n

Article 5: Your Information

\n

If you wish to place an order for digital products or engage our consulting services, you may be asked to supply certain information relevant to your order or engagement, including, without limitation, your name, email address, billing address, and payment information.

\n

You represent and warrant that:

\n
    \n
  • You have the legal right to use any credit or debit card(s) or any other payment method(s) in connection with any order
  • \n
  • The information you supply to us is true, correct, and complete
  • \n
  • You will maintain the accuracy of such information
  • \n
\n

By submitting such information, you grant us the right to provide the information to payment processing third parties for purposes of facilitating the completion of your order.

\n

Article 6: Delivery and Access

\n

Digital Products

\n

Delivery of digital products is made electronically, either through direct download from the Site or to the email address you provide at the time of purchase. Please ensure your email address is accurate to avoid delivery issues.

\n

Once access to the digital product has been provided, the responsibility for downloading, storing, and securing the product is yours. If you experience any difficulty accessing your purchase, please contact us at contact@artificerinnovations.com and we will assist you in receiving your product.

\n

Consulting Services

\n

Service delivery terms are specified in individual service agreements. We will work with you to establish appropriate timelines and deliverables for each engagement.

\n

Article 7: Intellectual Property

\n

All content on this website, including but not limited to text, graphics, logos, images, and software, is the property of Artificer Innovations, LLC or its content suppliers and is protected by copyright and other intellectual property laws.

\n

Our digital products are protected by copyright and other intellectual property rights. You may not:

\n
    \n
  • Copy, distribute, or resell our digital products
  • \n
  • Share access credentials with others
  • \n
  • Use our content for commercial purposes without written permission
  • \n
\n

Article 8: Limitation of Liability

\n

To the fullest extent permitted by law, Artificer Innovations, LLC shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Site or our services and products.

\n

Article 9: Changes to These Terms

\n

We may update these Terms of Service from time to time. Any changes will be effective immediately upon posting on the Site. Your continued use of the Site after changes are posted constitutes your acceptance of the updated Terms.

\n

We will notify users of material changes via email or through a notice on our website.

\n

Article 10: Governing Law

\n

These Terms of Service shall be governed by and construed in accordance with the laws of the State of Washington, without regard to its conflict of law provisions.

\n

Article 11: Contact Information

\n

For questions about these Terms of Service, please contact us at contact@artificerinnovations.com.

\n
\n

These Terms of Service are effective as of September 7, 2025, and apply to all users of our website and purchasers of our services and products.

\n', privacy: - '

Privacy Policy

\n

Our Privacy Policy was last updated on September 7, 2025.

\n

This Privacy Policy applies to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by BeakerStack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Personal Identification Information

\n

We may collect personal identification information from Users in a variety of ways, including when they:

\n
    \n
  • Visit our Site
  • \n
  • Subscribe to our newsletter or communications
  • \n
  • Request information about our services
  • \n
  • Engage our consulting services
  • \n
  • Purchase or download our digital products
  • \n
  • Contact us through our website
  • \n
\n

Users may be asked for information such as:

\n
    \n
  • Name and email address
  • \n
  • Company information (for consulting services)
  • \n
  • Billing information (for purchases)
  • \n
  • Phone number (for service inquiries)
  • \n
\n

Users may also visit our Site anonymously. We will collect personal identification information from Users only if they voluntarily provide it. Users can always choose not to provide such information, though it may prevent them from accessing certain features of the Site, receiving our services, or obtaining digital products.

\n

Non-Personal Identification Information

\n

We may collect non-personal identification information about Users whenever they interact with our Site. Non-personal identification information may include:

\n
    \n
  • Browser name and version
  • \n
  • Type of computer and operating system
  • \n
  • Internet service provider
  • \n
  • IP address
  • \n
  • Pages visited and time spent on our Site
  • \n
  • Referring website information
  • \n
  • Other similar technical information
  • \n
\n

Web Browser Cookies

\n

Our Site may use 'cookies' to enhance User experience. User's web browser places cookies on their hard drive for record-keeping purposes and sometimes to track information about them. User may choose to set their web browser to refuse cookies, or to alert you when cookies are being sent. If they do so, note that some parts of the Site may not function properly.

\n

We may use cookies for:

\n
    \n
  • Remembering user preferences
  • \n
  • Analyzing site traffic and usage patterns
  • \n
  • Improving site functionality
  • \n
  • Personalizing content
  • \n
\n

How We Use Collected Information

\n

Our Site may collect and use Users' personal information for the following purposes:

\n

To Improve Customer Service

\n

Information you provide helps us respond to your customer service requests and support needs more efficiently.

\n

To Personalize User Experience

\n

We may use aggregated information to understand how Users as a group interact with our Site, services, and digital products.

\n

To Process Transactions

\n

We may use the information Users provide about themselves when placing an order or engaging our services only to provide service to that order or engagement. We do not share this information with outside parties except to the extent necessary to provide the service.

\n

To Send Periodic Communications

\n

We may use the email address to send Users information and updates related to their orders, service engagements, as well as to respond to inquiries or requests. If a User opts in to our mailing list, they may receive emails that include company updates, new product information, industry insights, and promotional materials (with clear opt-out options).

\n

Each email will include clear unsubscribe instructions, and Users may also contact us through the Site to be removed from future communications.

\n

To Provide Consulting Services

\n

For consulting engagements, we may use personal and business information to understand your business needs, deliver customized advisory services, maintain project documentation, and provide ongoing support and follow-up.

\n

How We Protect Your Information

\n

We adopt appropriate data collection, storage and processing practices, and security measures to protect against unauthorized access, alteration, disclosure or destruction of your personal information, including:

\n
    \n
  • Secure data transmission (SSL/TLS encryption)
  • \n
  • Regular security assessments
  • \n
  • Access controls and authentication
  • \n
  • Secure data storage and backup procedures
  • \n
  • Employee training on data protection
  • \n
\n

Sharing Your Personal Information

\n

We do not sell, trade, or rent Users' personal identification information to others. We may share generic aggregated demographic information not linked to any personal identification information regarding visitors and users with our business partners, trusted affiliates and advertisers for the purposes outlined above.

\n

We may share personal information in the following limited circumstances:

\n
    \n
  • With your explicit consent
  • \n
  • To comply with legal obligations
  • \n
  • To protect our rights and prevent fraud
  • \n
  • With service providers who assist us in operating our business (under strict confidentiality agreements)
  • \n
  • In connection with a business transfer or acquisition
  • \n
\n

Data Retention

\n

We retain personal information only as long as necessary to fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required or permitted by law. For consulting services, we may retain project-related information for business and legal purposes.

\n

Third Party Websites

\n

Users may find advertising or other content on our Site that links to third-party websites. We do not control the content or links that appear on these sites and are not responsible for the practices employed by websites linked to or from our Site. Browsing and interaction on any other website are subject to that website's own policies.

\n

International Data Transfers

\n

If you are accessing our Site from outside the United States, please be aware that your information may be transferred to, stored, and processed in the United States where our servers are located and our central database is operated.

\n

Your Rights

\n

Depending on your location, you may have certain rights regarding your personal information, including:

\n
    \n
  • The right to access your personal information
  • \n
  • The right to correct inaccurate information
  • \n
  • The right to delete your personal information
  • \n
  • The right to restrict or object to processing
  • \n
  • The right to data portability
  • \n
  • The right to withdraw consent
  • \n
\n

To exercise these rights, please contact us using the information provided below.

\n

Children's Privacy

\n

Our Site is not directed to children under the age of 13, and we do not knowingly collect personal information from children under 13. If we become aware that we have collected personal information from a child under 13, we will take steps to delete such information.

\n

Changes to This Privacy Policy

\n

The Company has the discretion to update this privacy policy at any time. When we do, we will revise the updated date at the top of this page. We encourage Users to frequently check this page for any changes to stay informed about how we are helping to protect the personal information we collect.

\n

Contact Us

\n

If you have questions about this Privacy Policy or wish to exercise your rights regarding your personal information, please contact us at contact@artificerinnovations.com.

\n
\n

This Privacy Policy is effective as of September 7, 2025, and applies to all users of our website and recipients of our services and products.

\n', + '

Privacy Policy

\n

Our Privacy Policy was last updated on September 7, 2025.

\n

This Privacy Policy applies to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by Beaker Stack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Personal Identification Information

\n

We may collect personal identification information from Users in a variety of ways, including when they:

\n
    \n
  • Visit our Site
  • \n
  • Subscribe to our newsletter or communications
  • \n
  • Request information about our services
  • \n
  • Engage our consulting services
  • \n
  • Purchase or download our digital products
  • \n
  • Contact us through our website
  • \n
\n

Users may be asked for information such as:

\n
    \n
  • Name and email address
  • \n
  • Company information (for consulting services)
  • \n
  • Billing information (for purchases)
  • \n
  • Phone number (for service inquiries)
  • \n
\n

Users may also visit our Site anonymously. We will collect personal identification information from Users only if they voluntarily provide it. Users can always choose not to provide such information, though it may prevent them from accessing certain features of the Site, receiving our services, or obtaining digital products.

\n

Non-Personal Identification Information

\n

We may collect non-personal identification information about Users whenever they interact with our Site. Non-personal identification information may include:

\n
    \n
  • Browser name and version
  • \n
  • Type of computer and operating system
  • \n
  • Internet service provider
  • \n
  • IP address
  • \n
  • Pages visited and time spent on our Site
  • \n
  • Referring website information
  • \n
  • Other similar technical information
  • \n
\n

Web Browser Cookies

\n

Our Site may use 'cookies' to enhance User experience. User's web browser places cookies on their hard drive for record-keeping purposes and sometimes to track information about them. User may choose to set their web browser to refuse cookies, or to alert you when cookies are being sent. If they do so, note that some parts of the Site may not function properly.

\n

We may use cookies for:

\n
    \n
  • Remembering user preferences
  • \n
  • Analyzing site traffic and usage patterns
  • \n
  • Improving site functionality
  • \n
  • Personalizing content
  • \n
\n

How We Use Collected Information

\n

Our Site may collect and use Users' personal information for the following purposes:

\n

To Improve Customer Service

\n

Information you provide helps us respond to your customer service requests and support needs more efficiently.

\n

To Personalize User Experience

\n

We may use aggregated information to understand how Users as a group interact with our Site, services, and digital products.

\n

To Process Transactions

\n

We may use the information Users provide about themselves when placing an order or engaging our services only to provide service to that order or engagement. We do not share this information with outside parties except to the extent necessary to provide the service.

\n

To Send Periodic Communications

\n

We may use the email address to send Users information and updates related to their orders, service engagements, as well as to respond to inquiries or requests. If a User opts in to our mailing list, they may receive emails that include company updates, new product information, industry insights, and promotional materials (with clear opt-out options).

\n

Each email will include clear unsubscribe instructions, and Users may also contact us through the Site to be removed from future communications.

\n

To Provide Consulting Services

\n

For consulting engagements, we may use personal and business information to understand your business needs, deliver customized advisory services, maintain project documentation, and provide ongoing support and follow-up.

\n

How We Protect Your Information

\n

We adopt appropriate data collection, storage and processing practices, and security measures to protect against unauthorized access, alteration, disclosure or destruction of your personal information, including:

\n
    \n
  • Secure data transmission (SSL/TLS encryption)
  • \n
  • Regular security assessments
  • \n
  • Access controls and authentication
  • \n
  • Secure data storage and backup procedures
  • \n
  • Employee training on data protection
  • \n
\n

Sharing Your Personal Information

\n

We do not sell, trade, or rent Users' personal identification information to others. We may share generic aggregated demographic information not linked to any personal identification information regarding visitors and users with our business partners, trusted affiliates and advertisers for the purposes outlined above.

\n

We may share personal information in the following limited circumstances:

\n
    \n
  • With your explicit consent
  • \n
  • To comply with legal obligations
  • \n
  • To protect our rights and prevent fraud
  • \n
  • With service providers who assist us in operating our business (under strict confidentiality agreements)
  • \n
  • In connection with a business transfer or acquisition
  • \n
\n

Data Retention

\n

We retain personal information only as long as necessary to fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required or permitted by law. For consulting services, we may retain project-related information for business and legal purposes.

\n

Third Party Websites

\n

Users may find advertising or other content on our Site that links to third-party websites. We do not control the content or links that appear on these sites and are not responsible for the practices employed by websites linked to or from our Site. Browsing and interaction on any other website are subject to that website's own policies.

\n

International Data Transfers

\n

If you are accessing our Site from outside the United States, please be aware that your information may be transferred to, stored, and processed in the United States where our servers are located and our central database is operated.

\n

Your Rights

\n

Depending on your location, you may have certain rights regarding your personal information, including:

\n
    \n
  • The right to access your personal information
  • \n
  • The right to correct inaccurate information
  • \n
  • The right to delete your personal information
  • \n
  • The right to restrict or object to processing
  • \n
  • The right to data portability
  • \n
  • The right to withdraw consent
  • \n
\n

To exercise these rights, please contact us using the information provided below.

\n

Children's Privacy

\n

Our Site is not directed to children under the age of 13, and we do not knowingly collect personal information from children under 13. If we become aware that we have collected personal information from a child under 13, we will take steps to delete such information.

\n

Changes to This Privacy Policy

\n

The Company has the discretion to update this privacy policy at any time. When we do, we will revise the updated date at the top of this page. We encourage Users to frequently check this page for any changes to stay informed about how we are helping to protect the personal information we collect.

\n

Contact Us

\n

If you have questions about this Privacy Policy or wish to exercise your rights regarding your personal information, please contact us at contact@artificerinnovations.com.

\n
\n

This Privacy Policy is effective as of September 7, 2025, and applies to all users of our website and recipients of our services and products.

\n', refunds: - '

Refunds Policy

\n

This Refunds Policy applies to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by BeakerStack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Digital Products

\n

All digital products sold through our website are delivered electronically. Due to the instant delivery nature of digital products, refunds are generally not available once access has been provided. However, we may provide refunds in the following circumstances:

\n

Digital Product Exceptions

\n

We may provide a refund or replacement at our sole discretion in the following limited circumstances:

\n
    \n
  • Duplicate Charges: If you are charged more than once for the same product due to a technical error
  • \n
  • Failed Delivery: If you did not receive access to your digital product after purchase (for example, failed delivery of a download link or access credentials)
  • \n
  • Product Defect: If the digital product is materially different from what was described or advertised
  • \n
  • Unauthorized Purchase: If the purchase was made without your authorization (subject to verification)
  • \n
\n

In these cases, please contact us within 7 days of purchase at contact@artificerinnovations.com and include your order details. We will work with you to ensure you receive access to the product you purchased or, if necessary, issue a refund.

\n

Customer Satisfaction

\n

We stand behind the quality of our digital products. If you have concerns about the content or quality, please contact us first so we can address your concerns.

\n

Pre-Order Products

\n

If you purchase a digital product on pre-order and it is significantly delayed beyond the advertised release date, you may request a refund by contacting us at contact@artificerinnovations.com.

\n

Consulting Services

\n

Service Engagement Cancellation

\n

For consulting services, cancellation and refund terms are specified in individual service agreements. Generally:

\n
    \n
  • Before Service Commencement: Full refund available if cancelled before work begins
  • \n
  • During Service Delivery: Refunds are prorated based on work completed and are subject to the terms of your specific service agreement
  • \n
  • After Service Completion: No refunds are available for completed services
  • \n
\n

Service Agreement Terms

\n

Each consulting engagement includes specific terms regarding cancellation policies, payment schedules, deliverable requirements, and refund conditions. Please refer to your individual service agreement for specific refund terms applicable to your engagement.

\n

Subscription Services

\n

Cancellation Policy

\n

If we offer subscription-based services, the following cancellation terms will apply:

\n

Monthly Subscriptions

\n
    \n
  • Cancellation: You may cancel your subscription at any time through your account settings or by contacting us
  • \n
  • Effective Date: Cancellation takes effect at the end of your current billing period
  • \n
  • Access: You will retain access to subscription benefits until the end of your paid period
  • \n
  • Refunds: No refunds for unused time in the current billing period
  • \n
\n

Annual Subscriptions

\n
    \n
  • Cancellation: You may cancel your annual subscription at any time
  • \n
  • Refund Window: Full refund available if cancelled within 14 days of purchase
  • \n
  • After 14 Days: No refunds available, but cancellation prevents future billing
  • \n
  • Access: You retain access until the end of your paid annual period
  • \n
\n

Recurring Billing

\n

Subscriptions automatically renew unless cancelled before the renewal date. You will receive a receipt after successful payment. Ensure your payment method is current to avoid service interruption.

\n

Subscription Cancellation Process

\n

To cancel a subscription:

\n
    \n
  1. Online: Log into your account and navigate to subscription settings
  2. \n
  3. Email: Contact us at contact@artificerinnovations.com with your subscription details
  4. \n
  5. Confirmation: You will receive email confirmation of your cancellation
  6. \n
  7. Effective Date: Cancellation takes effect at the end of your current billing period
  8. \n
\n

Refund Processing

\n

Timeline

\n
    \n
  • Digital Products: Refunds will be processed within 5-10 business days of approval
  • \n
  • Consulting Services: Refunds will be processed according to the terms specified in your service agreement
  • \n
\n

Method

\n

Refunds will be issued to the original payment method used for the purchase. Processing times may vary depending on your financial institution.

\n

Non-Refundable Items

\n

The following are not eligible for refunds:

\n
    \n
  • Completed Consulting Services: Services that have been fully delivered according to the service agreement
  • \n
  • Downloaded Digital Products: Digital products that have been successfully downloaded or accessed
  • \n
  • Custom Work: Any custom consulting work or deliverables that have been completed
  • \n
  • Third-Party Services: Any third-party services or products recommended or facilitated by us
  • \n
  • Subscription Services: Used portions of subscription periods (except as specified in subscription terms)
  • \n
  • Promotional or Discounted Items: Items purchased with special pricing or promotional codes (unless otherwise specified)
  • \n
\n

Dispute Resolution

\n

If you believe you are entitled to a refund that has been denied, you may:

\n
    \n
  1. Contact Us: Reach out to us at contact@artificerinnovations.com with detailed information about your concern
  2. \n
  3. Provide Documentation: Include relevant documentation such as order confirmations, service agreements, or correspondence
  4. \n
  5. Review Process: We will review your request and respond within 5 business days
  6. \n
\n

Chargebacks and Payment Disputes

\n

We encourage customers to contact us directly to resolve any issues before initiating chargebacks or payment disputes. Chargebacks and disputes may result in additional fees and may affect your ability to purchase our services or products in the future.

\n

Changes to This Policy

\n

We reserve the right to modify this Refund Policy at any time. Changes will be effective immediately upon posting on our website. Your continued use of our services or purchase of our products after changes are posted constitutes acceptance of the updated policy.

\n

Contact Us

\n

If you have any questions about this Refund Policy or need to request a refund, please contact us at contact@artificerinnovations.com.

\n

When contacting us about a refund, please include:

\n
    \n
  • Your order number or service agreement reference
  • \n
  • Date of purchase or service engagement
  • \n
  • Description of the issue
  • \n
  • Any relevant documentation
  • \n
\n
\n

This Refund Policy is effective as of September 7, 2025, and applies to all purchases and service engagements made after this date.

\n', + '

Refunds Policy

\n

This Refunds Policy applies to BeakerStack.com and all other websites and brands operated by Artificer Innovations, LLC. This website is operated by Beaker Stack, a brand of Artificer Innovations, LLC. References to "Company," "we," "our," or "us" refer to Artificer Innovations, LLC regardless of which brand website you are accessing.

\n

Digital Products

\n

All digital products sold through our website are delivered electronically. Due to the instant delivery nature of digital products, refunds are generally not available once access has been provided. However, we may provide refunds in the following circumstances:

\n

Digital Product Exceptions

\n

We may provide a refund or replacement at our sole discretion in the following limited circumstances:

\n
    \n
  • Duplicate Charges: If you are charged more than once for the same product due to a technical error
  • \n
  • Failed Delivery: If you did not receive access to your digital product after purchase (for example, failed delivery of a download link or access credentials)
  • \n
  • Product Defect: If the digital product is materially different from what was described or advertised
  • \n
  • Unauthorized Purchase: If the purchase was made without your authorization (subject to verification)
  • \n
\n

In these cases, please contact us within 7 days of purchase at contact@artificerinnovations.com and include your order details. We will work with you to ensure you receive access to the product you purchased or, if necessary, issue a refund.

\n

Customer Satisfaction

\n

We stand behind the quality of our digital products. If you have concerns about the content or quality, please contact us first so we can address your concerns.

\n

Pre-Order Products

\n

If you purchase a digital product on pre-order and it is significantly delayed beyond the advertised release date, you may request a refund by contacting us at contact@artificerinnovations.com.

\n

Consulting Services

\n

Service Engagement Cancellation

\n

For consulting services, cancellation and refund terms are specified in individual service agreements. Generally:

\n
    \n
  • Before Service Commencement: Full refund available if cancelled before work begins
  • \n
  • During Service Delivery: Refunds are prorated based on work completed and are subject to the terms of your specific service agreement
  • \n
  • After Service Completion: No refunds are available for completed services
  • \n
\n

Service Agreement Terms

\n

Each consulting engagement includes specific terms regarding cancellation policies, payment schedules, deliverable requirements, and refund conditions. Please refer to your individual service agreement for specific refund terms applicable to your engagement.

\n

Subscription Services

\n

Cancellation Policy

\n

If we offer subscription-based services, the following cancellation terms will apply:

\n

Monthly Subscriptions

\n
    \n
  • Cancellation: You may cancel your subscription at any time through your account settings or by contacting us
  • \n
  • Effective Date: Cancellation takes effect at the end of your current billing period
  • \n
  • Access: You will retain access to subscription benefits until the end of your paid period
  • \n
  • Refunds: No refunds for unused time in the current billing period
  • \n
\n

Annual Subscriptions

\n
    \n
  • Cancellation: You may cancel your annual subscription at any time
  • \n
  • Refund Window: Full refund available if cancelled within 14 days of purchase
  • \n
  • After 14 Days: No refunds available, but cancellation prevents future billing
  • \n
  • Access: You retain access until the end of your paid annual period
  • \n
\n

Recurring Billing

\n

Subscriptions automatically renew unless cancelled before the renewal date. You will receive a receipt after successful payment. Ensure your payment method is current to avoid service interruption.

\n

Subscription Cancellation Process

\n

To cancel a subscription:

\n
    \n
  1. Online: Log into your account and navigate to subscription settings
  2. \n
  3. Email: Contact us at contact@artificerinnovations.com with your subscription details
  4. \n
  5. Confirmation: You will receive email confirmation of your cancellation
  6. \n
  7. Effective Date: Cancellation takes effect at the end of your current billing period
  8. \n
\n

Refund Processing

\n

Timeline

\n
    \n
  • Digital Products: Refunds will be processed within 5-10 business days of approval
  • \n
  • Consulting Services: Refunds will be processed according to the terms specified in your service agreement
  • \n
\n

Method

\n

Refunds will be issued to the original payment method used for the purchase. Processing times may vary depending on your financial institution.

\n

Non-Refundable Items

\n

The following are not eligible for refunds:

\n
    \n
  • Completed Consulting Services: Services that have been fully delivered according to the service agreement
  • \n
  • Downloaded Digital Products: Digital products that have been successfully downloaded or accessed
  • \n
  • Custom Work: Any custom consulting work or deliverables that have been completed
  • \n
  • Third-Party Services: Any third-party services or products recommended or facilitated by us
  • \n
  • Subscription Services: Used portions of subscription periods (except as specified in subscription terms)
  • \n
  • Promotional or Discounted Items: Items purchased with special pricing or promotional codes (unless otherwise specified)
  • \n
\n

Dispute Resolution

\n

If you believe you are entitled to a refund that has been denied, you may:

\n
    \n
  1. Contact Us: Reach out to us at contact@artificerinnovations.com with detailed information about your concern
  2. \n
  3. Provide Documentation: Include relevant documentation such as order confirmations, service agreements, or correspondence
  4. \n
  5. Review Process: We will review your request and respond within 5 business days
  6. \n
\n

Chargebacks and Payment Disputes

\n

We encourage customers to contact us directly to resolve any issues before initiating chargebacks or payment disputes. Chargebacks and disputes may result in additional fees and may affect your ability to purchase our services or products in the future.

\n

Changes to This Policy

\n

We reserve the right to modify this Refund Policy at any time. Changes will be effective immediately upon posting on our website. Your continued use of our services or purchase of our products after changes are posted constitutes acceptance of the updated policy.

\n

Contact Us

\n

If you have any questions about this Refund Policy or need to request a refund, please contact us at contact@artificerinnovations.com.

\n

When contacting us about a refund, please include:

\n
    \n
  • Your order number or service agreement reference
  • \n
  • Date of purchase or service engagement
  • \n
  • Description of the issue
  • \n
  • Any relevant documentation
  • \n
\n
\n

This Refund Policy is effective as of September 7, 2025, and applies to all purchases and service engagements made after this date.

\n', }; export type PolicyKey = 'terms' | 'privacy' | 'refunds'; diff --git a/packages/shared/src/hooks/useAuth.ts b/packages/shared/src/hooks/useAuth.ts index 14e2783f..3b8a6daf 100644 --- a/packages/shared/src/hooks/useAuth.ts +++ b/packages/shared/src/hooks/useAuth.ts @@ -37,18 +37,23 @@ export function useAuth(supabaseClient: SupabaseClient): AuthHookReturn { setLoading(true); setError(null); - const { error } = await supabaseClient.auth.signInWithPassword({ + const { data, error } = await supabaseClient.auth.signInWithPassword({ email, password, }); - setLoading(false); - if (error) { + setLoading(false); const errorObj = new Error(error.message); setError(errorObj); throw errorObj; } + + // Set user/session immediately so callers that navigate() after signIn() + // see a non-null user before onAuthStateChange fires asynchronously. + setSession(data.session ?? null); + setUser(data.user ?? null); + setLoading(false); }; const signUp = async (email: string, password: string): Promise => { diff --git a/packages/shared/src/types/database.ts b/packages/shared/src/types/database.ts index 8b9beca0..f4ae4de4 100644 --- a/packages/shared/src/types/database.ts +++ b/packages/shared/src/types/database.ts @@ -282,6 +282,7 @@ export type Database = { quantity: number metadata: Json created_at: string + idempotency_key: string | null } Insert: { id?: string @@ -291,6 +292,7 @@ export type Database = { quantity?: number metadata?: Json created_at?: string + idempotency_key?: string | null } Update: { id?: string @@ -300,6 +302,7 @@ export type Database = { quantity?: number metadata?: Json created_at?: string + idempotency_key?: string | null } Relationships: [] } @@ -395,6 +398,7 @@ export type Database = { p_event_type: string p_quantity?: number p_metadata?: Json + p_idempotency_key?: string } Returns: undefined } diff --git a/scripts/check-csp-hash.mjs b/scripts/check-csp-hash.mjs new file mode 100644 index 00000000..27d59ff0 --- /dev/null +++ b/scripts/check-csp-hash.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Verifies that the SHA-256 hash of the inline theme script in apps/web/index.html + * matches the value recorded in the active CSP directive in + * infra/aws/pr-preview-stack.yml. + * + * Works for both phases: + * Phase 1 (report-only): hash must appear in the Content-Security-Policy-Report-Only + * value under CustomHeadersConfig. + * Phase 2 (enforcement): hash must appear in the ContentSecurityPolicy value under + * SecurityHeadersConfig. + * + * Run: node scripts/check-csp-hash.mjs + * CI: triggered on changes to index.html or pr-preview-stack.yml + */ +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const htmlPath = resolve(root, 'apps/web/index.html'); +const cfnPath = resolve(root, 'infra/aws/pr-preview-stack.yml'); + +const html = readFileSync(htmlPath, 'utf8'); +const cfn = readFileSync(cfnPath, 'utf8'); + +// Strip YAML line comments before matching so that comment lines between a +// Header: entry and its Value: entry don't break the whitespace bridge in the regex. +const cfnNoComments = cfn.replace(/^\s*#.*$/gm, ''); + +// Extract content between +const scriptMatch = html.match(/]*\bid="csp-inline-theme"[^>]*>([\s\S]*?)<\/script>/); +if (!scriptMatch) { + console.error('ERROR: