Enhance intake link functionality and theme management
- Added `NEXT_PUBLIC_APP_URL` to `.env.example` for app URL configuration. - Updated `README.md` to document the new `NEXT_PUBLIC_APP_URL` variable. - Improved CSS to support light and dark color schemes. - Refactored intake link actions to utilize the new app URL and handle token generation more efficiently. - Enhanced public intake route to validate work area selection. - Updated `IntakeLinksPanel` to allow copying of generated links. - Improved error handling and user experience in `BriefPreviewPublic` and `IntakePublic` components. - Refined theme synchronization and toggle components for better user preference management.
This commit is contained in:
parent
d9f6ac0fad
commit
b52d4a6287
15 changed files with 301 additions and 103 deletions
|
|
@ -7,6 +7,10 @@
|
|||
# to it directly — so the URL itself is not a secret.
|
||||
NEXT_PUBLIC_PB_URL=https://pocketbase.example.com
|
||||
|
||||
# Public address of this app. Used as a fallback when a link is generated
|
||||
# without a browser Origin header. Set this to your production HTTPS domain.
|
||||
NEXT_PUBLIC_APP_URL=https://planner.example.com
|
||||
|
||||
# Superuser credentials, used server-side only by scripts/setup-remote.ts,
|
||||
# scripts/seed-demo.ts, and the /api/client/* route handlers.
|
||||
PB_SUPERUSER_EMAIL=you@example.com
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ Fill in `.env.local`:
|
|||
| Variable | Notes |
|
||||
| --- | --- |
|
||||
| `NEXT_PUBLIC_PB_URL` | The Coolify PocketBase URL. Public by design — the browser connects to it directly. |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public HTTPS address of the app, used if a link request has no Origin header. Set it to the deployed domain, for example `https://planner.apphub.co.za`. |
|
||||
| `PB_SUPERUSER_EMAIL` | Server-side only. Never exposed to the browser. |
|
||||
| `PB_SUPERUSER_PASSWORD` | Server-side only. Never commit this. |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { getAdminPb } from "@/lib/pb/admin";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { generateIntakeToken } from "@/lib/intake/token";
|
||||
import { intakeUrl } from "@/lib/intake-links/url";
|
||||
import type { IntakeLinkRecord } from "@/lib/pb/types";
|
||||
|
||||
/** Owner-only entry point. See src/app/actions/log-work.ts for the pattern. */
|
||||
|
|
@ -37,10 +39,12 @@ export async function createIntakeLinkAction(input: {
|
|||
|
||||
const label = input.label.trim().slice(0, 80);
|
||||
if (!label) throw new Error("A label is required");
|
||||
const token = generateIntakeToken();
|
||||
const url = await intakeUrlFor(token);
|
||||
|
||||
const createFields: Record<string, unknown> = {
|
||||
label,
|
||||
token: generateIntakeToken(),
|
||||
token,
|
||||
active: true,
|
||||
expires_at: input.expiresAt ?? "",
|
||||
max_submissions_per_day: input.maxSubmissionsPerDay ?? 100,
|
||||
|
|
@ -66,7 +70,7 @@ export async function createIntakeLinkAction(input: {
|
|||
return {
|
||||
id: record.id,
|
||||
token: record.token,
|
||||
url: intakeUrlFor(record.token),
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -80,10 +84,11 @@ export async function rotateIntakeTokenAction(input: {
|
|||
const pb = await getAdminPb();
|
||||
|
||||
const token = generateIntakeToken();
|
||||
const url = await intakeUrlFor(token);
|
||||
await pb.collection("intake_links").update(input.linkId, { token });
|
||||
revalidatePath(adminPathForScope(input.scope));
|
||||
|
||||
return { token, url: intakeUrlFor(token) };
|
||||
return { token, url };
|
||||
}
|
||||
|
||||
export async function setIntakeLinkActiveAction(input: {
|
||||
|
|
@ -130,7 +135,7 @@ export async function deleteIntakeLinkAction(input: {
|
|||
revalidatePath(adminPathForScope(input.scope));
|
||||
}
|
||||
|
||||
function intakeUrlFor(token: string): string {
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
return `${base}/intake/${token}`;
|
||||
async function intakeUrlFor(token: string): Promise<string> {
|
||||
const requestOrigin = (await headers()).get("origin");
|
||||
return intakeUrl(token, requestOrigin, process.env.NEXT_PUBLIC_APP_URL, process.env.NODE_ENV === "production");
|
||||
}
|
||||
|
|
|
|||
59
src/app/api/public/intake/route.test.ts
Normal file
59
src/app/api/public/intake/route.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { POST } from "@/app/api/public/intake/route";
|
||||
import { getAdminPb } from "@/lib/pb/admin";
|
||||
import { resolveIntakeLink } from "@/lib/intake-links";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
|
||||
vi.mock("@/lib/pb/admin", () => ({ getAdminPb: vi.fn() }));
|
||||
vi.mock("@/lib/intake-links", () => ({
|
||||
resolveIntakeLink: vi.fn(),
|
||||
isLinkProjectUsable: vi.fn(),
|
||||
bumpDailyCapForProject: vi.fn(),
|
||||
bumpSubmissionCount: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/work-areas/server", () => ({ resolveWorkArea: vi.fn() }));
|
||||
|
||||
describe("public intake submission", () => {
|
||||
it("rejects a request with no area before creating a card", async () => {
|
||||
const createCard = vi.fn(async () => ({ id: "card-1", number: 1, title: "Request" }));
|
||||
const collections = {
|
||||
columns: { getFirstListItem: vi.fn(async () => ({ id: "inbox-1" })) },
|
||||
cards: {
|
||||
getFirstListItem: vi.fn(async () => { throw new Error("No cards"); }),
|
||||
getFullList: vi.fn(async () => []),
|
||||
create: createCard,
|
||||
},
|
||||
activity: { create: vi.fn(async () => ({})) },
|
||||
};
|
||||
vi.mocked(getAdminPb).mockResolvedValue({
|
||||
collection: (name: string) => collections[name as keyof typeof collections],
|
||||
} as never);
|
||||
vi.mocked(resolveIntakeLink).mockResolvedValue({
|
||||
id: "link-1",
|
||||
token: "a".repeat(32),
|
||||
active: true,
|
||||
expires_at: "",
|
||||
project: "project-1",
|
||||
client: "",
|
||||
} as never);
|
||||
vi.mocked(resolveWorkArea).mockResolvedValue("");
|
||||
|
||||
const body = {
|
||||
token: "a".repeat(32),
|
||||
brief: { title: "Request", summary: "Please help", subtasks: [], openQuestions: [] },
|
||||
transcript: [],
|
||||
};
|
||||
const submit = (payload: Record<string, unknown>) => POST(new Request("http://localhost/api/public/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}));
|
||||
const response = await submit(body);
|
||||
const malformed = await submit({ ...body, areaId: { id: "area-1" } });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(malformed.status).toBe(400);
|
||||
expect(createCard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||
import { cleanWorkAreaName } from "@/lib/work-areas/core";
|
||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +63,11 @@ export async function POST(request: Request): Promise<Response> {
|
|||
) {
|
||||
return jsonError("Not found", 404);
|
||||
}
|
||||
const areaId = typeof record.areaId === "string" ? record.areaId.trim() : "";
|
||||
const newAreaName = cleanWorkAreaName(record.newAreaName);
|
||||
if (!areaId && !newAreaName) {
|
||||
return jsonError("Choose an area", 400);
|
||||
}
|
||||
|
||||
const pb = await getAdminPb();
|
||||
|
||||
|
|
@ -111,8 +117,8 @@ export async function POST(request: Request): Promise<Response> {
|
|||
}
|
||||
|
||||
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
|
||||
areaId: record.areaId as string | undefined,
|
||||
newAreaName: record.newAreaName as string | undefined,
|
||||
areaId,
|
||||
newAreaName,
|
||||
}).catch(() => null);
|
||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
* board becomes unreadable on a client's mid-range laptop.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Structure: a tinted page with a raised panel floating on it. */
|
||||
--page: #eef2f5;
|
||||
--panel: #ffffff;
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--page: #051a2d;
|
||||
--panel: #0a2338;
|
||||
--surface: #122d44;
|
||||
|
|
@ -154,6 +156,7 @@ body {
|
|||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
color-scheme: dark;
|
||||
--page: #051a2d;
|
||||
--panel: #0a2338;
|
||||
--surface: #122d44;
|
||||
|
|
@ -164,23 +167,23 @@ body {
|
|||
|
||||
--text-primary: #f0f4f8;
|
||||
--text-secondary: #b8c8d8;
|
||||
--text-muted: #8aa1b5;
|
||||
--text-muted: #8499ad;
|
||||
|
||||
--brand: #10bbc3;
|
||||
--brand-hover: #4dd0d6;
|
||||
--brand-soft: #103e44;
|
||||
--brand-hover: #2dd0d8;
|
||||
--brand-soft: #0c3a45;
|
||||
|
||||
--accent: #fea24f;
|
||||
--accent-soft: #3a2410;
|
||||
--accent: #ff5782;
|
||||
--accent-soft: #4d1629;
|
||||
|
||||
--priority-critical: #fea24f;
|
||||
--priority-high: #f0a16d;
|
||||
--priority-critical: #ff5782;
|
||||
--priority-high: #ffb775;
|
||||
--priority-medium: #10bbc3;
|
||||
--priority-low: #8aa1b5;
|
||||
--priority-low: #8499ad;
|
||||
|
||||
--shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
--shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
0 16px 40px -12px rgba(0, 0, 0, 0.6);
|
||||
--shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
|
@ -204,4 +207,4 @@ body {
|
|||
:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,9 @@ import type { IntakeLinkRecord } from "@/lib/pb/types";
|
|||
* The two paths share the same UI; only the `scope` they forward to the
|
||||
* Server Actions differs.
|
||||
*
|
||||
* The URL is shown once on creation and on rotation. After that the owner
|
||||
* can only re-copy the same URL — losing the URL does not lose access,
|
||||
* because the admin page itself can read it. The token itself never leaves
|
||||
* this panel except via a clipboard write the owner explicitly triggers.
|
||||
* The URL is shown on creation and rotation. An existing link can also be
|
||||
* copied again using the current app origin, so links first created during
|
||||
* local development can be shared from the production admin page.
|
||||
*
|
||||
* Notes are persisted on save; the others act on click. The list shows
|
||||
* status, expiry, daily cap and the lifetime submission count, which is
|
||||
|
|
@ -166,7 +165,7 @@ export function IntakeLinksPanel({
|
|||
{newUrl && (
|
||||
<div className="space-y-1 rounded-lg border border-border-subtle bg-surface-raised p-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
New URL — copy it now, it won't be shown again in full:
|
||||
New URL — copy it to share with your client:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input readOnly value={newUrl} className={`${field} font-mono`} />
|
||||
|
|
@ -210,6 +209,15 @@ export function IntakeLinksPanel({
|
|||
<> · expires {link.expires_at.slice(0, 10)}</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(`${window.location.origin}/intake/${link.token}`)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2 py-1 text-xs text-text-secondary hover:bg-surface-raised"
|
||||
title="Copy a link for this app address"
|
||||
>
|
||||
<Copy size={12} strokeWidth={2} />
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
|
|
|
|||
|
|
@ -34,16 +34,19 @@ export function BriefPreviewPublic({
|
|||
token,
|
||||
projectId,
|
||||
areas,
|
||||
areaChoice,
|
||||
onAreaChange,
|
||||
}: {
|
||||
brief: Brief & { sizeBand: SizeBand };
|
||||
turns: Turn[];
|
||||
token: string;
|
||||
projectId: string;
|
||||
areas: WorkAreaRecord[];
|
||||
areaChoice: WorkAreaChoice;
|
||||
onAreaChange: (choice: WorkAreaChoice) => void;
|
||||
}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
||||
|
||||
async function submit() {
|
||||
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
|
||||
|
|
@ -53,28 +56,27 @@ export function BriefPreviewPublic({
|
|||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch("/api/public/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
projectId,
|
||||
brief,
|
||||
transcript: turns,
|
||||
sizeBand: brief.sizeBand,
|
||||
...areaChoice,
|
||||
}),
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const response = await fetch("/api/public/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
projectId,
|
||||
brief,
|
||||
transcript: turns,
|
||||
sizeBand: brief.sizeBand,
|
||||
...areaChoice,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("Submission failed");
|
||||
const { card } = await response.json();
|
||||
window.location.assign(`/intake/${token}/sent/${card.id}`);
|
||||
} catch {
|
||||
setError("Could not send that. Please try again.");
|
||||
return;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
const { card } = await response.json();
|
||||
window.location.assign(`/intake/${token}/sent/${card.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -126,7 +128,7 @@ export function BriefPreviewPublic({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={sending} />
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={onAreaChange} disabled={sending} />
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useState, useTransition } from "react";
|
|||
import { Sparkles, ArrowRight } from "lucide-react";
|
||||
|
||||
import { BriefPreviewPublic } from "@/components/intake/brief-preview-public";
|
||||
import { WorkAreaPicker } from "@/components/work-areas/work-area-picker";
|
||||
import { cleanWorkAreaName, type WorkAreaChoice } from "@/lib/work-areas/core";
|
||||
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
|
||||
import type { InterviewStep } from "@/lib/intake/interview";
|
||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||
|
|
@ -11,11 +13,9 @@ import type { WorkAreaRecord } from "@/lib/pb/types";
|
|||
/**
|
||||
* The public route's interview flow.
|
||||
*
|
||||
* Mirrors IntakeWizard's shape (a "Start" button, then a question-and-answer
|
||||
* loop, then a brief), but calls the public Server Action and renders the
|
||||
* public BriefPreview. There is no fallback to the plain request box — the
|
||||
* spec deliberately does not offer one on the public route, so an
|
||||
* unavailable assistant shows a static message instead.
|
||||
* The visitor chooses an area before the interview. If the writing assistant
|
||||
* is unavailable, they can still enter a title and details and submit the
|
||||
* same public brief without signing in.
|
||||
*/
|
||||
export function IntakePublic({
|
||||
projectId,
|
||||
|
|
@ -32,7 +32,11 @@ export function IntakePublic({
|
|||
const [question, setQuestion] = useState<string | null>(null);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [step, setStep] = useState<InterviewStep | null>(null);
|
||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
||||
const [manualTitle, setManualTitle] = useState("");
|
||||
const [manualDetails, setManualDetails] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const areaSelected = Boolean(areaChoice.areaId || cleanWorkAreaName(areaChoice.newAreaName));
|
||||
|
||||
async function callAction(nextTurns: Turn[], finishNow: boolean): Promise<InterviewStep> {
|
||||
const { intakeStepPublicAction } = await import("@/app/actions/intake-public");
|
||||
|
|
@ -46,12 +50,20 @@ export function IntakePublic({
|
|||
|
||||
function advance(nextTurns: Turn[], finishNow: boolean) {
|
||||
startTransition(async () => {
|
||||
const result = await callAction(nextTurns, finishNow);
|
||||
setStep(result);
|
||||
try {
|
||||
const result = await callAction(nextTurns, finishNow);
|
||||
setStep(result);
|
||||
|
||||
if (result.kind === "question") {
|
||||
setQuestion(result.question ?? null);
|
||||
setAnswer("");
|
||||
if (result.kind === "question") {
|
||||
setQuestion(result.question ?? null);
|
||||
setAnswer("");
|
||||
}
|
||||
if (result.kind === "unavailable") {
|
||||
setManualDetails(nextTurns.map((turn) => turn.answer).join("\n\n").slice(0, 5000));
|
||||
}
|
||||
} catch {
|
||||
setManualDetails(nextTurns.map((turn) => turn.answer).join("\n\n").slice(0, 5000));
|
||||
setStep({ kind: "unavailable" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -64,35 +76,64 @@ export function IntakePublic({
|
|||
token={token}
|
||||
projectId={projectId}
|
||||
areas={areas}
|
||||
areaChoice={areaChoice}
|
||||
onAreaChange={setAreaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step?.kind === "unavailable") {
|
||||
return (
|
||||
<section className="space-y-2 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
|
||||
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{step.message ?? "The assistant is unavailable right now."}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
The developer is notified of every submission. Please try again
|
||||
later, or contact them directly.
|
||||
The writing assistant is unavailable. You can still send your request.
|
||||
</p>
|
||||
<label className="block space-y-1 text-xs font-medium text-text-secondary">
|
||||
<span>What do you need?</span>
|
||||
<input value={manualTitle} onChange={(event) => setManualTitle(event.target.value)} maxLength={200} className="w-full rounded-lg border border-border-subtle bg-panel p-2.5 text-sm text-text-primary" />
|
||||
</label>
|
||||
<label className="block space-y-1 text-xs font-medium text-text-secondary">
|
||||
<span>Details</span>
|
||||
<textarea value={manualDetails} onChange={(event) => setManualDetails(event.target.value)} maxLength={5000} rows={5} className="w-full rounded-lg border border-border-subtle bg-panel p-2.5 text-sm text-text-primary" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manualTitle.trim() || !manualDetails.trim()}
|
||||
onClick={() => setStep({
|
||||
kind: "brief",
|
||||
brief: {
|
||||
title: manualTitle.trim(),
|
||||
summary: manualDetails.trim(),
|
||||
subtasks: [],
|
||||
openQuestions: [],
|
||||
area: "",
|
||||
degraded: false,
|
||||
sizeBand: "small",
|
||||
},
|
||||
})}
|
||||
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg disabled:opacity-50"
|
||||
>
|
||||
Review request
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => advance([], false)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={2} />
|
||||
{pending ? "Starting…" : `Tell ${projectName} what's not working`}
|
||||
</button>
|
||||
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
|
||||
<p className="text-sm text-text-secondary">Choose what part of {projectName} this request belongs to.</p>
|
||||
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={pending} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || !areaSelected}
|
||||
onClick={() => advance([], false)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={2} />
|
||||
{pending ? "Starting…" : "Describe your request"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +165,7 @@ export function IntakePublic({
|
|||
setTurns(next);
|
||||
advance(next, false);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-text-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
||||
>
|
||||
<ArrowRight size={14} strokeWidth={2} />
|
||||
{pending ? "…" : "Next"}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { applyThemePreference } from "@/lib/theme/preference";
|
||||
|
||||
/**
|
||||
* Applies the user's stored theme preference as soon as React mounts.
|
||||
|
|
@ -17,19 +18,17 @@ import { useEffect } from "react";
|
|||
*/
|
||||
export function ThemeSync() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored !== "light" && stored !== "dark") return;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const dark = stored === "dark" || (stored !== "light" && prefersDark);
|
||||
// We only get here if `stored` is "light" or "dark"; an explicit user
|
||||
// choice always wins over the OS preference.
|
||||
document.documentElement.classList.toggle("dark", stored === "dark");
|
||||
document.documentElement.style.colorScheme = stored === "dark" ? "dark" : "light";
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, etc.) — OS preference wins.
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const sync = () => {
|
||||
let stored: string | null = null;
|
||||
try { stored = localStorage.getItem("theme"); } catch { /* OS preference wins. */ }
|
||||
const choice = stored === "light" || stored === "dark" ? stored : "system";
|
||||
applyThemePreference(document.documentElement, choice, media.matches);
|
||||
};
|
||||
sync();
|
||||
media.addEventListener("change", sync);
|
||||
return () => media.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyThemePreference, type ThemePreference } from "@/lib/theme/preference";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
function apply(theme: Theme) {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const dark = theme === "dark" || (theme === "system" && prefersDark);
|
||||
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
|
||||
if (theme === "system") localStorage.removeItem("theme");
|
||||
else localStorage.setItem("theme", theme);
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{ value: Theme; label: string }> = [
|
||||
const OPTIONS: Array<{ value: ThemePreference; label: string }> = [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "system", label: "Auto" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [theme, setTheme] = useState<ThemePreference>("system");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") setTheme(stored);
|
||||
}, []);
|
||||
|
||||
function choose(next: Theme) {
|
||||
function choose(next: ThemePreference) {
|
||||
setTheme(next);
|
||||
apply(next);
|
||||
applyThemePreference(document.documentElement, next, window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (next === "system") localStorage.removeItem("theme");
|
||||
else localStorage.setItem("theme", next);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -57,4 +47,4 @@ export function ThemeToggle() {
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/lib/intake-links/url.test.ts
Normal file
20
src/lib/intake-links/url.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { intakeUrl } from "@/lib/intake-links/url";
|
||||
|
||||
describe("public intake URL", () => {
|
||||
it("uses the current app origin even when local development configuration says localhost", () => {
|
||||
expect(intakeUrl("abc123", "https://board.example.com", "http://localhost:3000", true))
|
||||
.toBe("https://board.example.com/intake/abc123");
|
||||
});
|
||||
|
||||
it("uses a configured URL when there is no request origin", () => {
|
||||
expect(intakeUrl("abc123", null, "https://board.example.com/", true))
|
||||
.toBe("https://board.example.com/intake/abc123");
|
||||
});
|
||||
|
||||
it("refuses a localhost URL in production instead of generating an unusable client link", () => {
|
||||
expect(() => intakeUrl("abc123", null, "http://localhost:3000", true))
|
||||
.toThrow("Public app URL is not configured");
|
||||
});
|
||||
});
|
||||
19
src/lib/intake-links/url.ts
Normal file
19
src/lib/intake-links/url.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function intakeUrl(
|
||||
token: string,
|
||||
requestOrigin: string | null,
|
||||
configuredBase: string | undefined,
|
||||
isProduction: boolean
|
||||
): string {
|
||||
const candidate = requestOrigin || configuredBase;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(candidate || "");
|
||||
} catch {
|
||||
throw new Error("Public app URL is not configured");
|
||||
}
|
||||
if (!["http:", "https:"].includes(base.protocol) ||
|
||||
(isProduction && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) {
|
||||
throw new Error("Public app URL is not configured");
|
||||
}
|
||||
return new URL(`/intake/${encodeURIComponent(token)}`, base).toString();
|
||||
}
|
||||
34
src/lib/theme/preference.test.ts
Normal file
34
src/lib/theme/preference.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyThemePreference } from "@/lib/theme/preference";
|
||||
|
||||
describe("theme preference", () => {
|
||||
it("forces Light over a dark system preference and clears the override for Auto", () => {
|
||||
const classes = new Set<string>();
|
||||
const root = {
|
||||
classList: {
|
||||
toggle(name: string, force: boolean) {
|
||||
if (force) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
style: { colorScheme: "" },
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
applyThemePreference(root, "light");
|
||||
expect([...classes]).toEqual(["light"]);
|
||||
expect(root.style.colorScheme).toBe("light");
|
||||
|
||||
applyThemePreference(root, "dark");
|
||||
expect([...classes]).toEqual(["dark"]);
|
||||
expect(root.style.colorScheme).toBe("dark");
|
||||
|
||||
applyThemePreference(root, "system", true);
|
||||
expect([...classes]).toEqual(["dark"]);
|
||||
expect(root.style.colorScheme).toBe("");
|
||||
|
||||
applyThemePreference(root, "system", false);
|
||||
expect([...classes]).toEqual([]);
|
||||
expect(root.style.colorScheme).toBe("");
|
||||
});
|
||||
});
|
||||
7
src/lib/theme/preference.ts
Normal file
7
src/lib/theme/preference.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
export function applyThemePreference(root: HTMLElement, theme: ThemePreference, systemDark = false): void {
|
||||
root.classList.toggle("light", theme === "light");
|
||||
root.classList.toggle("dark", theme === "dark" || (theme === "system" && systemDark));
|
||||
root.style.colorScheme = theme === "system" ? "" : theme;
|
||||
}
|
||||
Loading…
Reference in a new issue