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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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