Skip to content

Add subscriptions: store tab, account panel, manage/cancel#3918

Draft
evanpelle wants to merge 1 commit into
mainfrom
subs
Draft

Add subscriptions: store tab, account panel, manage/cancel#3918
evanpelle wants to merge 1 commit into
mainfrom
subs

Conversation

@evanpelle
Copy link
Copy Markdown
Collaborator

@evanpelle evanpelle commented May 13, 2026

Summary

  • Add a Subscriptions tab to the Store. Each tier renders as a <cosmetic-button> with description, daily Pu/Caps amounts, and a Stripe checkout button driven by the existing createCheckoutSession flow.
  • Show the player's active subscription in the Account modal via a new <subscription-panel> Lit component (status badge, period-end / cancel-at-period-end, daily currency breakdown).
  • Manage button opens the Stripe billing portal in a new tab (POST /subscriptions/@me/portal).
  • Cancel button (hidden once cancelAtPeriodEnd === true) calls POST /subscriptions/@me/cancel after a confirm() prompt, then invalidates the userMe cache and refetches.
  • Block re-purchase: clicking Subscribe when the user already has a subscription:* flare alerts "Already subscribed" before opening checkout (upgrade/downgrade flows are out of scope for now).
  • Schema additions:
    • CosmeticsSchema.subscriptions: Record<string, SubscriptionSchema> (optional) in src/core/CosmeticSchemas.ts.
    • UserMeResponse.player.subscription: { tier, status, currentPeriodEnd, cancelAtPeriodEnd } | null in src/core/ApiSchemas.ts.
  • Translations: new store.* and account_modal.sub_* keys in resources/lang/en.json (English only — Crowdin handles the rest).

Discord

evanpelle

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Review Change Stack

Walkthrough

Adds subscription schemas and translations, API helpers to open portal/cancel, resolves subscriptions into cosmetics, store UI and CosmeticButton previews, SubscriptionPanel component, AccountModal integration, main flow update, and a test fixture change.

Changes

Subscription Management Feature

Layer / File(s) Summary
Data schemas and translation strings
src/core/ApiSchemas.ts, src/core/CosmeticSchemas.ts, resources/lang/en.json
UserMeResponse includes a nullable player subscription; CosmeticsSchema adds subscriptions; new translations for account modal, store subscription UI, and cosmetics.per_day are added.
Subscription API client functions
src/client/Api.ts
Adds cancelSubscription() (POST /subscriptions/@me/cancel) and openSubscriptionPortal() (POST /subscriptions/@me/portal) with auth and error handling.
Cosmetics resolution and purchase flow for subscriptions
src/client/Cosmetics.ts
ResolvedCosmetic supports subscription; resolveCosmetics emits subscription entries; purchase flow blocks duplicate subscription purchases and rejects currency checkout for subscription items; pricing refactor for non-subscription items.
Subscription UI components: CosmeticButton and SubscriptionPanel
src/client/components/CosmeticButton.ts, src/client/components/SubscriptionPanel.ts
CosmeticButton renders subscription previews and translates subscription names; new subscription-panel component displays subscription tier, status (including cancel-on date), cosmetic details, and manage/cancel actions.
Store modal subscriptions tab and grid
src/client/Store.ts
Adds a subscriptions tab and renderSubscriptionGrid() to show purchasable subscriptions or an empty state; tab wiring and change handler updated.
Account modal subscription management
src/client/AccountModal.ts
Fetches cosmetics when opened, renders SubscriptionPanel with current user subscription, and wires manage/cancel handlers to API functions with user-me invalidation and refresh.
Main flow and tests
src/client/Main.ts, tests/ResolveCosmetics.test.ts
Purchase-completed hash handling adds subscription_tier path that invalidates cached user-me and reloads; test helper includes subscription: null in mocked user response.

Sequence Diagram

sequenceDiagram
  participant AccountModal
  participant Api
  participant SubscriptionPortal
  participant UserMeCache
  AccountModal->>Api: fetchCosmetics()
  AccountModal->>Api: getUserMe()
  AccountModal->>AccountModal: render SubscriptionPanel(sub, cosmetic)
  AccountModal->>Api: openSubscriptionPortal(returnUrl)
  Api->>SubscriptionPortal: POST /subscriptions/@me/portal
  SubscriptionPortal-->>Api: url
  Api-->>AccountModal: url | false
  AccountModal->>UserMeCache: invalidateUserMe()
  AccountModal->>Api: cancelSubscription()
  Api->>SubscriptionPortal: POST /subscriptions/@me/cancel
  SubscriptionPortal-->>Api: response.ok
  Api-->>AccountModal: true | false
  AccountModal->>Api: getUserMe() (refresh)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

✨ New tiers and tiny daily gains,
Buttons that open portal lanes.
Cancel confirms, the status shows,
Cosmetics match the plan one chose.
Little strings and types in tune—subscribe and swoon.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main changes: adding subscription support across Store tab, account panel, and management functions (manage/cancel).
Description check ✅ Passed The description comprehensively covers all major changes including Store Subscriptions tab, Account modal subscription panel, manage/cancel flows, schema additions, and translations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/Api.ts`:
- Around line 172-221: Both cancelSubscription() and openSubscriptionPortal()
need to handle 401/unauthorized like other authenticated calls: after receiving
the fetch response, if response.status === 401 call logOut() (the existing auth
cleanup function) before returning false, and leave other non-OK handling and
console.error logging as-is; also ensure the catch block still returns false on
network errors. Update the checks inside cancelSubscription and
openSubscriptionPortal to explicitly detect 401 and invoke logOut() to clear
stale auth state.

In `@src/client/components/CosmeticButton.ts`:
- Line 111: The hardcoded "/day" label in the CosmeticButton component must be
localized: replace the literal "/day" text in the JSX (seen in CosmeticButton)
with a call to translateText() using a new key (e.g., "pricing.per_day") and add
the corresponding entry to resources/lang/en.json; ensure both occurrences (the
span at line ~111 and the other at ~118) use translateText("pricing.per_day") so
all user-visible text follows the translateText() pattern.

In `@src/client/components/SubscriptionPanel.ts`:
- Line 109: Replace the hard-coded "/day" text in the SubscriptionPanel
component with a call to translateText() and add a corresponding key in
resources/lang/en.json; specifically, in SubscriptionPanel (the <span
class="text-[10px] ...">"/day"</span> occurrences at lines noted), change the
literal to something like translateText('subscription.perDay') and create an
"subscription.perDay": "/day" entry in en.json so all user-visible text uses
translateText().

In `@src/client/Cosmetics.ts`:
- Around line 349-356: The loop that builds subscription entries always sets
relationship to "purchasable"; change the rel computation to mark subscriptions
as "owned" when the user already has that subscription by checking for the key
`subscription:${subKey}` in the user's ownership set (use the module's existing
ownership collection, e.g., owned/ownedCosmetics/userOwned or ownedKeys).
Concretely, replace the rel assignment (currently `const rel = sub.product ?
"purchasable" : "blocked";`) with logic that first forms the key `const key =
\`subscription:${subKey}\`` then if the ownership set contains that key set rel
= "owned", else if sub.product set rel = "purchasable" else "blocked", and use
that rel in the result.push.
- Around line 69-72: The currency-purchase branch uses a type assertion to treat
c as Pattern|Flag|Pack and passes it to purchaseWithCurrency, but there's no
runtime guard to prevent a Subscription (which lacks priceHard/priceSoft) from
being passed; add a runtime type check before the currency branch: when method
is "hard" or "soft", verify c has priceHard/priceSoft (e.g., check "priceHard"
in c or typeof (c as any).priceHard !== "undefined") and if the check fails,
throw or return an error instead of proceeding; replace the blind cast to priced
with this guarded path and only call purchaseWithCurrency(priced, method) after
the guard passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c2ecd4b-2d4f-4a5e-9e2c-c32c301043e4

