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:
Dave 2026-09-22 22:24:14 +02:00
parent d9f6ac0fad
commit b52d4a6287
15 changed files with 301 additions and 103 deletions

View file

@ -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

View file

@ -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. |

View file

@ -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");
}

View 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();
});
});

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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&apos;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}

View file

@ -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

View file

@ -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"}

View file

@ -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;
}
}

View file

@ -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>
);
}
}

View 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");
});
});

View 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();
}

View 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("");
});
});

View 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;
}