Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev": "vinxi dev --port 3010",
"dev:prod": "pnpm run build:prod && wrangler dev --port 3010",
"build": "vinxi build --mode production && node scripts/version-sw.js",
"build:prod": "pnpm run build && pnpm --filter @corates/shared build && pnpm --filter @corates/web build",
"build:prod": "pnpm --filter @corates/shared build && pnpm run build && pnpm --filter @corates/web build",
"preview": "npx serve .output/public",
"deploy": "pnpm run build:prod && wrangler deploy",
"lint": "eslint ."
Expand Down
7 changes: 5 additions & 2 deletions packages/landing/src/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ export const config = {
// Helper functions for common URLs
export const urls = {
signIn: () => `${config.appUrl}/signin`,
signUp: plan => {
signUp: (plan, interval) => {
const base = `${config.appUrl}/signup`;
return plan ? `${base}?plan=${plan}` : base;
if (!plan) return base;
const params = new URLSearchParams({ plan });
if (interval) params.set('interval', interval);
return `${base}?${params.toString()}`;
},
checklist: () => `${config.appUrl}/checklist?from=landing`,
dashboard: () => `${config.appUrl}/dashboard`,
Expand Down
5 changes: 5 additions & 0 deletions packages/landing/src/routes/pricing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export default function Pricing() {

// Get button URL with appropriate plan parameter
const getButtonUrl = plan => {
// For subscription plans, include billing interval
if (plan.cta === 'subscribe') {
return urls.signUp(plan.tier, billingInterval());
}
// For trial and single_project, no interval needed
return urls.signUp(plan.tier);
};

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/plans/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,11 @@ export function getBillingPlanCatalog(): BillingCatalogResponse {
],
};
}

/**
* Tiers that have a checkout/redirect flow (derived from catalog)
* Used for validating plan params from landing page URLs
*/
export const CHECKOUT_ELIGIBLE_TIERS: BillingCatalogTier[] = getBillingPlanCatalog()
.plans.filter(p => p.cta !== 'none')
.map(p => p.tier);
2 changes: 1 addition & 1 deletion packages/shared/src/plans/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export { PLAN_PRICING, getPlanPricing, getMonthlyEquivalent } from './pricing.js
export type { PlanPricing } from './pricing.js';

// Billing catalog (pricing page / billing UI)
export { getBillingPlanCatalog } from './catalog.js';
export { getBillingPlanCatalog, CHECKOUT_ELIGIBLE_TIERS } from './catalog.js';

// Stripe setup
export { getStripeProductConfig, getAllStripeProductConfigs } from './stripe.js';
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/api/better-auth-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,16 @@ function createBetterAuthStore() {
const base = (BASEPATH || '').replace(/\/$/, ''); // Remove trailing slash from basepath
const callbackURL = `${window.location.origin}${base}${path}`;

// Build error URL to redirect to /signin with error param on OAuth failure
const errorURL = `${window.location.origin}${base}/signin`;

// Save login method before redirect
saveLastLoginMethod(LOGIN_METHODS.GOOGLE);

const { data, error } = await authClient.signIn.social({
provider: 'google',
callbackURL,
errorCallbackURL: errorURL,
});

if (error) {
Expand All @@ -369,13 +373,17 @@ function createBetterAuthStore() {
const base = (BASEPATH || '').replace(/\/$/, ''); // Remove trailing slash from basepath
const callbackURL = `${window.location.origin}${base}${path}`;

// Build error URL to redirect to /signin with error param on OAuth failure
const errorURL = `${window.location.origin}${base}/signin`;

// Save login method before redirect
saveLastLoginMethod(LOGIN_METHODS.ORCID);

// Use genericOAuth signIn for custom providers
const { data, error } = await authClient.signIn.oauth2({
providerId: 'orcid',
callbackURL,
errorCallbackURL: errorURL,
});

if (error) {
Expand Down
33 changes: 23 additions & 10 deletions packages/web/src/api/billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export async function getSubscription() {
* Create a Stripe Checkout session
* @param {string} tier - The subscription tier to checkout
* @param {'monthly' | 'yearly'} interval - Billing interval
* @param {Object} [options] - Additional options
* @param {boolean} [options.showToast=true] - Whether to show toast on error
* @returns {Promise<{ url: string, sessionId: string }>}
*/
export async function createCheckoutSession(tier, interval = 'monthly') {
return apiFetch.post('/api/billing/checkout', { tier, interval });
export async function createCheckoutSession(tier, interval = 'monthly', options = {}) {
return apiFetch.post('/api/billing/checkout', { tier, interval }, options);
}

/**
Expand All @@ -35,9 +37,12 @@ export async function createPortalSession() {
* Redirect to Stripe Checkout
* @param {string} tier - The subscription tier
* @param {'monthly' | 'yearly'} interval - Billing interval
* @param {Object} [options] - Additional options
* @param {boolean} [options.showToast=false] - Whether to show toast on error (default false for redirect flows)
*/
export async function redirectToCheckout(tier, interval = 'monthly') {
const { url } = await createCheckoutSession(tier, interval);
export async function redirectToCheckout(tier, interval = 'monthly', options = {}) {
// Default showToast to false for redirect flows - caller handles errors
const { url } = await createCheckoutSession(tier, interval, { showToast: false, ...options });
window.location.href = url;
}

Expand All @@ -51,17 +56,22 @@ export async function redirectToPortal() {

/**
* Create a Stripe Checkout session for one-time Single Project purchase
* @param {Object} [options] - Additional options
* @param {boolean} [options.showToast=true] - Whether to show toast on error
* @returns {Promise<{ url: string, sessionId: string }>}
*/
export async function createSingleProjectCheckout() {
return apiFetch.post('/api/billing/single-project/checkout');
export async function createSingleProjectCheckout(options = {}) {
return apiFetch.post('/api/billing/single-project/checkout', {}, options);
}

/**
* Redirect to Stripe Checkout for Single Project purchase
* @param {Object} [options] - Additional options
* @param {boolean} [options.showToast=false] - Whether to show toast on error (default false for redirect flows)
*/
export async function redirectToSingleProjectCheckout() {
const { url } = await createSingleProjectCheckout();
export async function redirectToSingleProjectCheckout(options = {}) {
// Default showToast to false for redirect flows - caller handles errors
const { url } = await createSingleProjectCheckout({ showToast: false, ...options });
window.location.href = url;
}

Expand All @@ -75,10 +85,13 @@ export async function getMembers() {

/**
* Start a 14-day trial grant for the current org (owner-only)
* @param {Object} [options] - Additional options
* @param {boolean} [options.showToast=false] - Whether to show toast on error (default false - caller handles)
* @returns {Promise<{ success: boolean, grantId: string, expiresAt: number }>}
*/
export async function startTrial() {
return apiFetch.post('/api/billing/trial/start');
export async function startTrial(options = {}) {
// Default showToast to false - caller handles errors with specific messages
return apiFetch.post('/api/billing/trial/start', {}, { showToast: false, ...options });
}

/**
Expand Down
16 changes: 14 additions & 2 deletions packages/web/src/components/auth/AuthLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createEffect, Show, createSignal } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router';
import { useBetterAuth } from '@api/better-auth-store.js';
import { capturePlanParams, hasPendingPlan } from '@/lib/plan-redirect-utils.js';

/**
* AuthLayout - Layout for auth pages (signin, signup, etc.)
Expand Down Expand Up @@ -30,12 +31,23 @@ export default function AuthLayout(props) {
return;
}

// Capture plan params from signup page before redirecting (for logged-in users clicking from landing)
if (currentPath === '/signup') {
const urlParams = new URLSearchParams(window.location.search);
capturePlanParams(urlParams);
}

// If user hasn't completed profile setup, send to complete-profile
// Otherwise send to dashboard
// Otherwise send to dashboard (plan redirect will be handled there or in settings)
if (!currentUser?.profileCompletedAt) {
navigate('/complete-profile', { replace: true });
} else {
navigate('/dashboard', { replace: true });
// If there's a pending plan, go to settings/plans to handle it
if (hasPendingPlan()) {
navigate('/settings/plans', { replace: true });
} else {
navigate('/dashboard', { replace: true });
}
}
}
});
Expand Down
34 changes: 31 additions & 3 deletions packages/web/src/components/auth/CompleteProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ import { FiCheck } from 'solid-icons/fi';
import { handleError } from '@/lib/error-utils.js';
import { apiFetch } from '@lib/apiFetch.js';
import { showToast } from '@/components/ui/toast';
import {
hasPendingPlan,
clearPendingPlan,
handlePendingPlanRedirect,
BILLING_MESSAGES,
} from '@/lib/plan-redirect-utils.js';

const STEPS = [
{ title: 'Your Name', description: 'Basic information' },
Expand Down Expand Up @@ -83,9 +89,13 @@ export default function CompleteProfile() {
createEffect(() => {
const currentUser = user();

// If already completed onboarding, go to dashboard.
// If already completed onboarding, check for pending plan or go to dashboard.
if (currentUser?.profileCompletedAt) {
navigate('/dashboard', { replace: true });
if (hasPendingPlan()) {
navigate('/settings/plans', { replace: true });
} else {
navigate('/dashboard', { replace: true });
}
return;
}

Expand Down Expand Up @@ -240,10 +250,28 @@ export default function CompleteProfile() {
// Log error but don't block profile completion
console.error('Failed to accept invitation:', inviteErr);
localStorage.removeItem('pendingInvitationToken');
// Continue to dashboard
// Continue to dashboard or plan redirect
}
}

// Handle plan redirect from landing pricing page
const { handled, error: planError } = await handlePendingPlanRedirect({ navigate });

if (handled) {
if (planError) {
// Redirect failed - send to /settings/plans which has error recovery UI with retry
showToast.error(
BILLING_MESSAGES.CHECKOUT_ERROR.title,
BILLING_MESSAGES.CHECKOUT_ERROR.message,
);
navigate('/settings/plans', { replace: true });
}
// Success cases: handlePendingPlanRedirect already navigated
return;
}

// Default: no pending plan, clear any stale data and go to dashboard
clearPendingPlan();
await new Promise(resolve => setTimeout(resolve, 200));
navigate('/dashboard', { replace: true });
} catch (err) {
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/components/auth/SignIn.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ import MagicLinkForm from './MagicLinkForm.jsx';
import TwoFactorVerify from './TwoFactorVerify.jsx';
import LastLoginHint from './LastLoginHint.jsx';
import { handleError } from '@/lib/error-utils.js';
import { useOAuthError } from '@/primitives/useOAuthError.js';

export default function SignIn() {
// Handle OAuth errors from URL params (e.g., ?error=state_mismatch)
useOAuthError();

const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [error, setError] = createSignal('');
Expand Down Expand Up @@ -100,8 +104,10 @@ export default function SignIn() {
localStorage.setItem('oauthSignup', 'true');
// Redirect to complete-profile which will check if profile is complete
// and redirect to dashboard if so
// OAuth provider errors are handled by useOAuthError hook via errorCallbackURL redirect
await signinWithGoogle('/complete-profile');
} catch (err) {
// This catches immediate errors (network failure before redirect), not OAuth provider errors
console.error('Google sign-in error:', err);
setError('Failed to sign in with Google. Please try again.');
localStorage.removeItem('oauthSignup');
Expand All @@ -117,8 +123,10 @@ export default function SignIn() {
// Mark as OAuth signup in case this is a new user who needs to complete profile
localStorage.setItem('oauthSignup', 'true');
// Redirect to complete-profile which will check if profile is complete
// OAuth provider errors are handled by useOAuthError hook via errorCallbackURL redirect
await signinWithOrcid('/complete-profile');
} catch (err) {
// This catches immediate errors (network failure before redirect), not OAuth provider errors
console.error('ORCID sign-in error:', err);
setError('Failed to sign in with ORCID. Please try again.');
localStorage.removeItem('oauthSignup');
Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/components/auth/SignUp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import {
import MagicLinkForm from './MagicLinkForm.jsx';
import { LANDING_URL } from '@config/api.js';
import { handleError } from '@/lib/error-utils.js';
import { capturePlanParams } from '@/lib/plan-redirect-utils.js';
import { useOAuthError } from '@/primitives/useOAuthError.js';

/**
* Sign Up page - minimal friction with magic link or social providers
* After signup: users go to complete-profile to set name and role
*/
export default function SignUp() {
// Handle OAuth errors from URL params (e.g., ?error=state_mismatch)
useOAuthError();

const [error, setError] = createSignal('');
const [googleLoading, setGoogleLoading] = createSignal(false);
const [orcidLoading, setOrcidLoading] = createSignal(false);
Expand Down Expand Up @@ -49,6 +54,9 @@ export default function SignUp() {
localStorage.setItem('pendingInvitationToken', invitationToken);
}

// Capture plan selection from landing pricing page (e.g., /signup?plan=starter_team&interval=yearly)
capturePlanParams(urlParams);

// If the user clicks OAuth and then uses browser Back,
// the page can be restored from bfcache with stale state.
const handleReturn = () => resetSocialLoading();
Expand All @@ -75,8 +83,10 @@ export default function SignUp() {
// Mark this as an OAuth signup so complete-profile knows not to ask for password
localStorage.setItem('oauthSignup', 'true');
// OAuth users will be redirected to complete-profile after auth
// OAuth provider errors are handled by useOAuthError hook via errorCallbackURL redirect
await signinWithGoogle('/complete-profile');
} catch (err) {
// This catches immediate errors (network failure before redirect), not OAuth provider errors
console.error('Google sign-up error:', err);
await handleError(err, {
setError,
Expand All @@ -95,8 +105,10 @@ export default function SignUp() {
// Mark this as an OAuth signup so complete-profile knows not to ask for password
localStorage.setItem('oauthSignup', 'true');
// OAuth users will be redirected to complete-profile after auth
// OAuth provider errors are handled by useOAuthError hook via errorCallbackURL redirect
await signinWithOrcid('/complete-profile');
} catch (err) {
// This catches immediate errors (network failure before redirect), not OAuth provider errors
console.error('ORCID sign-up error:', err);
await handleError(err, {
setError,
Expand Down
Loading