📥 Commits

Reviewing files that changed from the base of the PR and between 9e39a7f and 45d5e58.

📒 Files selected for processing (10)
  • resources/lang/en.json
  • src/client/AccountModal.ts
  • src/client/Api.ts
  • src/client/Cosmetics.ts
  • src/client/Store.ts
  • src/client/components/CosmeticButton.ts
  • src/client/components/SubscriptionPanel.ts
  • src/core/ApiSchemas.ts
  • src/core/CosmeticSchemas.ts
  • tests/ResolveCosmetics.test.ts

Comment thread src/client/Api.ts
Comment thread src/client/components/CosmeticButton.ts Outdated
Comment thread src/client/components/SubscriptionPanel.ts Outdated
Comment thread src/client/Cosmetics.ts Outdated
Comment thread src/client/Cosmetics.ts Outdated
@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 13, 2026
@evanpelle evanpelle changed the title subs Add subscriptions: store tab, account panel, manage/cancel May 14, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/client/components/SubscriptionPanel.ts (1)

109-109: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move /day to translations.

The literal /day text should use translateText() with a corresponding key in en.json for proper localization.

As per coding guidelines: "src/client/**/*.{ts,tsx}: All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json."

Also applies to: 116-116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/components/SubscriptionPanel.ts` at line 109, Replace the literal
"/day" string in SubscriptionPanel.ts with a call to translateText (e.g.,
translateText('subscription.per_day')) in both places mentioned (the span at the
current snippet and the other occurrence), and add the corresponding
"subscription.per_day" entry to resources/lang/en.json; ensure you import or use
the existing translateText function used across the project and update both
occurrences so all user-visible text goes through translation.
🧹 Nitpick comments (1)
src/client/components/SubscriptionPanel.ts (1)

97-101: 💤 Low value

Consider whether cosmetic.description needs translation.

The description is rendered directly without translateText() or translateCosmetic(). If descriptions are stored pre-translated in the cosmetics schema, this is fine. Otherwise, they should go through the translation system.

Verify whether cosmetic descriptions are already localized in the schema or need runtime translation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/components/SubscriptionPanel.ts` around lines 97 - 101, The
cosmetic.description is rendered raw in SubscriptionPanel (cosmetic.description)
— confirm whether the cosmetics schema stores localized text; if not, pass the
description through the translation helper (e.g., translateText() or
translateCosmetic()) before rendering; update the ternary branch that outputs
${cosmetic.description} to call the appropriate translator so runtime
localization is applied only when needed, and ensure any translator import/usage
matches existing translation utilities in the component.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/client/components/SubscriptionPanel.ts`:
- Line 109: Replace the literal "/day" string in SubscriptionPanel.ts with a
call to translateText (e.g., translateText('subscription.per_day')) in both
places mentioned (the span at the current snippet and the other occurrence), and
add the corresponding "subscription.per_day" entry to resources/lang/en.json;
ensure you import or use the existing translateText function used across the
project and update both occurrences so all user-visible text goes through
translation.

---

Nitpick comments:
In `@src/client/components/SubscriptionPanel.ts`:
- Around line 97-101: The cosmetic.description is rendered raw in
SubscriptionPanel (cosmetic.description) — confirm whether the cosmetics schema
stores localized text; if not, pass the description through the translation
helper (e.g., translateText() or translateCosmetic()) before rendering; update
the ternary branch that outputs ${cosmetic.description} to call the appropriate
translator so runtime localization is applied only when needed, and ensure any
translator import/usage matches existing translation utilities in the component.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 35ecb351-8f08-4bae-aa1a-9ce98b75a79a

📥 Commits

Reviewing files that changed from the base of the PR and between 45d5e58 and f8f7791.

📒 Files selected for processing (11)
  • resources/lang/en.json
  • src/client/AccountModal.ts
  • src/client/Api.ts
  • src/client/Cosmetics.ts
  • src/client/Main.ts
  • src/client/Store.ts
  • src/client/components/CosmeticButton.ts
  • src/client/components/SubscriptionPanel.ts
  • src/core/ApiSchemas.ts
  • src/core/CosmeticSchemas.ts
  • tests/ResolveCosmetics.test.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/core/ApiSchemas.ts
  • src/client/Api.ts
  • src/core/CosmeticSchemas.ts
  • resources/lang/en.json
  • tests/ResolveCosmetics.test.ts
  • src/client/Cosmetics.ts
  • src/client/components/CosmeticButton.ts
  • src/client/AccountModal.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/client/AccountModal.ts (1)

40-40: ⚡ Quick win

Consider using @state() decorator for consistency.

Other reactive properties in this class (email, isLoadingUser) use the @state() decorator. While manual requestUpdate() calls work, using @state() would make updates automatic and keep the pattern consistent.

♻️ Proposed change
+  `@state`() private cosmetics: Cosmetics | null = null;
-  private cosmetics: Cosmetics | null = null;

Then remove the manual requestUpdate() call on line 434 since it's redundant:

   void fetchCosmetics().then((cosmetics) => {
     this.cosmetics = cosmetics;
-    this.requestUpdate();
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AccountModal.ts` at line 40, Change the manual reactive field
declaration "private cosmetics: Cosmetics | null = null;" in the AccountModal
class to use the `@state`() decorator (consistent with other reactive properties
like email and isLoadingUser) so updates are automatic; then remove the
redundant manual requestUpdate() call that updates cosmetics (the
requestUpdate() call referenced in the review) since `@state`() will trigger
re-rendering for the cosmetics property.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/AccountModal.ts`:
- Around line 432-435: The promise from fetchCosmetics() is currently unhandled;
wrap the call so rejections are caught and handled: call fetchCosmetics() and in
the .then set this.cosmetics and requestUpdate(), and add a .catch handler that
logs the error (or calls this.error reporting), sets this.cosmetics to an
appropriate fallback (e.g., empty array or error state), and calls
this.requestUpdate() so the UI renders a non-blocking subscription panel;
reference the existing fetchCosmetics invocation and the this.cosmetics /
this.requestUpdate usage when making the change.
- Around line 228-245: handleCancelSubscription shows a success alert but
doesn’t handle errors from the subsequent getUserMe() call, so the UI may not
reflect the cancelled subscription; update handleCancelSubscription to wrap the
getUserMe() call in try/catch, call invalidateUserMe() before/after fetching,
set this.userMeResponse only on successful fetch, and on failure log the error
and ensure the UI is updated (this.requestUpdate()) so the subscription panel
reflects the cancelled state even if user fetch fails; reference
functions/methods: handleCancelSubscription, cancelSubscription, getUserMe,
invalidateUserMe, this.userMeResponse, this.requestUpdate.

---

Nitpick comments:
In `@src/client/AccountModal.ts`:
- Line 40: Change the manual reactive field declaration "private cosmetics:
Cosmetics | null = null;" in the AccountModal class to use the `@state`()
decorator (consistent with other reactive properties like email and
isLoadingUser) so updates are automatic; then remove the redundant manual
requestUpdate() call that updates cosmetics (the requestUpdate() call referenced
in the review) since `@state`() will trigger re-rendering for the cosmetics
property.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bf7a4ef-b616-4f49-b523-38b87d2a4ffe

📥 Commits

Reviewing files that changed from the base of the PR and between f8f7791 and 8b10e26.

📒 Files selected for processing (11)
  • resources/lang/en.json
  • src/client/AccountModal.ts
  • src/client/Api.ts
  • src/client/Cosmetics.ts
  • src/client/Main.ts
  • src/client/Store.ts
  • src/client/components/CosmeticButton.ts
  • src/client/components/SubscriptionPanel.ts
  • src/core/ApiSchemas.ts
  • src/core/CosmeticSchemas.ts
  • tests/ResolveCosmetics.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • tests/ResolveCosmetics.test.ts
  • src/core/CosmeticSchemas.ts
  • src/client/Api.ts
  • src/client/components/CosmeticButton.ts
  • src/client/Cosmetics.ts
  • src/client/Main.ts
  • src/client/Store.ts
  • src/core/ApiSchemas.ts
  • src/client/components/SubscriptionPanel.ts

Comment on lines +228 to +245
private handleCancelSubscription = async (): Promise<void> => {
const confirmed = window.confirm(
translateText("account_modal.cancel_subscription_confirm"),
);
if (!confirmed) return;
const ok = await cancelSubscription();
if (!ok) {
alert(translateText("account_modal.cancel_subscription_failed"));
return;
}
alert(translateText("account_modal.cancel_subscription_success"));
invalidateUserMe();
const userMe = await getUserMe();
if (userMe) {
this.userMeResponse = userMe;
}
this.requestUpdate();
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling for getUserMe() after successful cancellation.

If cancelSubscription() succeeds but the subsequent getUserMe() call fails (line 240), the UI won't update to reflect the cancellation. The user will see the success alert but the subscription panel will continue to display.

🛡️ Proposed fix
  alert(translateText("account_modal.cancel_subscription_success"));
  invalidateUserMe();
- const userMe = await getUserMe();
- if (userMe) {
-   this.userMeResponse = userMe;
- }
- this.requestUpdate();
+ try {
+   const userMe = await getUserMe();
+   if (userMe) {
+     this.userMeResponse = userMe;
+   }
+   this.requestUpdate();
+ } catch (err) {
+   console.warn("Failed to refresh user after cancel:", err);
+   this.requestUpdate();
+ }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AccountModal.ts` around lines 228 - 245, handleCancelSubscription
shows a success alert but doesn’t handle errors from the subsequent getUserMe()
call, so the UI may not reflect the cancelled subscription; update
handleCancelSubscription to wrap the getUserMe() call in try/catch, call
invalidateUserMe() before/after fetching, set this.userMeResponse only on
successful fetch, and on failure log the error and ensure the UI is updated
(this.requestUpdate()) so the subscription panel reflects the cancelled state
even if user fetch fails; reference functions/methods: handleCancelSubscription,
cancelSubscription, getUserMe, invalidateUserMe, this.userMeResponse,
this.requestUpdate.

Comment on lines +432 to +435
void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling for fetchCosmetics().

If fetchCosmetics() rejects, the promise is unhandled and this.cosmetics will remain null indefinitely. Users with subscriptions would see an incomplete subscription panel.

🛡️ Proposed fix
- void fetchCosmetics().then((cosmetics) => {
-   this.cosmetics = cosmetics;
-   this.requestUpdate();
- });
+ void fetchCosmetics()
+   .then((cosmetics) => {
+     this.cosmetics = cosmetics;
+     this.requestUpdate();
+   })
+   .catch((err) => {
+     console.warn("Failed to fetch cosmetics in AccountModal:", err);
+     this.requestUpdate();
+   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
});
void fetchCosmetics()
.then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
})
.catch((err) => {
console.warn("Failed to fetch cosmetics in AccountModal:", err);
this.requestUpdate();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AccountModal.ts` around lines 432 - 435, The promise from
fetchCosmetics() is currently unhandled; wrap the call so rejections are caught
and handled: call fetchCosmetics() and in the .then set this.cosmetics and
requestUpdate(), and add a .catch handler that logs the error (or calls
this.error reporting), sets this.cosmetics to an appropriate fallback (e.g.,
empty array or error state), and calls this.requestUpdate() so the UI renders a
non-blocking subscription panel; reference the existing fetchCosmetics
invocation and the this.cosmetics / this.requestUpdate usage when making the
change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant