Skip to content

feat: add hard paywall in hosted Hopp#349

Open
konsalex wants to merge 4 commits into
mainfrom
hard-paywall
Open

feat: add hard paywall in hosted Hopp#349
konsalex wants to merge 4 commits into
mainfrom
hard-paywall

Conversation

@konsalex

@konsalex konsalex commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

This PR introduces hard paywall for hosted service of Hopp.

Also some other things:

  1. Cleanup onboarding modal, now only admins will have an onboarding form directly when they sign-up
  2. Removed any Rewardful reference in code as its not used
  3. (Super important) Fix broken image of Michael Scott (prison Mike)
  4. Re-touched some automated emails (Subscription trial reminder PR is here https://github.com/gethopp/operations/pull/6)
  5. Changed Subscription Success modal, for the case of re-subscription vs new trial.

Summary by CodeRabbit

Summary

  • New Features

    • Added a two-step onboarding flow for teams that must complete setup before proceeding.
    • Added an endpoint and UI support for team admins to rename their team.
    • Introduced payment-method requirement signaling to improve onboarding/paywall gating.
    • Added a dedicated “trial started” email and updated subscription success modal messaging.
  • Bug Fixes

    • Improved subscription/paywall behavior for new vs legacy teams, including hard paywall cutoff handling and status mapping.
  • Changes

    • Removed referral/rewardful checkout support and related startup integration.

@netlify

netlify Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deploy Preview for hoppdocs ready!

Name Link
🔨 Latest commit 84f53ed
🔍 Latest deploy log https://app.netlify.com/projects/hoppdocs/deploys/6a4c0986a936b50008fd2bed
😎 Deploy Preview https://deploy-preview-349--hoppdocs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a hard paywall cutoff for new teams, Stripe trial onboarding and gating, a team rename endpoint, trial-specific billing/email handling, onboarding UI flow, and removes Rewardful tracking from checkout and app startup.

Changes

Hard Paywall, Trial Onboarding, and Rewardful Removal

Layer / File(s) Summary
API contracts and generated types
backend/api-files/openapi.yaml, tauri/src/openapi.d.ts, web-app/src/openapi.d.ts
Adds the Team schema, extends SubscriptionResponse with requires_payment_method, changes onboarding-form and checkout-session request bodies, and adds PATCH /api/auth/team to the OpenAPI and generated TypeScript contracts.
Hard paywall cutoff model
backend/internal/models/user.go
Adds the hard paywall cutoff date and IsTeamPostCutoff helper, and short-circuits GetUserWithSubscription for post-cutoff teams.
Stripe trial period config
backend/internal/config/config.go
Adds TrialPeriodDays to Stripe config and loads STRIPE_TRIAL_PERIOD_DAYS with a default of 14 and validation.
Billing status, checkout, and webhook flow
backend/internal/handlers/billing.go, backend/internal/handlers/billing_test.go
Adds RequiresPaymentMethod, removes referral from checkout, configures first-subscription trials, refactors subscription webhook handling, maps Stripe statuses, and branches checkout completion email sending on trial status.
Manual signup and team rename
backend/internal/handlers/handlers.go, backend/internal/server/server.go
ManualSignUp now creates a placeholder team for non-invited users, and UpdateTeam validates admin access before renaming the current team.
Trial subscription email
backend/internal/email/email.go, backend/web/emails/hopp-subscription-trial.html, backend/web/emails/hopp-subscription.html, backend/web/emails/hopp-unsubscribe.html, backend/web/emails/hopp-welcome.html
Adds SendSubscriptionTrialEmail, updates the confirmation subject, adds the trial email template, and updates email links and welcome copy.
Paywall integration coverage
backend/test/integration/paywall_test.go
Adds integration tests for pre-cutoff trials, post-cutoff access, invited user inheritance, room-join blocking, and the billing response flag.
Remove Rewardful affiliate tracking
web-app/src/lib/rewardful.js, web-app/src/main.tsx, web-app/src/pages/Subscription.tsx, tauri/src/components/sidebar/Sidebar.tsx
Removes the Rewardful helper module and startup initialization, strips referral data from the subscription upgrade path, and updates related click handlers.
Onboarding gate and page flow
web-app/src/App.tsx, web-app/src/pages/Onboarding.tsx, web-app/src/pages/Login.tsx, web-app/src/components/onboarding-fields.tsx
Adds the onboarding gate in the app shell, the new onboarding page, login/signup changes for invite-only signup input, and onboarding field controls with local draft persistence and checkout flow.
Subscription and success modal UI
web-app/src/components/SubscriptionSuccessModal.tsx, web-app/src/pages/Subscription.tsx
Updates subscription trial/status messaging and the success modal copy.
Dashboard auth dialog swap
web-app/src/pages/Dashboard.tsx
Replaces SignInSuccessModal with AuthenticationDialog.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • gethopp/hopp#253: Both PRs modify billing webhook dispatch and email-related subscription handling in backend/internal/handlers/billing.go.
  • gethopp/hopp#304: Both PRs change backend/internal/models/user.go subscription/trial cutoff logic.
  • gethopp/hopp#343: Both PRs adjust GetUserWithSubscription trial behavior before and after a date-based cutoff.

Suggested reviewers: iparaskev

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.69% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing a hard paywall for hosted Hopp.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hard-paywall

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 16

🤖 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 `@backend/internal/handlers/billing.go`:
- Around line 336-345: The subscription response for post-cutoff teams with no
subscription is still being serialized through the earlier subscription==nil
fallback, which incorrectly reports status=trialing even when
RequiresPaymentMethod is set. Update the response-building logic in the billing
handler so hard-paywall teams with no subscription are returned as a
blocked/no-subscription state instead of trialing, and make sure the branch that
sets subscriptionResponse.RequiresPaymentMethod and the surrounding subscription
serialization stay consistent with backend/internal/models/user.go’s
IsPro/IsTrial behavior.
- Around line 512-517: The cancellation flow in billing handling leaves the
pending-cancel flag set after a subscription is fully deleted. Update the
subscription teardown path in the billing handler logic that sets dbSub.Status
and dbSub.CanceledAt to also clear dbSub.CancelAtPeriodEnd when the final
deletion is processed, so GET /billing/subscription no longer reports a
scheduled cancellation for an already canceled subscription.
- Around line 528-545: The fallback in mapStripeSubscriptionStatus currently
maps unrecognized Stripe subscription states to models.StatusActive, which can
incorrectly keep access enabled. Update mapStripeSubscriptionStatus in
billing.go to fail closed by mapping unknown or paused statuses to
models.StatusCanceled or a dedicated unknown state, and ensure any entitlement
checks via models.Subscription.IsActive() no longer treat unrecognized upstream
states as active.

In `@backend/internal/handlers/handlers.go`:
- Around line 369-380: Wrap the placeholder team creation and the subsequent
user creation in the same transaction inside the signup flow handled by
h.DB.Create and the surrounding Team/User setup logic. Start a transaction
before creating the Team, reuse that transaction for both the Team insert and
the later h.DB.Create(u) call, and roll back on any failure so a user-create
error cannot leave an orphan Team behind. If the transaction succeeds, commit
once both inserts are successful; keep the existing error handling/messages but
make sure they are returned from the transactional path.
- Around line 688-691: The unauthorized branch in handlers.go for the
/api/auth/team flow currently returns plain text from
getAuthenticatedUserFromJWT, but the documented contract expects a JSON Error
shape. Update the 401 path in the relevant handler to return the same
application/json error payload used elsewhere in the API, keeping the response
consistent with the generated clients and OpenAPI definition.

In `@backend/test/integration/paywall_test.go`:
- Around line 82-89: The post-cutoff paywall fixtures are relying on
createTestTeam’s default created_at instead of stamping a known post-cutoff
time. Update newAdminWithTeam to explicitly set the team’s created_at to a
post-cutoff value, mirroring the existing preCutoffDate handling, so
TestHardPaywall_PostCutoffAccessByStatus,
TestHardPaywall_InvitedUserInheritsAccess, TestHardPaywall_RoomJoinBlocked, and
TestHardPaywall_RequiresPaymentMethod always exercise the intended path.

In `@backend/web/emails/hopp-subscription-trial.html`:
- Around line 111-113: The GitHub CTA link in the email template is missing its
URL scheme, so mail clients may not recognize it as a valid absolute link.
Update the href in the subscription trial HTML template to use an absolute
https:// GitHub URL, and make sure the anchor inside the CTA block points to the
correct GitHub repository target.
- Around line 24-25: The subscription trial email template still hardcodes
“14-day” in the preview, heading, and body even though the Stripe trial length
is now configurable. Update the relevant text in hopp-subscription-trial.html to
use the configured trial period from config.go instead of a fixed value, and
make sure the preview/heading/body sections stay consistent with the runtime
Stripe trial settings.

In `@web-app/src/App.tsx`:
- Around line 58-72: The subscription gate in App.tsx can fail open when
useQuery("/api/auth/billing/subscription") errors because subscription becomes
undefined and mustOnboard stays false, allowing dashboard content to render.
Update the App component’s query handling to include the error state (from
useQuery) and keep blocking navigation/content until onboarding eligibility is
known, either by rendering an error/retry view or treating the failed fetch as a
blocking state. Use the existing mustOnboard, isLoading, and navigate logic to
ensure the gate only opens when the subscription result is successfully
resolved.
- Around line 52-63: The checkout-success bypass in App.tsx is currently tied
directly to the URL param, so `justSubscribed` can keep suppressing
`mustOnboard` indefinitely. Update the `justSubscribed` logic around `useQuery`
and `mustOnboard` to make the bypass one-time only: either clear
`subscription_success` from the URL after the first render or use a ref/state
flag that is consumed once and then re-evaluates the fresh subscription data.
Keep the fix localized to `App` so the onboarding gate resumes normally after
the initial post-checkout render.
- Line 12: The App.tsx import still uses a relative path for a src module, so
update the import of QueryProvider and useAPI from useQueryClients to use the @
alias instead of ./hooks/useQueryClients. Keep the change limited to the App
component’s import statement and align it with the existing Vite alias
convention used across the web-app src code.

In `@web-app/src/components/SubscriptionSuccessModal.tsx`:
- Line 30: The success message in SubscriptionSuccessModal is hard-coded to “14
days” instead of using the configured trial duration. Update the text in
SubscriptionSuccessModal to pull the same backend/config-derived trial length
used by the onboarding payment step, and reuse that shared value wherever the
trial duration is displayed so the copy stays consistent with the configured
Stripe trial period.

In `@web-app/src/pages/Onboarding.tsx`:
- Line 18: The onboarding draft storage in Onboarding.tsx is using a single
origin-wide key, which can leak one account’s draft into another on the same
browser. Update the ONBOARDING_DRAFT_KEY usage so the draft is scoped by a
non-sensitive account/team identifier in the onboarding flow, or clear the
stored draft when auth/account context changes; make sure the save/load logic
around the onboarding draft state uses the same scoped key everywhere.
- Around line 29-34: The loadDraft() helper in Onboarding.tsx currently trusts
parsed localStorage data too much, which can let malformed values reach form
state. Update loadDraft() to normalize the returned draft before using it,
especially ensuring pairingTool is always an array (and other fields match
expected types) even when localStorage contains stale or invalid JSON. Keep the
fix localized to the loadDraft function so downstream components do not receive
unsafe form values.
- Line 16: The onboarding billing copy is hard-coded to “14 days” instead of
using the configurable Stripe trial period. Update Onboarding and the related
copy around the referenced trial messaging to read the duration from
backend/config or the shared generated contract, using the existing TRIAL_DAYS
constant only if it is sourced from that shared value, so the UI always matches
the actual billing behavior.
- Around line 130-132: The checkout flow in Onboarding.tsx leaves
checkoutLoading stuck true when the API response has no checkout_url, blocking
retries. Update the handler around the checkout_url redirect so the loading
state is always cleared on the non-redirect path as well, and only keep it
active while a valid redirect is happening. Use the existing Onboarding
component flow and the response.checkout_url check to locate the fix.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b60c279a-fb4b-4166-9571-0212114ab97b

📥 Commits

Reviewing files that changed from the base of the PR and between b10806e and e2808c5.

📒 Files selected for processing (21)
  • backend/api-files/openapi.yaml
  • backend/internal/config/config.go
  • backend/internal/email/email.go
  • backend/internal/handlers/billing.go
  • backend/internal/handlers/billing_test.go
  • backend/internal/handlers/handlers.go
  • backend/internal/models/user.go
  • backend/internal/server/server.go
  • backend/sql/mock_data.sql
  • backend/test/integration/paywall_test.go
  • backend/web/emails/hopp-subscription-trial.html
  • tauri/src/openapi.d.ts
  • web-app/src/App.tsx
  • web-app/src/components/OnboardingModal.tsx
  • web-app/src/components/SubscriptionSuccessModal.tsx
  • web-app/src/lib/rewardful.js
  • web-app/src/main.tsx
  • web-app/src/openapi.d.ts
  • web-app/src/pages/Login.tsx
  • web-app/src/pages/Onboarding.tsx
  • web-app/src/pages/Subscription.tsx
💤 Files with no reviewable changes (2)
  • web-app/src/lib/rewardful.js
  • web-app/src/main.tsx

Comment thread backend/internal/handlers/billing.go
Comment on lines +512 to +517
dbSub.Status = models.StatusCanceled
canceledAt := time.Now()
if subscription.CanceledAt != 0 {
canceledAt = time.Unix(subscription.CanceledAt, 0)
}
dbSub.CanceledAt = &canceledAt

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clear CancelAtPeriodEnd when the subscription is fully deleted.

This path marks the row canceled but leaves the pending-cancel flag untouched. For subscriptions that were already scheduled to end, GET /billing/subscription can keep returning cancel_at_period_end=true after the deletion is final.

Proposed fix
 	dbSub.Status = models.StatusCanceled
+	dbSub.CancelAtPeriodEnd = false
 	canceledAt := time.Now()
 	if subscription.CanceledAt != 0 {
 		canceledAt = time.Unix(subscription.CanceledAt, 0)
 	}
📝 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
dbSub.Status = models.StatusCanceled
canceledAt := time.Now()
if subscription.CanceledAt != 0 {
canceledAt = time.Unix(subscription.CanceledAt, 0)
}
dbSub.CanceledAt = &canceledAt
dbSub.Status = models.StatusCanceled
dbSub.CancelAtPeriodEnd = false
canceledAt := time.Now()
if subscription.CanceledAt != 0 {
canceledAt = time.Unix(subscription.CanceledAt, 0)
}
dbSub.CanceledAt = &canceledAt
🤖 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 `@backend/internal/handlers/billing.go` around lines 512 - 517, The
cancellation flow in billing handling leaves the pending-cancel flag set after a
subscription is fully deleted. Update the subscription teardown path in the
billing handler logic that sets dbSub.Status and dbSub.CanceledAt to also clear
dbSub.CancelAtPeriodEnd when the final deletion is processed, so GET
/billing/subscription no longer reports a scheduled cancellation for an already
canceled subscription.

Comment on lines +528 to +545
// mapStripeSubscriptionStatus converts a Stripe subscription status into our
// internal SubscriptionStatus. Unknown statuses default to active so access is
// not accidentally revoked.
func mapStripeSubscriptionStatus(status stripe.SubscriptionStatus) models.SubscriptionStatus {
switch status {
case stripe.SubscriptionStatusTrialing:
return models.StatusTrialing
case stripe.SubscriptionStatusActive:
return models.StatusActive
case stripe.SubscriptionStatusPastDue:
return models.StatusPastDue
case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusUnpaid:
return models.StatusCanceled
case stripe.SubscriptionStatusIncomplete, stripe.SubscriptionStatusIncompleteExpired:
return models.StatusIncomplete
default:
return models.StatusActive
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Stripe's subscriptions API, what does the pausedstatus mean, and shouldpaused or unknown subscription statuses be treated as active for access control?

💡 Result:

In Stripe’s Subscription API, the paused status indicates that a subscription has been explicitly paused, which stops both service delivery and billing [1][2]. When a subscription enters the paused status, it stops generating invoices and does not advance to new billing periods [3][1]. It is important to distinguish the paused status from the pause_collection feature [3][1]: - paused status: The subscription is effectively inactive; Stripe stops billing and service is typically revoked [1][2]. - pause_collection: The subscription status remains active, but payment collection is frozen [4][3][1]. Because the status remains active during pause_collection, your application logic must independently check the pause_collection field to determine if billing or service access should be restricted [5][6]. Regarding access control, you should generally not treat paused subscriptions as active [1][2]. For subscriptions that are active but have pause_collection enabled, access control should be determined by your specific business policy (e.g., read-only access, full access, or limited access) rather than relying solely on the subscription status [5][6]. For any unknown or unhandled subscription statuses, the safest approach for access control is to default to the most restrictive state (e.g., deny access) to prevent unintended service usage until the status is explicitly accounted for in your application logic. Always rely on the authoritative status field provided by the Stripe API and supplement it with checks for pause_collection or other metadata as required by your product's billing policy [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant handler and nearby access-control logic.
git ls-files backend/internal/handlers/billing.go backend/internal/models backend/internal | sed -n '1,120p'
echo '--- billing.go around mapStripeSubscriptionStatus ---'
sed -n '480,600p' backend/internal/handlers/billing.go

echo '--- search for SubscriptionStatus usage ---'
rg -n "StatusActive|StatusCanceled|StatusTrialing|StatusPastDue|StatusIncomplete|mapStripeSubscriptionStatus|SubscriptionStatus" backend/internal -g '!**/*_test.go'

echo '--- search for Stripe status fields and any paused handling ---'
rg -n "paused|pause_collection|stripe.SubscriptionStatus" backend -g '!**/*_test.go'

Repository: gethopp/hopp

Length of output: 9299


Fail closed for unrecognized Stripe subscription states Map unknown or paused Stripe statuses to canceled (or a dedicated unknown state) instead of active; models.Subscription.IsActive() treats active/trialing as entitled, so the current default can grant Pro access on non-billable upstream states.

🤖 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 `@backend/internal/handlers/billing.go` around lines 528 - 545, The fallback in
mapStripeSubscriptionStatus currently maps unrecognized Stripe subscription
states to models.StatusActive, which can incorrectly keep access enabled. Update
mapStripeSubscriptionStatus in billing.go to fail closed by mapping unknown or
paused statuses to models.StatusCanceled or a dedicated unknown state, and
ensure any entitlement checks via models.Subscription.IsActive() no longer treat
unrecognized upstream states as active.

Comment thread backend/internal/handlers/handlers.go Outdated
Comment on lines +688 to +691
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
if !isAuthenticated {
return c.String(http.StatusUnauthorized, "Unauthorized")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Return the documented JSON error shape on 401.

Lines 689-690 return plain text, but /api/auth/team is documented in backend/api-files/openapi.yaml and both generated clients as application/json Error for 401. That breaks the new typed contract on expired/missing-token paths.

Proposed fix
 	user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
 	if !isAuthenticated {
-		return c.String(http.StatusUnauthorized, "Unauthorized")
+		return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
 	}
📝 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
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
if !isAuthenticated {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
user, isAuthenticated := h.getAuthenticatedUserFromJWT(c)
if !isAuthenticated {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
🤖 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 `@backend/internal/handlers/handlers.go` around lines 688 - 691, The
unauthorized branch in handlers.go for the /api/auth/team flow currently returns
plain text from getAuthenticatedUserFromJWT, but the documented contract expects
a JSON Error shape. Update the 401 path in the relevant handler to return the
same application/json error payload used elsewhere in the API, keeping the
response consistent with the generated clients and OpenAPI definition.

const title = isTrial ? "🚢 Welcome onboard to Hopp" : "🎉 Welcome back to Hopp";
const subtitle =
isTrial ?
"Your trial to Hopp just started. Enjoy all the premium features for 14 days."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use the configured trial duration here too.

This hard-codes “14 days” even though the Stripe trial period is configurable in this PR stack. Reuse the same backend/config-derived value as the onboarding payment step.

🤖 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 `@web-app/src/components/SubscriptionSuccessModal.tsx` at line 30, The success
message in SubscriptionSuccessModal is hard-coded to “14 days” instead of using
the configured trial duration. Update the text in SubscriptionSuccessModal to
pull the same backend/config-derived trial length used by the onboarding payment
step, and reuse that shared value wherever the trial duration is displayed so
the copy stays consistent with the configured Stripe trial period.


import { CompanySizeSelect, PairingToolMultiSelect, ReferralSourceSelect } from "@/components/OnboardingModal";

const TRIAL_DAYS = 14;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don’t hard-code the trial duration in billing copy.

The PR stack makes the Stripe trial period configurable, but this page always tells users “14 days.” Pull this value from backend/config or a shared generated contract so UI copy matches actual billing behavior.

Also applies to: 220-230

🤖 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 `@web-app/src/pages/Onboarding.tsx` at line 16, The onboarding billing copy is
hard-coded to “14 days” instead of using the configurable Stripe trial period.
Update Onboarding and the related copy around the referenced trial messaging to
read the duration from backend/config or the shared generated contract, using
the existing TRIAL_DAYS constant only if it is sourced from that shared value,
so the UI always matches the actual billing behavior.

Comment thread web-app/src/pages/Onboarding.tsx Outdated
Comment thread web-app/src/pages/Onboarding.tsx Outdated
Comment on lines +130 to +132
if (response?.checkout_url) {
window.location.href = response.checkout_url;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle a missing checkout URL before leaving the button disabled.

If the API returns without checkout_url, checkoutLoading stays true and the user cannot retry.

Proposed fix
-      if (response?.checkout_url) {
-        window.location.href = response.checkout_url;
-      }
+      if (!response?.checkout_url) {
+        toast.error("Failed to start your trial. Please try again.");
+        setCheckoutLoading(false);
+        return;
+      }
+
+      window.location.href = response.checkout_url;
📝 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
if (response?.checkout_url) {
window.location.href = response.checkout_url;
}
if (!response?.checkout_url) {
toast.error("Failed to start your trial. Please try again.");
setCheckoutLoading(false);
return;
}
window.location.href = response.checkout_url;
🤖 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 `@web-app/src/pages/Onboarding.tsx` around lines 130 - 132, The checkout flow
in Onboarding.tsx leaves checkoutLoading stuck true when the API response has no
checkout_url, blocking retries. Update the handler around the checkout_url
redirect so the loading state is always cleared on the non-redirect path as
well, and only keep it active while a valid redirect is happening. Use the
existing Onboarding component flow and the response.checkout_url check to locate
the fix.

@iparaskev iparaskev self-requested a review June 30, 2026 18:37
@konsalex konsalex changed the title feat: still wip for hard paywall feat: add hard paywall in hosted Hopp Jul 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
web-app/src/pages/Onboarding.tsx (1)

127-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

subscription variable shadows the outer billing subscription.

The form.store.subscribe(...) return value is named subscription, which shadows the outer const subscription = subscriptionData?.subscription; (Line 58) used throughout the component. This works today only because the effect body doesn't reference the outer variable, but it's a landmine for future edits — anyone extending this effect to reference the Stripe subscription would silently get the wrong value.

♻️ Suggested rename
   useEffect(() => {
     if (!userId) return;
-    const subscription = form.store.subscribe(() => {
+    const unsubscribe = form.store.subscribe(() => {
       localStorage.setItem(draftKey(userId), JSON.stringify(form.store.state.values));
     });
-    return () => subscription.unsubscribe();
+    return () => unsubscribe.unsubscribe();
   }, [form, userId]);
🤖 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 `@web-app/src/pages/Onboarding.tsx` around lines 127 - 133, The local
`subscription` declared inside `Onboarding`’s `useEffect` shadows the outer
Stripe subscription variable from `subscriptionData?.subscription`, which can
cause accidental misuse later. Rename the `form.store.subscribe(...)` result to
a distinct name in `Onboarding` and update the cleanup to use that new
identifier so the effect stays clear and non-conflicting.
🤖 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 `@web-app/src/components/onboarding-fields.tsx`:
- Around line 71-88: The remove control inside the selected tool Badge is
mouse-only because Button with asChild renders a span instead of a focusable
control. Update the remove action in onClick/removeSelection to use a real
button element, or add the required accessibility props and keyboard handlers so
it can receive focus and activate with Enter/Space. Keep the fix localized to
the Badge/Button/XIcon remove control in onboarding-fields.tsx.

---

Nitpick comments:
In `@web-app/src/pages/Onboarding.tsx`:
- Around line 127-133: The local `subscription` declared inside `Onboarding`’s
`useEffect` shadows the outer Stripe subscription variable from
`subscriptionData?.subscription`, which can cause accidental misuse later.
Rename the `form.store.subscribe(...)` result to a distinct name in `Onboarding`
and update the cleanup to use that new identifier so the effect stays clear and
non-conflicting.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2158a15a-5985-4fcb-82bc-c182eced3d4c

📥 Commits

Reviewing files that changed from the base of the PR and between e2808c5 and c89dc36.

📒 Files selected for processing (12)
  • backend/internal/handlers/billing.go
  • backend/internal/handlers/handlers.go
  • backend/test/integration/paywall_test.go
  • backend/web/emails/hopp-subscription-trial.html
  • backend/web/emails/hopp-subscription.html
  • backend/web/emails/hopp-unsubscribe.html
  • backend/web/emails/hopp-welcome.html
  • web-app/src/components/OnboardingModal.tsx
  • web-app/src/components/SignInSuccessModal.tsx
  • web-app/src/components/onboarding-fields.tsx
  • web-app/src/pages/Dashboard.tsx
  • web-app/src/pages/Onboarding.tsx
💤 Files with no reviewable changes (3)
  • web-app/src/components/SignInSuccessModal.tsx
  • web-app/src/pages/Dashboard.tsx
  • web-app/src/components/OnboardingModal.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • backend/web/emails/hopp-subscription-trial.html
  • backend/internal/handlers/handlers.go
  • backend/test/integration/paywall_test.go
  • backend/internal/handlers/billing.go

Comment on lines +71 to +88
<Badge key={val} variant="outline">
{tool.label}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4"
onClick={(e) => {
e.stopPropagation();
removeSelection(val);
}}
asChild
>
<span>
<XIcon className="size-3" />
</span>
</Button>
</Badge>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the Button component's asChild/Slot implementation for focus/keyboard handling
fd -a button.tsx web-app/src/components/ui | xargs cat -n

Repository: gethopp/hopp

Length of output: 5484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== web-app/src/components/ui/button.tsx =="
cat -n web-app/src/components/ui/button.tsx

echo
echo "== web-app/src/components/onboarding-fields.tsx (relevant range) =="
sed -n '60,100p' web-app/src/components/onboarding-fields.tsx | cat -n

Repository: gethopp/hopp

Length of output: 5110


Remove control is mouse-only. Button with asChild renders the inner <span> directly, so this icon never gets focus or Enter/Space activation. Use a real <button> here, or add role, tabIndex, and keyboard handling, so selected tools can be removed from the keyboard too.

🤖 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 `@web-app/src/components/onboarding-fields.tsx` around lines 71 - 88, The
remove control inside the selected tool Badge is mouse-only because Button with
asChild renders a span instead of a focusable control. Update the remove action
in onClick/removeSelection to use a real button element, or add the required
accessibility props and keyboard handlers so it can receive focus and activate
with Enter/Space. Keep the fix localized to the Badge/Button/XIcon remove
control in onboarding-fields.tsx.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tauri/src/components/sidebar/Sidebar.tsx (2)

122-129: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Floating promises now unhandled — restore void or add .catch().

installAndRelaunch() and downloadAndRelaunch() both return promises (per tauri/src/update.ts) and are called without await, void, or .catch(). Any rejection (failed install/download/relaunch) becomes an unhandled promise rejection instead of being intentionally discarded.

♻️ Restore explicit handling
             if (OS === "macos" && hasPendingUpdate()) {
-              installAndRelaunch();
+              installAndRelaunch().catch((err) => console.error("Failed to install and relaunch:", err));
               return;
             }
-            downloadAndRelaunch();
+            downloadAndRelaunch().catch((err) => console.error("Failed to download and relaunch:", err));
🤖 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 `@tauri/src/components/sidebar/Sidebar.tsx` around lines 122 - 129, The sidebar
update click handler is dropping returned promises from `installAndRelaunch()`
and `downloadAndRelaunch()`, which can lead to unhandled rejections. Update the
`onClick` callback in `Sidebar` to explicitly handle these async calls by
prefixing them with `void` or by chaining a `.catch()` handler, while keeping
the existing `setUpdateInProgress` and `hasPendingUpdate()` flow intact. Use the
`installAndRelaunch` and `downloadAndRelaunch` calls as the fix points.

182-196: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Same floating-promise concern for openUrl(...).

openUrl returns Promise<void> (per @tauri-apps/plugin-opener), and the call at line 187 no longer discards or handles the promise. This is inside a throttled click handler, so a rejection would surface as an unhandled rejection on every click.

♻️ Restore explicit handling
           if (user.is_admin) {
-            openUrl(new URL("/subscription", Constants.webAppUrl).toString());
+            openUrl(new URL("/subscription", Constants.webAppUrl).toString()).catch((err) =>
+              console.error("Failed to open subscription URL:", err),
+            );
           } else {
🤖 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 `@tauri/src/components/sidebar/Sidebar.tsx` around lines 182 - 196, The
throttled click handler in Sidebar’s handleClick is triggering a
floating-promise issue because openUrl returns a Promise<void> and its result is
not handled. Update the user.is_admin branch to explicitly discard or handle the
promise from openUrl, keeping the behavior inside the throttle wrapper unchanged
and ensuring any rejection is not left unhandled.
🤖 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.

Nitpick comments:
In `@tauri/src/components/sidebar/Sidebar.tsx`:
- Around line 122-129: The sidebar update click handler is dropping returned
promises from `installAndRelaunch()` and `downloadAndRelaunch()`, which can lead
to unhandled rejections. Update the `onClick` callback in `Sidebar` to
explicitly handle these async calls by prefixing them with `void` or by chaining
a `.catch()` handler, while keeping the existing `setUpdateInProgress` and
`hasPendingUpdate()` flow intact. Use the `installAndRelaunch` and
`downloadAndRelaunch` calls as the fix points.
- Around line 182-196: The throttled click handler in Sidebar’s handleClick is
triggering a floating-promise issue because openUrl returns a Promise<void> and
its result is not handled. Update the user.is_admin branch to explicitly discard
or handle the promise from openUrl, keeping the behavior inside the throttle
wrapper unchanged and ensuring any rejection is not left unhandled.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a26afc4a-950c-4f9f-b1d1-08f07f94738a

📥 Commits

Reviewing files that changed from the base of the PR and between c89dc36 and 84f53ed.

📒 Files selected for processing (4)
  • backend/internal/models/user.go
  • backend/test/integration/paywall_test.go
  • tauri/src/components/sidebar/Sidebar.tsx
  • web-app/src/pages/Onboarding.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • backend/internal/models/user.go
  • web-app/src/pages/Onboarding.tsx
  • backend/test/integration/paywall_test.go

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant