redesign: auth pages (#60)

Remove card/shadow layout in favor of a clean centered design.
Add tree logo, simplify inputs (placeholder-only, no labels),
use soft buttons, narrow form width, and clean up footer links.

* fix: remove unused AUTUMN_SEO_DATA_USAGE_FEATURE_ID export

This constant became unused after the billing buckets separation
in #57 which replaced it with separate balance/topup feature IDs.

* fix: resolve oxlint errors from billing buckets PR

Add eslint-disable for max-lines in HostedBillingContent.tsx.
Type trackMock properly in dataforseoClient.test.ts to eliminate
unsafe type assertions without needing eslint-disable.
This commit is contained in:
Ben Senescu 2026-04-02 15:11:31 -04:00 committed by Ben Senescu
parent 25255923b8
commit 5617c6b9f3
10 changed files with 274 additions and 328 deletions

BIN
public/transparent-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -38,32 +38,37 @@ export function AuthPageCard({
footer, footer,
}: { }: {
title: string; title: string;
helperText: string; helperText?: string;
children: React.ReactNode; children: React.ReactNode;
footer?: React.ReactNode; footer?: React.ReactNode;
}) { }) {
return ( return (
<div className="card w-full max-w-md bg-base-100 shadow-xl border border-base-300"> <div className="w-full max-w-xs space-y-6">
<div className="card-body gap-4"> <div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<div> <div>
<h1 className="text-2xl font-semibold">{title}</h1> <h1 className="text-xl font-semibold">{title}</h1>
<p className="text-sm text-base-content/70 mt-1">{helperText}</p> {helperText ? (
<p className="text-sm text-base-content/60 mt-1">{helperText}</p>
) : null}
</div>
</div> </div>
{children} {children}
{footer} {footer ? <div className="text-center">{footer}</div> : null}
</div>
</div> </div>
); );
} }
export function AuthPageShell({ children }: { children: React.ReactNode }) { export function AuthPageShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-[100dvh] bg-base-200"> <div className="min-h-[100dvh] flex flex-col items-center justify-center p-4 bg-base-100">
<div className="min-h-[100dvh] flex items-center justify-center p-4">
{children} {children}
</div> </div>
</div>
); );
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { UseCustomerResult } from "autumn-js/react"; import type { UseCustomerResult } from "autumn-js/react";
import { ExternalLink, LoaderCircle } from "lucide-react"; import { ExternalLink, LoaderCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";

View File

@ -23,20 +23,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
component: SignInPage, component: SignInPage,
}); });
function getHelperText(isHostedMode: boolean) {
if (!isHostedMode) {
return "Sign-in isn't available right now.";
}
return "Sign in to your OpenSEO account.";
}
function SignInPage() { function SignInPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState( const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect, search.redirect,
); );
const helperText = getHelperText(isHostedMode);
const [verificationEmail, setVerificationEmail] = useState<string | null>( const [verificationEmail, setVerificationEmail] = useState<string | null>(
null, null,
); );
@ -127,19 +118,24 @@ function SignInPage() {
return ( return (
<AuthPageCard <AuthPageCard
title="Sign in" title="Sign in"
helperText={helperText}
footer={ footer={
isHostedMode ? ( isHostedMode ? (
<p className="text-sm text-base-content/70"> <div className="flex justify-between text-sm text-base-content/50">
Need an account?{" "} <Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Forgot password?
</Link>
<Link <Link
to="/sign-up" to="/sign-up"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="link link-primary" className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
> >
Create account Create account
</Link> </Link>
</p> </div>
) : null ) : null
} }
> >
@ -150,18 +146,16 @@ function SignInPage() {
void form.handleSubmit(); void form.handleSubmit();
}} }}
> >
<label className="form-control block">
<span className="label-text text-sm font-medium">Email</span>
<form.Field name="email"> <form.Field name="email">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="email" type="email"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="you@example.com" placeholder="Email address..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email" autoComplete="email"
@ -171,24 +165,21 @@ function SignInPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Password</span>
<form.Field name="password"> <form.Field name="password">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="password" type="password"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Enter your password" placeholder="Password..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password" autoComplete="current-password"
@ -198,21 +189,10 @@ function SignInPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<div className="text-right">
<Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="link link-hover text-sm"
>
Forgot password?
</Link>
</div>
{verificationEmail ? ( {verificationEmail ? (
<div className="alert alert-warning items-start"> <div className="alert alert-warning items-start">
@ -251,7 +231,7 @@ function SignInPage() {
<p className="text-sm text-error">{errorMessage}</p> <p className="text-sm text-error">{errorMessage}</p>
) : null} ) : null}
<button <button
className="btn btn-primary w-full" className="btn btn-soft w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting} disabled={!isHostedMode || isSessionPending || isSubmitting}
> >
{isSubmitting ? "Signing in..." : "Sign in"} {isSubmitting ? "Signing in..." : "Sign in"}

View File

@ -41,19 +41,12 @@ export const Route = createFileRoute("/_auth/sign-up")({
component: SignUpPage, component: SignUpPage,
}); });
function getHelperText(isHostedMode: boolean) {
return isHostedMode
? "Create your OpenSEO account."
: "Account creation is only available when AUTH_MODE=hosted.";
}
function SignUpPage() { function SignUpPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState( const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect, search.redirect,
); );
const helperText = getHelperText(isHostedMode);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -109,16 +102,15 @@ function SignUpPage() {
return ( return (
<AuthPageCard <AuthPageCard
title="Create account" title="Create your account"
helperText={helperText}
footer={ footer={
isHostedMode ? ( isHostedMode ? (
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/50">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="link link-primary" className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
> >
Sign in Sign in
</Link> </Link>
@ -133,18 +125,16 @@ function SignUpPage() {
void form.handleSubmit(); void form.handleSubmit();
}} }}
> >
<label className="form-control block">
<span className="label-text text-sm font-medium">Name</span>
<form.Field name="name"> <form.Field name="name">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="text" type="text"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Jane Doe (optional)" placeholder="Name (optional)..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name" autoComplete="name"
@ -153,24 +143,21 @@ function SignUpPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Email</span>
<form.Field name="email"> <form.Field name="email">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="email" type="email"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="you@example.com" placeholder="Email address..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email" autoComplete="email"
@ -180,24 +167,21 @@ function SignUpPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Password</span>
<form.Field name="password"> <form.Field name="password">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="password" type="password"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Create a password" placeholder="Password..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password" autoComplete="new-password"
@ -209,26 +193,21 @@ function SignUpPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">
Confirm password
</span>
<form.Field name="confirmPassword"> <form.Field name="confirmPassword">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="password" type="password"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Confirm your password" placeholder="Confirm password..."
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password" autoComplete="new-password"
@ -240,11 +219,10 @@ function SignUpPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<form.Subscribe <form.Subscribe
selector={(state) => ({ selector={(state) => ({
@ -260,7 +238,7 @@ function SignUpPage() {
<p className="text-sm text-error">{errorMessage}</p> <p className="text-sm text-error">{errorMessage}</p>
) : null} ) : null}
<button <button
className="btn btn-primary w-full" className="btn btn-soft w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting} disabled={!isHostedMode || isSessionPending || isSubmitting}
> >
{isSubmitting ? "Creating account..." : "Create account"} {isSubmitting ? "Creating account..." : "Create account"}

View File

@ -87,12 +87,11 @@ function ForgotPasswordPage() {
: "Password reset isn't available right now." : "Password reset isn't available right now."
} }
footer={ footer={
<p className="text-sm text-base-content/70"> <p className="text-sm">
Remembered it?{" "}
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="link link-primary" className="text-base-content/50 hover:text-base-content transition-colors"
> >
Back to sign in Back to sign in
</Link> </Link>
@ -114,20 +113,16 @@ function ForgotPasswordPage() {
void form.handleSubmit(); void form.handleSubmit();
}} }}
> >
<label className="form-control block">
<span className="label-text text-sm font-medium">
Email
</span>
<form.Field name="email"> <form.Field name="email">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="email" type="email"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="you@example.com" placeholder="Email address..."
value={field.state.value} value={field.state.value}
onChange={(event) => onChange={(event) =>
field.handleChange(event.target.value) field.handleChange(event.target.value)
@ -139,17 +134,16 @@ function ForgotPasswordPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
{errorMessage ? ( {errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p> <p className="text-sm text-error">{errorMessage}</p>
) : null} ) : null}
<button <button
className="btn btn-primary w-full" className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting} disabled={!isHostedMode || isSubmitting}
> >
{isSubmitting ? "Sending reset link..." : "Send reset link"} {isSubmitting ? "Sending reset link..." : "Send reset link"}

View File

@ -173,14 +173,13 @@ function ResetPasswordPage() {
title={pageCopy.title} title={pageCopy.title}
helperText={pageCopy.helperText} helperText={pageCopy.helperText}
footer={ footer={
<p className="text-sm text-base-content/70"> <p className="text-sm">
Back to{" "}
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="link link-primary" className="text-base-content/50 hover:text-base-content transition-colors"
> >
sign in Sign in
</Link> </Link>
</p> </p>
} }
@ -192,7 +191,7 @@ function ResetPasswordPage() {
? "/sign-in" ? "/sign-in"
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}` : `/sign-in?redirect=${encodeURIComponent(redirectTo)}`
} }
className="btn btn-primary w-full" className="btn btn-soft w-full"
> >
Continue to sign in Continue to sign in
</a> </a>
@ -200,7 +199,7 @@ function ResetPasswordPage() {
<Link <Link
to="/forgot-password" to="/forgot-password"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full" className="btn btn-soft w-full"
> >
Request a new reset link Request a new reset link
</Link> </Link>
@ -212,20 +211,16 @@ function ResetPasswordPage() {
void form.handleSubmit(); void form.handleSubmit();
}} }}
> >
<label className="form-control block">
<span className="label-text text-sm font-medium">
New password
</span>
<form.Field name="password"> <form.Field name="password">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="password" type="password"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Create a new password" placeholder="New password..."
value={field.state.value} value={field.state.value}
onChange={(event) => onChange={(event) =>
field.handleChange(event.target.value) field.handleChange(event.target.value)
@ -238,26 +233,21 @@ function ResetPasswordPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">
Confirm password
</span>
<form.Field name="confirmPassword"> <form.Field name="confirmPassword">
{(field) => { {(field) => {
const error = getFieldError(field.state.meta.errors); const error = getFieldError(field.state.meta.errors);
return ( return (
<> <div>
<input <input
type="password" type="password"
className="input input-bordered w-full mt-1" className="input input-bordered w-full"
placeholder="Confirm your new password" placeholder="Confirm new password..."
value={field.state.value} value={field.state.value}
onChange={(event) => onChange={(event) =>
field.handleChange(event.target.value) field.handleChange(event.target.value)
@ -270,17 +260,16 @@ function ResetPasswordPage() {
{error ? ( {error ? (
<p className="mt-1 text-sm text-error">{error}</p> <p className="mt-1 text-sm text-error">{error}</p>
) : null} ) : null}
</> </div>
); );
}} }}
</form.Field> </form.Field>
</label>
{errorMessage ? ( {errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p> <p className="text-sm text-error">{errorMessage}</p>
) : null} ) : null}
<button <button
className="btn btn-primary w-full" className="btn btn-soft w-full"
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "Updating password..." : "Update password"} {isSubmitting ? "Updating password..." : "Update password"}

View File

@ -151,14 +151,13 @@ function VerifyEmailPage() {
title={pageCopy.title} title={pageCopy.title}
helperText={pageCopy.helperText} helperText={pageCopy.helperText}
footer={ footer={
<p className="text-sm text-base-content/70"> <p className="text-sm">
Need to sign in instead?{" "}
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="link link-primary" className="text-base-content/50 hover:text-base-content transition-colors"
> >
Open sign in Sign in
</Link> </Link>
</p> </p>
} }
@ -171,7 +170,7 @@ function VerifyEmailPage() {
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full" className="btn btn-soft w-full"
> >
Back to sign in Back to sign in
</Link> </Link>
@ -186,7 +185,7 @@ function VerifyEmailPage() {
</div> </div>
<button <button
type="button" type="button"
className="btn btn-outline w-full" className="btn btn-soft w-full"
onClick={() => void handleResend()} onClick={() => void handleResend()}
disabled={isResending} disabled={isResending}
> >
@ -205,7 +204,7 @@ function VerifyEmailPage() {
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full" className="btn btn-soft w-full"
> >
Sign in to continue Sign in to continue
</Link> </Link>

View File

@ -4,10 +4,17 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing"; } from "@/shared/billing";
interface TrackCallArg {
customerId: string;
featureId: string;
value: number;
properties?: { balanceFeatureId: string };
}
const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } = const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
vi.hoisted(() => ({ vi.hoisted(() => ({
checkMock: vi.fn(), checkMock: vi.fn(),
trackMock: vi.fn(), trackMock: vi.fn<(arg: TrackCallArg) => void>(),
getOrCreateMock: vi.fn(), getOrCreateMock: vi.fn(),
isHostedServerAuthModeMock: vi.fn(), isHostedServerAuthModeMock: vi.fn(),
})); }));
@ -218,23 +225,17 @@ describe("meterDataforseoCall with split balances", () => {
await client.backlinks.summary(backlinksInput); await client.backlinks.summary(backlinksInput);
const monthlyCall = trackMock.mock.calls.find( const monthlyCall = trackMock.mock.calls.find(
(call: unknown[]) => ([arg]) => arg.featureId === AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
(call[0] as { featureId: string }).featureId ===
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
); );
const topupCall = trackMock.mock.calls.find( const topupCall = trackMock.mock.calls.find(
(call: unknown[]) => ([arg]) => arg.featureId === AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
(call[0] as { featureId: string }).featureId ===
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
); );
expect( expect(monthlyCall![0].properties?.balanceFeatureId).toBe(
(monthlyCall?.[0] as { properties: { balanceFeatureId: string } }) AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
.properties.balanceFeatureId, );
).toBe(AUTUMN_SEO_DATA_BALANCE_FEATURE_ID); expect(topupCall![0].properties?.balanceFeatureId).toBe(
expect( AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
(topupCall?.[0] as { properties: { balanceFeatureId: string } }) );
.properties.balanceFeatureId,
).toBe(AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID);
}); });
}); });

View File

@ -6,7 +6,6 @@ export const AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID =
"managed_service_access"; "managed_service_access";
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits"; export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits"; export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
export const AUTUMN_SEO_DATA_USAGE_FEATURE_ID = "seo_data_usage";
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
export const MINIMUM_SEO_DATA_BALANCE_USD = 0.15; export const MINIMUM_SEO_DATA_BALANCE_USD = 0.15;