diff --git a/apps/web/__tests__/components/habits/habit-card.test.tsx b/apps/web/__tests__/components/habits/habit-card.test.tsx index 4996f3c9a..4fbc47b5e 100644 --- a/apps/web/__tests__/components/habits/habit-card.test.tsx +++ b/apps/web/__tests__/components/habits/habit-card.test.tsx @@ -78,7 +78,7 @@ describe('HabitCard', () => { const habit = createMockHabit() render() const article = screen.getByLabelText('Exercise') - expect(article.tagName).toBe('ARTICLE') + expect(article.tagName).toBe('BUTTON') }) it('calls onDetail when card is clicked', () => { @@ -129,7 +129,7 @@ describe('HabitCard', () => { it('applies opacity-40 when habit is completed', () => { const habit = createMockHabit({ isCompleted: true }) const { container } = render() - const article = container.querySelector('article') + const article = container.querySelector('button[aria-label]') expect(article?.className).toContain('opacity-40') }) @@ -352,21 +352,13 @@ describe('HabitCard', () => { expect(screen.getByText('habits.actions.openSubHabits')).toBeDefined() }) - it('handles keyboard Enter to trigger card click', () => { + it('card button is focusable and clickable (Enter/Space handled natively)', () => { const onDetail = vi.fn() const habit = createMockHabit() render() - const article = screen.getByLabelText('Exercise') - fireEvent.keyDown(article, { key: 'Enter' }) - expect(onDetail).toHaveBeenCalledOnce() - }) - - it('handles keyboard Space to trigger card click', () => { - const onDetail = vi.fn() - const habit = createMockHabit() - render() - const article = screen.getByLabelText('Exercise') - fireEvent.keyDown(article, { key: ' ' }) + const card = screen.getByLabelText('Exercise') + expect(card.tagName).toBe('BUTTON') + fireEvent.click(card) expect(onDetail).toHaveBeenCalledOnce() }) @@ -380,14 +372,14 @@ describe('HabitCard', () => { it('uses child CSS classes at depth > 0', () => { const habit = createMockHabit() const { container } = render() - const article = container.querySelector('article') + const article = container.querySelector('button[aria-label]') expect(article?.className).toContain('habit-card-child') }) it('uses parent CSS classes at depth 0', () => { const habit = createMockHabit() const { container } = render() - const article = container.querySelector('article') + const article = container.querySelector('button[aria-label]') expect(article?.className).toContain('habit-card-parent') }) @@ -396,7 +388,7 @@ describe('HabitCard', () => { const { container } = render( , ) - const article = container.querySelector('article') + const article = container.querySelector('button[aria-label]') expect(article?.className).toContain('ring-2') }) diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index c27832430..032388395 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -278,7 +278,7 @@ export default function TodayPage() { const { tags } = useTags() // Show general on today preference (local storage) - const [showGeneralOnToday] = useState(() => { + const [showGeneralOnToday, _setShowGeneralOnToday] = useState(() => { if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return true // NOSONAR - SSR guard return localStorage.getItem('orbit_show_general_on_today') !== 'false' }) diff --git a/apps/web/app/(app)/upgrade/page.tsx b/apps/web/app/(app)/upgrade/page.tsx index 746992a6c..5ddfcfd73 100644 --- a/apps/web/app/(app)/upgrade/page.tsx +++ b/apps/web/app/(app)/upgrade/page.tsx @@ -421,6 +421,348 @@ function PlanCards({ plans, hasProAccess, checkoutLoading, discountedAmount, onC ) } +// --------------------------------------------------------------------------- +// Billing dashboard (S3776: extracted to reduce cognitive complexity) +// --------------------------------------------------------------------------- + +interface BillingDashboardProps { + billing: ReturnType['billing'] + isBillingLoading: boolean + isBillingError: boolean + profile: { aiMessagesUsed: number; aiMessagesLimit: number; isLifetimePro?: boolean } | null + locale: string + dateFnsLocale: Locale + usagePercent: number + usageUrgent: boolean + portalError: string + onOpenPortal: () => void + onRetryBilling: () => void + t: ReturnType +} + +function BillingDashboard({ + billing, isBillingLoading, isBillingError, profile, locale, dateFnsLocale, + usagePercent, usageUrgent, portalError, onOpenPortal, onRetryBilling, t, +}: Readonly) { + return ( +
+ {/* Loading */} + {isBillingLoading && ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + )} + + {/* Error */} + {isBillingError && !billing && !isBillingLoading && ( +
+ +

{t('upgrade.billing.error')}

+ +
+ )} + + {/* Loaded: billing data available (Stripe Pro) */} + {billing && ( + <> + {/* Plan card */} +
+
+
+ +
+
+
+

+ {billing.interval === 'yearly' ? t('upgrade.billing.plan.yearly') : t('upgrade.billing.plan.monthly')} +

+ {billing.cancelAtPeriodEnd && ( + + {t('upgrade.billing.plan.canceledBadge')} + + )} + {!billing.cancelAtPeriodEnd && billing.status === 'past_due' && ( + + {t('upgrade.billing.plan.pastDue')} + + )} +
+

+ {billing.cancelAtPeriodEnd + ? t('upgrade.billing.plan.canceledHint', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) }) + : t('upgrade.billing.plan.renewsOn', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) })} + {billing.amountPerPeriod > 0 && ( + + {' '}·{' '}{formatPrice(billing.amountPerPeriod, billing.currency)} + {billing.interval === 'yearly' ? t('upgrade.plans.yearly.period') : t('upgrade.plans.monthly.period')} + + )} +

+
+
+
+ + {/* Payment method */} + {billing.paymentMethod && ( +
+
+
+ +
+

+ {t('upgrade.billing.payment.card', { + brand: formatCardBrand(billing.paymentMethod.brand), + last4: billing.paymentMethod.last4, + })} +

+

+ {t('upgrade.billing.payment.expires', { + month: String(billing.paymentMethod.expMonth).padStart(2, '0'), + year: billing.paymentMethod.expYear, + })} +

+
+
+ +
+
+ )} + + + + {/* Invoice history */} + {billing.recentInvoices.length > 0 && ( +
+
+

{t('upgrade.billing.invoices.title')}

+
+
+ {billing.recentInvoices.map((invoice) => ( +
+
+
+ + {formatBillingDate(invoice.date, locale, dateFnsLocale)} + + + {invoiceReasonLabelFn(invoice.billingReason, t)} + +
+ + {invoiceStatusLabelFn(invoice.status, t)} + +
+
+ + {formatPrice(invoice.amountPaid, invoice.currency)} + + {(invoice.invoicePdf ?? invoice.hostedInvoiceUrl) && ( + + + + )} +
+
+ ))} +
+
+ )} + + {/* Manage subscription */} +
+ +

{t('upgrade.billing.actions.manageHint')}

+ {portalError &&

{portalError}

} +
+ + )} + + {/* Loaded: no billing data (lifetime Pro or no Stripe subscription) */} + {!isBillingLoading && !isBillingError && !billing && ( + <> + {/* Plan card */} +
+
+
+ +
+
+

+ {profile?.isLifetimePro ? t('upgrade.billing.plan.lifetime') : t('upgrade.alreadyPro')} +

+

+ {profile?.isLifetimePro ? t('upgrade.billing.plan.lifetimeHint') : t('upgrade.manageHint')} +

+
+
+
+ + + + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Pricing section (S3776: extracted to reduce cognitive complexity) +// --------------------------------------------------------------------------- + +interface PricingSectionProps { + profile: { isTrialActive?: boolean } | null + plans: ReturnType['plans'] + isLoadingPlans: boolean + isPlansError: boolean + trialExpired: boolean + trialDaysLeft: number | null + trialUrgent: boolean + hasProAccess: boolean + checkoutLoading: string | null + checkoutError: string + discountedAmount: (amount: number) => number + onCheckout: (interval: 'monthly' | 'yearly') => void + onRetryPlans: () => void + t: ReturnType +} + +function PricingSection({ + profile, plans, isLoadingPlans, isPlansError, trialExpired, trialDaysLeft, trialUrgent, + hasProAccess, checkoutLoading, checkoutError, discountedAmount, onCheckout, onRetryPlans, t, +}: Readonly) { + return ( + <> + {/* Trial countdown banner */} + {profile?.isTrialActive && ( +
+ +

+ {trialDaysLeft === 0 + ? t('trial.banner.lastDay') + : plural(t('trial.banner.daysLeft', { days: trialDaysLeft ?? 0 }), trialDaysLeft ?? 0)} +

+
+ )} + + {/* Trial expired emotional section */} + {trialExpired && ( +
+
+ + {t('trial.expired.title')} +
+

+ {t('trial.expired.dontLose')} +

+
    + {trialExpiredFeatures.map((feature) => ( +
  • + + {t(feature)} +
  • + ))} +
+
+ )} + + {/* PRICING PLAN CARDS */} + + {/* Loading skeletons */} + {isLoadingPlans && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Error state */} + {isPlansError && !plans && !isLoadingPlans && ( +
+ +

{t('upgrade.plans.error')}

+ +
+ )} + + {/* Plan cards */} + {plans && ( + + )} + + {checkoutError && ( +

{checkoutError}

+ )} + + {/* Feature comparison */} + + + ) +} + // --------------------------------------------------------------------------- // Main page // --------------------------------------------------------------------------- @@ -505,293 +847,37 @@ export default function UpgradePage() { {/* Already Pro: Billing Dashboard */} {hasProAccess && !profile?.isTrialActive ? ( -
- {/* Loading */} - {isBillingLoading && ( - <> -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - )} - - {/* Error */} - {isBillingError && !billing && !isBillingLoading && ( -
- -

{t('upgrade.billing.error')}

- -
- )} - - {/* Loaded: billing data available (Stripe Pro) */} - {billing && ( - <> - {/* Plan card */} -
-
-
- -
-
-
-

- {billing.interval === 'yearly' ? t('upgrade.billing.plan.yearly') : t('upgrade.billing.plan.monthly')} -

- {billing.cancelAtPeriodEnd && ( - - {t('upgrade.billing.plan.canceledBadge')} - - )} - {!billing.cancelAtPeriodEnd && billing.status === 'past_due' && ( - - {t('upgrade.billing.plan.pastDue')} - - )} -
-

- {billing.cancelAtPeriodEnd - ? t('upgrade.billing.plan.canceledHint', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) }) - : t('upgrade.billing.plan.renewsOn', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) })} - {billing.amountPerPeriod > 0 && ( - - {' '}·{' '}{formatPrice(billing.amountPerPeriod, billing.currency)} - {billing.interval === 'yearly' ? t('upgrade.plans.yearly.period') : t('upgrade.plans.monthly.period')} - - )} -

-
-
-
- - {/* Payment method */} - {billing.paymentMethod && ( -
-
-
- -
-

- {t('upgrade.billing.payment.card', { - brand: formatCardBrand(billing.paymentMethod.brand), - last4: billing.paymentMethod.last4, - })} -

-

- {t('upgrade.billing.payment.expires', { - month: String(billing.paymentMethod.expMonth).padStart(2, '0'), - year: billing.paymentMethod.expYear, - })} -

-
-
- -
-
- )} - - - - {/* Invoice history */} - {billing.recentInvoices.length > 0 && ( -
-
-

{t('upgrade.billing.invoices.title')}

-
-
- {billing.recentInvoices.map((invoice) => ( -
-
-
- - {formatBillingDate(invoice.date, locale, dateFnsLocale)} - - - {invoiceReasonLabelFn(invoice.billingReason, t)} - -
- - {invoiceStatusLabelFn(invoice.status, t)} - -
-
- - {formatPrice(invoice.amountPaid, invoice.currency)} - - {(invoice.invoicePdf ?? invoice.hostedInvoiceUrl) && ( - - - - )} -
-
- ))} -
-
- )} - - {/* Manage subscription */} -
- -

{t('upgrade.billing.actions.manageHint')}

- {portalError &&

{portalError}

} -
- - )} - - {/* Loaded: no billing data (lifetime Pro or no Stripe subscription) */} - {!isBillingLoading && !isBillingError && !billing && ( - <> - {/* Plan card */} -
-
-
- -
-
-

- {profile?.isLifetimePro ? t('upgrade.billing.plan.lifetime') : t('upgrade.alreadyPro')} -

-

- {profile?.isLifetimePro ? t('upgrade.billing.plan.lifetimeHint') : t('upgrade.manageHint')} -

-
-
-
- - - - )} -
+ refetchBilling()} + t={t} + /> ) : ( - <> - {/* Trial countdown banner */} - {profile?.isTrialActive && ( -
- -

- {trialDaysLeft === 0 - ? t('trial.banner.lastDay') - : plural(t('trial.banner.daysLeft', { days: trialDaysLeft ?? 0 }), trialDaysLeft ?? 0)} -

-
- )} - - {/* Trial expired emotional section */} - {trialExpired && ( -
-
- - {t('trial.expired.title')} -
-

- {t('trial.expired.dontLose')} -

-
    - {trialExpiredFeatures.map((feature) => ( -
  • - - {t(feature)} -
  • - ))} -
-
- )} - - {/* PRICING PLAN CARDS */} - - {/* Loading skeletons */} - {isLoadingPlans && ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- )} - - {/* Error state */} - {isPlansError && !plans && !isLoadingPlans && ( -
- -

{t('upgrade.plans.error')}

- -
- )} - - {/* Plan cards */} - {plans && ( - - )} - - {checkoutError && ( -

{checkoutError}

- )} - - {/* Feature comparison */} - - + refetchPlans()} + t={t} + /> )}
) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 08fb1d786..6ec9d85b0 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -252,6 +252,71 @@ function CodeStep({ ) } +// --------------------------------------------------------------------------- +// API helpers (S3776: extracted to reduce cognitive complexity) +// --------------------------------------------------------------------------- + +async function fetchAuthEndpoint( + url: string, + body: Record, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + const data = await response.json().catch(() => null) + throw data ?? { error: 'Authentication failed' } + } + return response.json() +} + +function handleVerifySuccess( + loginResponse: LoginResponse, + referralCode: string | undefined, + setAuth: (lr: LoginResponse) => void, + setSuccessMessage: (msg: string | null) => void, + t: ReturnType, + router: ReturnType, + getReturnUrl: () => string, +) { + setAuth(loginResponse) + if (referralCode) { + localStorage.setItem('orbit_referral_applied', '1') + document.cookie = 'referral_code=;max-age=0;path=/;samesite=strict;secure' + } + if (loginResponse.wasReactivated) { + setSuccessMessage(t('profile.deleteAccount.reactivated')) + } + router.push(getReturnUrl()) +} + +function handleCodeDigitInput( + index: number, + cleanValue: string, + codeDigits: string[], + setCodeDigits: (digits: string[]) => void, + codeInputRefs: React.RefObject<(HTMLInputElement | null)[]>, + verifyCode: (code: string) => void, +) { + if (cleanValue.length > 1) { + const { digits: newCodeDigits, nextFocusIndex } = fillCodeDigits(index, cleanValue, codeDigits) + setCodeDigits(newCodeDigits) + codeInputRefs.current[nextFocusIndex]?.focus() + if (newCodeDigits.join('').length === 6) { + setTimeout(() => verifyCode(newCodeDigits.join('')), 0) + } + return + } + const newCodeDigits = [...codeDigits] + newCodeDigits[index] = cleanValue + setCodeDigits(newCodeDigits) + if (cleanValue && index < 5) { + codeInputRefs.current[index + 1]?.focus() + } +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -330,17 +395,7 @@ export default function LoginPage() { setErrorMessage(null) try { - const response = await fetch('/api/auth/send-code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, language: locale }), - }) - - if (!response.ok) { - const data = await response.json().catch(() => null) - throw data ?? { error: 'Authentication failed' } - } - + await fetchAuthEndpoint('/api/auth/send-code', { email, language: locale }) setStep('code') setSuccessMessage(t('auth.codeSent')) startResendCountdown() @@ -359,36 +414,13 @@ export default function LoginPage() { setSuccessMessage(null) try { - const response = await fetch('/api/auth/verify-code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email, - code, - language: locale, - ...(referralCode ? { referralCode } : {}), - }), - }) - - if (!response.ok) { - const data = await response.json().catch(() => null) - throw data ?? { error: 'Authentication failed' } - } - - const loginResponse = (await response.json()) as LoginResponse - - setAuth(loginResponse) - - if (referralCode) { - localStorage.setItem('orbit_referral_applied', '1') - document.cookie = 'referral_code=;max-age=0;path=/;samesite=strict;secure' - } - - if (loginResponse.wasReactivated) { - setSuccessMessage(t('profile.deleteAccount.reactivated')) - } - - router.push(getReturnUrl()) + const loginResponse = await fetchAuthEndpoint('/api/auth/verify-code', { + email, + code, + language: locale, + ...(referralCode ? { referralCode } : {}), + }) as LoginResponse + handleVerifySuccess(loginResponse, referralCode, setAuth, setSuccessMessage, t, router, getReturnUrl) } catch (err: unknown) { setErrorMessage(extractError(err, t)) } finally { @@ -403,17 +435,7 @@ export default function LoginPage() { setSuccessMessage(null) try { - const response = await fetch('/api/auth/send-code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, language: locale }), - }) - - if (!response.ok) { - const data = await response.json().catch(() => null) - throw data ?? { error: 'Authentication failed' } - } - + await fetchAuthEndpoint('/api/auth/send-code', { email, language: locale }) setSuccessMessage(t('auth.codeSent')) startResendCountdown() } catch (err: unknown) { @@ -432,24 +454,7 @@ export default function LoginPage() { function onCodeInput(index: number, value: string) { const cleanValue = value.replaceAll(/\D/g, '') - - if (cleanValue.length > 1) { - const { digits: newCodeDigits, nextFocusIndex } = fillCodeDigits(index, cleanValue, codeDigits) - setCodeDigits(newCodeDigits) - codeInputRefs.current[nextFocusIndex]?.focus() - if (newCodeDigits.join('').length === 6) { - setTimeout(() => verifyCode(newCodeDigits.join('')), 0) - } - return - } - - const newCodeDigits = [...codeDigits] - newCodeDigits[index] = cleanValue - setCodeDigits(newCodeDigits) - - if (cleanValue && index < 5) { - codeInputRefs.current[index + 1]?.focus() - } + handleCodeDigitInput(index, cleanValue, codeDigits, setCodeDigits, codeInputRefs, (c) => verifyCode(c)) } function onCodePaste(event: React.ClipboardEvent) { diff --git a/apps/web/components/habits/habit-card.tsx b/apps/web/components/habits/habit-card.tsx index 53a310d43..cac2dd85b 100644 --- a/apps/web/components/habits/habit-card.tsx +++ b/apps/web/components/habits/habit-card.tsx @@ -1086,16 +1086,10 @@ export function HabitCard({ return ( <>
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleCardClick() - } - }} aria-label={habit.title} >
)}
-
+
{showActionsMenu && ( diff --git a/apps/web/components/habits/habit-form-fields.tsx b/apps/web/components/habits/habit-form-fields.tsx index e956607ba..9880ba6f5 100644 --- a/apps/web/components/habits/habit-form-fields.tsx +++ b/apps/web/components/habits/habit-form-fields.tsx @@ -408,6 +408,66 @@ function ScheduledReminderSection({ ) } +// --------------------------------------------------------------------------- +// Slip alert sub-component (S3776: extracted to reduce cognitive complexity) +// --------------------------------------------------------------------------- + +interface SlipAlertSectionProps { + hasProAccess: boolean + slipAlertEnabled: boolean + slipAlertLabelId: string + slipAlertDescriptionId: string + onToggle: () => void + t: ReturnType +} + +function SlipAlertSection({ + hasProAccess, slipAlertEnabled, slipAlertLabelId, slipAlertDescriptionId, onToggle, t, +}: Readonly) { + return ( +
+ {hasProAccess ? ( + /* Pro unlocked state */ +
+
+
+ + {t('habits.form.slipAlert')} +
+ {t('habits.form.slipAlertDescription')} +
+ +
+ ) : ( + /* Pro locked state */ +
+
+
+ + {t('habits.form.slipAlert')} + {t('common.proBadge')} +
+ {t('habits.form.slipAlertDescription')} +
+
+ +
+
+ )} +
+ ) +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -1000,46 +1060,14 @@ export function HabitFormFields({ {/* Slip alert toggle (only when bad habit) */} {watchedIsBadHabit && ( -
- {hasProAccess ? ( - /* Pro unlocked state */ -
-
-
- - {t('habits.form.slipAlert')} -
- {t('habits.form.slipAlertDescription')} -
- -
- ) : ( - /* Pro locked state */ -
-
-
- - {t('habits.form.slipAlert')} - {t('common.proBadge')} -
- {t('habits.form.slipAlertDescription')} -
-
- -
-
- )} -
+ setValue('slipAlertEnabled', !watchedSlipAlertEnabled, { shouldDirty: true })} + t={t} + /> )} {/* Slot for extra fields (e.g. sub-habits) */} diff --git a/apps/web/components/ui/app-overlay.tsx b/apps/web/components/ui/app-overlay.tsx index b8c258000..94b2cf925 100644 --- a/apps/web/components/ui/app-overlay.tsx +++ b/apps/web/components/ui/app-overlay.tsx @@ -201,16 +201,18 @@ export function AppOverlay({ if (isEntering) panelClass = 'translate-y-full sm:translate-y-0 sm:scale-95 opacity-0' else if (isLeaving) panelClass = 'opacity-0' - const overlay = ( // NOSONAR - backdrop dismiss via pointer; keyboard equivalent handled by Escape key listener + const overlay = (
- {/* Backdrop */} -