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.
|
# to it directly — so the URL itself is not a secret.
|
||||||
NEXT_PUBLIC_PB_URL=https://pocketbase.example.com
|
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,
|
# Superuser credentials, used server-side only by scripts/setup-remote.ts,
|
||||||
# scripts/seed-demo.ts, and the /api/client/* route handlers.
|
# scripts/seed-demo.ts, and the /api/client/* route handlers.
|
||||||
PB_SUPERUSER_EMAIL=you@example.com
|
PB_SUPERUSER_EMAIL=you@example.com
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ Fill in `.env.local`:
|
||||||
| Variable | Notes |
|
| Variable | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `NEXT_PUBLIC_PB_URL` | The Coolify PocketBase URL. Public by design — the browser connects to it directly. |
|
| `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_EMAIL` | Server-side only. Never exposed to the browser. |
|
||||||
| `PB_SUPERUSER_PASSWORD` | Server-side only. Never commit this. |
|
| `PB_SUPERUSER_PASSWORD` | Server-side only. Never commit this. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
import { getAdminPb } from "@/lib/pb/admin";
|
import { getAdminPb } from "@/lib/pb/admin";
|
||||||
import { requireSession } from "@/lib/auth/session";
|
import { requireSession } from "@/lib/auth/session";
|
||||||
import { generateIntakeToken } from "@/lib/intake/token";
|
import { generateIntakeToken } from "@/lib/intake/token";
|
||||||
|
import { intakeUrl } from "@/lib/intake-links/url";
|
||||||
import type { IntakeLinkRecord } from "@/lib/pb/types";
|
import type { IntakeLinkRecord } from "@/lib/pb/types";
|
||||||
|
|
||||||
/** Owner-only entry point. See src/app/actions/log-work.ts for the pattern. */
|
/** 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);
|
const label = input.label.trim().slice(0, 80);
|
||||||
if (!label) throw new Error("A label is required");
|
if (!label) throw new Error("A label is required");
|
||||||
|
const token = generateIntakeToken();
|
||||||
|
const url = await intakeUrlFor(token);
|
||||||
|
|
||||||
const createFields: Record<string, unknown> = {
|
const createFields: Record<string, unknown> = {
|
||||||
label,
|
label,
|
||||||
token: generateIntakeToken(),
|
token,
|
||||||
active: true,
|
active: true,
|
||||||
expires_at: input.expiresAt ?? "",
|
expires_at: input.expiresAt ?? "",
|
||||||
max_submissions_per_day: input.maxSubmissionsPerDay ?? 100,
|
max_submissions_per_day: input.maxSubmissionsPerDay ?? 100,
|
||||||
|
|
@ -66,7 +70,7 @@ export async function createIntakeLinkAction(input: {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
token: record.token,
|
token: record.token,
|
||||||
url: intakeUrlFor(record.token),
|
url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,10 +84,11 @@ export async function rotateIntakeTokenAction(input: {
|
||||||
const pb = await getAdminPb();
|
const pb = await getAdminPb();
|
||||||
|
|
||||||
const token = generateIntakeToken();
|
const token = generateIntakeToken();
|
||||||
|
const url = await intakeUrlFor(token);
|
||||||
await pb.collection("intake_links").update(input.linkId, { token });
|
await pb.collection("intake_links").update(input.linkId, { token });
|
||||||
revalidatePath(adminPathForScope(input.scope));
|
revalidatePath(adminPathForScope(input.scope));
|
||||||
|
|
||||||
return { token, url: intakeUrlFor(token) };
|
return { token, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setIntakeLinkActiveAction(input: {
|
export async function setIntakeLinkActiveAction(input: {
|
||||||
|
|
@ -130,7 +135,7 @@ export async function deleteIntakeLinkAction(input: {
|
||||||
revalidatePath(adminPathForScope(input.scope));
|
revalidatePath(adminPathForScope(input.scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
function intakeUrlFor(token: string): string {
|
async function intakeUrlFor(token: string): Promise<string> {
|
||||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
const requestOrigin = (await headers()).get("origin");
|
||||||
return `${base}/intake/${token}`;
|
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 { resolveProjectForClient } from "@/lib/intake-links/client-projects";
|
||||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||||
import { resolveWorkArea } from "@/lib/work-areas/server";
|
import { resolveWorkArea } from "@/lib/work-areas/server";
|
||||||
|
import { cleanWorkAreaName } from "@/lib/work-areas/core";
|
||||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
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);
|
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();
|
const pb = await getAdminPb();
|
||||||
|
|
||||||
|
|
@ -111,8 +117,8 @@ export async function POST(request: Request): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
|
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
|
||||||
areaId: record.areaId as string | undefined,
|
areaId,
|
||||||
newAreaName: record.newAreaName as string | undefined,
|
newAreaName,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
if (workArea === null) return jsonError("Area not found in this project", 400);
|
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.
|
* board becomes unreadable on a client's mid-range laptop.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
/* Structure: a tinted page with a raised panel floating on it. */
|
/* Structure: a tinted page with a raised panel floating on it. */
|
||||||
--page: #eef2f5;
|
--page: #eef2f5;
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--page: #051a2d;
|
--page: #051a2d;
|
||||||
--panel: #0a2338;
|
--panel: #0a2338;
|
||||||
--surface: #122d44;
|
--surface: #122d44;
|
||||||
|
|
@ -154,6 +156,7 @@ body {
|
||||||
*/
|
*/
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not(.light) {
|
:root:not(.light) {
|
||||||
|
color-scheme: dark;
|
||||||
--page: #051a2d;
|
--page: #051a2d;
|
||||||
--panel: #0a2338;
|
--panel: #0a2338;
|
||||||
--surface: #122d44;
|
--surface: #122d44;
|
||||||
|
|
@ -164,23 +167,23 @@ body {
|
||||||
|
|
||||||
--text-primary: #f0f4f8;
|
--text-primary: #f0f4f8;
|
||||||
--text-secondary: #b8c8d8;
|
--text-secondary: #b8c8d8;
|
||||||
--text-muted: #8aa1b5;
|
--text-muted: #8499ad;
|
||||||
|
|
||||||
--brand: #10bbc3;
|
--brand: #10bbc3;
|
||||||
--brand-hover: #4dd0d6;
|
--brand-hover: #2dd0d8;
|
||||||
--brand-soft: #103e44;
|
--brand-soft: #0c3a45;
|
||||||
|
|
||||||
--accent: #fea24f;
|
--accent: #ff5782;
|
||||||
--accent-soft: #3a2410;
|
--accent-soft: #4d1629;
|
||||||
|
|
||||||
--priority-critical: #fea24f;
|
--priority-critical: #ff5782;
|
||||||
--priority-high: #f0a16d;
|
--priority-high: #ffb775;
|
||||||
--priority-medium: #10bbc3;
|
--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);
|
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);
|
--shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,4 +207,4 @@ body {
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--brand);
|
outline: 2px solid var(--brand);
|
||||||
outline-offset: 2px;
|
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
|
* The two paths share the same UI; only the `scope` they forward to the
|
||||||
* Server Actions differs.
|
* Server Actions differs.
|
||||||
*
|
*
|
||||||
* The URL is shown once on creation and on rotation. After that the owner
|
* The URL is shown on creation and rotation. An existing link can also be
|
||||||
* can only re-copy the same URL — losing the URL does not lose access,
|
* copied again using the current app origin, so links first created during
|
||||||
* because the admin page itself can read it. The token itself never leaves
|
* local development can be shared from the production admin page.
|
||||||
* this panel except via a clipboard write the owner explicitly triggers.
|
|
||||||
*
|
*
|
||||||
* Notes are persisted on save; the others act on click. The list shows
|
* Notes are persisted on save; the others act on click. The list shows
|
||||||
* status, expiry, daily cap and the lifetime submission count, which is
|
* status, expiry, daily cap and the lifetime submission count, which is
|
||||||
|
|
@ -166,7 +165,7 @@ export function IntakeLinksPanel({
|
||||||
{newUrl && (
|
{newUrl && (
|
||||||
<div className="space-y-1 rounded-lg border border-border-subtle bg-surface-raised p-3">
|
<div className="space-y-1 rounded-lg border border-border-subtle bg-surface-raised p-3">
|
||||||
<p className="text-xs text-text-muted">
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input readOnly value={newUrl} className={`${field} font-mono`} />
|
<input readOnly value={newUrl} className={`${field} font-mono`} />
|
||||||
|
|
@ -210,6 +209,15 @@ export function IntakeLinksPanel({
|
||||||
<> · expires {link.expires_at.slice(0, 10)}</>
|
<> · expires {link.expires_at.slice(0, 10)}</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,19 @@ export function BriefPreviewPublic({
|
||||||
token,
|
token,
|
||||||
projectId,
|
projectId,
|
||||||
areas,
|
areas,
|
||||||
|
areaChoice,
|
||||||
|
onAreaChange,
|
||||||
}: {
|
}: {
|
||||||
brief: Brief & { sizeBand: SizeBand };
|
brief: Brief & { sizeBand: SizeBand };
|
||||||
turns: Turn[];
|
turns: Turn[];
|
||||||
token: string;
|
token: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
areas: WorkAreaRecord[];
|
areas: WorkAreaRecord[];
|
||||||
|
areaChoice: WorkAreaChoice;
|
||||||
|
onAreaChange: (choice: WorkAreaChoice) => void;
|
||||||
}) {
|
}) {
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [areaChoice, setAreaChoice] = useState<WorkAreaChoice>({});
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
|
if (!areaChoice.areaId && !cleanWorkAreaName(areaChoice.newAreaName)) {
|
||||||
|
|
@ -53,28 +56,27 @@ export function BriefPreviewPublic({
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch("/api/public/intake", {
|
try {
|
||||||
method: "POST",
|
const response = await fetch("/api/public/intake", {
|
||||||
headers: { "content-type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({
|
headers: { "content-type": "application/json" },
|
||||||
token,
|
body: JSON.stringify({
|
||||||
projectId,
|
token,
|
||||||
brief,
|
projectId,
|
||||||
transcript: turns,
|
brief,
|
||||||
sizeBand: brief.sizeBand,
|
transcript: turns,
|
||||||
...areaChoice,
|
sizeBand: brief.sizeBand,
|
||||||
}),
|
...areaChoice,
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
setSending(false);
|
if (!response.ok) throw new Error("Submission failed");
|
||||||
|
const { card } = await response.json();
|
||||||
if (!response.ok) {
|
window.location.assign(`/intake/${token}/sent/${card.id}`);
|
||||||
|
} catch {
|
||||||
setError("Could not send that. Please try again.");
|
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 (
|
return (
|
||||||
|
|
@ -126,7 +128,7 @@ export function BriefPreviewPublic({
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useState, useTransition } from "react";
|
||||||
import { Sparkles, ArrowRight } from "lucide-react";
|
import { Sparkles, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import { BriefPreviewPublic } from "@/components/intake/brief-preview-public";
|
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 { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
|
||||||
import type { InterviewStep } from "@/lib/intake/interview";
|
import type { InterviewStep } from "@/lib/intake/interview";
|
||||||
import type { WorkAreaRecord } from "@/lib/pb/types";
|
import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||||
|
|
@ -11,11 +13,9 @@ import type { WorkAreaRecord } from "@/lib/pb/types";
|
||||||
/**
|
/**
|
||||||
* The public route's interview flow.
|
* The public route's interview flow.
|
||||||
*
|
*
|
||||||
* Mirrors IntakeWizard's shape (a "Start" button, then a question-and-answer
|
* The visitor chooses an area before the interview. If the writing assistant
|
||||||
* loop, then a brief), but calls the public Server Action and renders the
|
* is unavailable, they can still enter a title and details and submit the
|
||||||
* public BriefPreview. There is no fallback to the plain request box — the
|
* same public brief without signing in.
|
||||||
* spec deliberately does not offer one on the public route, so an
|
|
||||||
* unavailable assistant shows a static message instead.
|
|
||||||
*/
|
*/
|
||||||
export function IntakePublic({
|
export function IntakePublic({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -32,7 +32,11 @@ export function IntakePublic({
|
||||||
const [question, setQuestion] = useState<string | null>(null);
|
const [question, setQuestion] = useState<string | null>(null);
|
||||||
const [answer, setAnswer] = useState("");
|
const [answer, setAnswer] = useState("");
|
||||||
const [step, setStep] = useState<InterviewStep | null>(null);
|
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 [pending, startTransition] = useTransition();
|
||||||
|
const areaSelected = Boolean(areaChoice.areaId || cleanWorkAreaName(areaChoice.newAreaName));
|
||||||
|
|
||||||
async function callAction(nextTurns: Turn[], finishNow: boolean): Promise<InterviewStep> {
|
async function callAction(nextTurns: Turn[], finishNow: boolean): Promise<InterviewStep> {
|
||||||
const { intakeStepPublicAction } = await import("@/app/actions/intake-public");
|
const { intakeStepPublicAction } = await import("@/app/actions/intake-public");
|
||||||
|
|
@ -46,12 +50,20 @@ export function IntakePublic({
|
||||||
|
|
||||||
function advance(nextTurns: Turn[], finishNow: boolean) {
|
function advance(nextTurns: Turn[], finishNow: boolean) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await callAction(nextTurns, finishNow);
|
try {
|
||||||
setStep(result);
|
const result = await callAction(nextTurns, finishNow);
|
||||||
|
setStep(result);
|
||||||
|
|
||||||
if (result.kind === "question") {
|
if (result.kind === "question") {
|
||||||
setQuestion(result.question ?? null);
|
setQuestion(result.question ?? null);
|
||||||
setAnswer("");
|
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}
|
token={token}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
|
areaChoice={areaChoice}
|
||||||
|
onAreaChange={setAreaChoice}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step?.kind === "unavailable") {
|
if (step?.kind === "unavailable") {
|
||||||
return (
|
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">
|
<p className="text-sm text-text-secondary">
|
||||||
{step.message ?? "The assistant is unavailable right now."}
|
The writing assistant is unavailable. You can still send your request.
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-text-muted">
|
|
||||||
The developer is notified of every submission. Please try again
|
|
||||||
later, or contact them directly.
|
|
||||||
</p>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
return (
|
return (
|
||||||
<button
|
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
|
||||||
type="button"
|
<p className="text-sm text-text-secondary">Choose what part of {projectName} this request belongs to.</p>
|
||||||
disabled={pending}
|
<WorkAreaPicker areas={areas} value={areaChoice} onChange={setAreaChoice} disabled={pending} />
|
||||||
onClick={() => advance([], false)}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
disabled={pending || !areaSelected}
|
||||||
<Sparkles size={14} strokeWidth={2} />
|
onClick={() => advance([], false)}
|
||||||
{pending ? "Starting…" : `Tell ${projectName} what's not working`}
|
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"
|
||||||
</button>
|
>
|
||||||
|
<Sparkles size={14} strokeWidth={2} />
|
||||||
|
{pending ? "Starting…" : "Describe your request"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +165,7 @@ export function IntakePublic({
|
||||||
setTurns(next);
|
setTurns(next);
|
||||||
advance(next, false);
|
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} />
|
<ArrowRight size={14} strokeWidth={2} />
|
||||||
{pending ? "…" : "Next"}
|
{pending ? "…" : "Next"}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { applyThemePreference } from "@/lib/theme/preference";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the user's stored theme preference as soon as React mounts.
|
* Applies the user's stored theme preference as soon as React mounts.
|
||||||
|
|
@ -17,19 +18,17 @@ import { useEffect } from "react";
|
||||||
*/
|
*/
|
||||||
export function ThemeSync() {
|
export function ThemeSync() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const stored = localStorage.getItem("theme");
|
const sync = () => {
|
||||||
if (stored !== "light" && stored !== "dark") return;
|
let stored: string | null = null;
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
try { stored = localStorage.getItem("theme"); } catch { /* OS preference wins. */ }
|
||||||
const dark = stored === "dark" || (stored !== "light" && prefersDark);
|
const choice = stored === "light" || stored === "dark" ? stored : "system";
|
||||||
// We only get here if `stored` is "light" or "dark"; an explicit user
|
applyThemePreference(document.documentElement, choice, media.matches);
|
||||||
// choice always wins over the OS preference.
|
};
|
||||||
document.documentElement.classList.toggle("dark", stored === "dark");
|
sync();
|
||||||
document.documentElement.style.colorScheme = stored === "dark" ? "dark" : "light";
|
media.addEventListener("change", sync);
|
||||||
} catch {
|
return () => media.removeEventListener("change", sync);
|
||||||
// localStorage unavailable (private mode, etc.) — OS preference wins.
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,27 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { applyThemePreference, type ThemePreference } from "@/lib/theme/preference";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
const OPTIONS: Array<{ value: ThemePreference; label: string }> = [
|
||||||
|
|
||||||
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 }> = [
|
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "system", label: "Auto" },
|
{ value: "system", label: "Auto" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [theme, setTheme] = useState<Theme>("system");
|
const [theme, setTheme] = useState<ThemePreference>("system");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem("theme");
|
const stored = localStorage.getItem("theme");
|
||||||
if (stored === "light" || stored === "dark") setTheme(stored);
|
if (stored === "light" || stored === "dark") setTheme(stored);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function choose(next: Theme) {
|
function choose(next: ThemePreference) {
|
||||||
setTheme(next);
|
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 (
|
return (
|
||||||
|
|
@ -57,4 +47,4 @@ export function ThemeToggle() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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