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
15 changes: 14 additions & 1 deletion apps/site/app/_components/account-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useState } from "react";
import { ResendVerification } from "./resend-verification";
import { AddPasskey } from "./passkey-button";

/**
* Client-side account and library views.
Expand Down Expand Up @@ -148,6 +149,10 @@ export function AccountView() {
</>
)}

<h3>Passkeys</h3>
<p>Sign in with your fingerprint, face or device PIN instead of an email link.</p>
<AddPasskey />

<h3>Devices</h3>
<p>
Devices are remembered so you can play offline. There is no hardware fingerprinting; revoke
Expand Down Expand Up @@ -268,7 +273,15 @@ export function LibraryView() {
{item.status}
</td>
<td>{item.grantedAt?.slice(0, 10)}</td>
<td>{item.active ? <a href="/play">Play</a> : <a href="/support">Support</a>}</td>
<td>
{item.active ? (
<a href={`/play?mode=campaign&episode=${encodeURIComponent(item.episodeId)}`}>
Play
</a>
) : (
<a href="/support">Support</a>
)}
</td>
</tr>
))}
</tbody>
Expand Down
34 changes: 34 additions & 0 deletions apps/site/app/_components/auth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { createAuthClient } from "better-auth/client";
import { passkeyClient } from "@better-auth/passkey/client";

/**
* Browser client for Better Auth, used only for the passkey ceremonies (the
* WebAuthn calls need the browser API and the plugin's challenge handling).
* Everything else on the site talks to /api/v1/auth with plain fetch.
*/
export const authClient = createAuthClient({
basePath: "/api/v1/auth",
plugins: [passkeyClient()],
});

/** True when this browser can do WebAuthn at all. */
export function passkeysSupported(): boolean {
return typeof window !== "undefined" && typeof window.PublicKeyCredential === "function";
}

/** Who is signed in, from Better Auth's own session endpoint. */
export async function currentSession(): Promise<{ userId: string; email: string } | null> {
try {
const response = await fetch("/api/v1/auth/get-session", {
credentials: "include",
cache: "no-store",
});
if (!response.ok) return null;
const body = (await response.json()) as { user?: { id: string; email: string } } | null;
return body?.user ? { userId: body.user.id, email: body.user.email } : null;
} catch {
return null;
}
}
33 changes: 33 additions & 0 deletions apps/site/app/_components/claim-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { claimMessage, type ClaimState } from "./claim-state";

const STATES: ClaimState[] = [
"claimable",
"claimed_by_you",
"claimed",
"expired",
"pending",
"unavailable",
"invalid",
];

describe("claim page copy", () => {
it("has a heading and detail for every state, and no em dashes", () => {
for (const state of STATES) {
const message = claimMessage(state, "False Dawn");
expect(message.heading.length, state).toBeGreaterThan(0);
expect(message.detail.length, state).toBeGreaterThan(0);
expect(`${message.heading} ${message.detail}`).not.toMatch(/—/);
}
});

it("only calls the good states good", () => {
const ok = STATES.filter((state) => claimMessage(state, "X").ok);
expect(ok).toEqual(["claimable", "claimed_by_you"]);
});

it("names the episode where it matters", () => {
expect(claimMessage("claimable", "False Dawn").heading).toContain("False Dawn");
expect(claimMessage("claimed", "False Dawn").detail).toContain("False Dawn");
});
});
60 changes: 60 additions & 0 deletions apps/site/app/_components/claim-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* What the claim page says for each state the API reports. Pure, so the copy
* is tested and cannot drift from the states the API can return.
*/

export type ClaimState =
"claimable" | "claimed_by_you" | "claimed" | "expired" | "pending" | "unavailable" | "invalid";

export interface ClaimMessage {
ok: boolean;
heading: string;
detail: string;
}

export function claimMessage(state: ClaimState, title: string): ClaimMessage {
switch (state) {
case "claimable":
return {
ok: true,
heading: `${title} is paid for.`,
detail: "Attach it to an account and it is yours on every supported platform.",
};
case "claimed_by_you":
return { ok: true, heading: "Already yours.", detail: `${title} is in your library.` };
case "claimed":
return {
ok: false,
heading: "This link has already been used.",
detail: `${title} was claimed to an account. Each claim link works once.`,
};
case "expired":
return {
ok: false,
heading: "This claim link has expired.",
detail: "Links last 7 days. Your purchase is safe: we can email you a fresh one.",
};
case "pending":
return {
ok: false,
heading: "Your payment is still confirming.",
detail:
"Crypto confirms when the network does, usually within minutes. Check again shortly.",
};
case "unavailable":
return {
ok: false,
heading: "This order can no longer be claimed.",
detail:
"It was refunded, reversed or flagged for review. Contact support if that is wrong.",
};
case "invalid":
default:
return {
ok: false,
heading: "This claim link is not valid.",
detail:
"It may be incomplete. Copy the whole link from the email, or request a fresh one with the email you paid with.",
};
}
}
Loading
Loading