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
80 changes: 60 additions & 20 deletions app/budgets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import { useEffect, useState } from "react";
import { useDemo } from "@/src/components/demo-context";
import { money } from "@/src/lib/demo-data";
import { currencySymbol, monthLabel } from "@/src/format";
import { money } from "@/src/lib/demo-data";

export default function BudgetsPage() {
const { activeSpace, categories, currency, source, month, saveBudgets } = useDemo();
Expand All @@ -12,25 +12,36 @@ export default function BudgetsPage() {
const [error, setError] = useState("");

useEffect(() => {
setLimits(Object.fromEntries(categories.map((category) => [category.key, category.budget ? String(category.budget) : ""])));
setLimits(
Object.fromEntries(
categories.map((category) => [category.key, category.budget ? String(category.budget) : ""]),
),
);
}, [categories]);

return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<p className="text-sm text-quiet">{activeSpace} · {monthLabel(month)}</p>
<p className="text-sm text-quiet">
{activeSpace} · {monthLabel(month)}
</p>
<h1 className="mt-1 text-3xl font-bold tracking-tight text-ink sm:text-4xl">Monthly budgets</h1>
<p className="mt-1 max-w-xl text-quiet">Set a spending limit for each category in {activeSpace}.</p>
</div>

<form onSubmit={(event) => {
event.preventDefault();
setError("");
const budgets = Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, Number(value) || 0]));
void saveBudgets(budgets)
.then(() => setSaved(true))
.catch((cause: Error) => setError(cause.message));
}} className="rounded-2xl border border-line bg-card p-5 sm:p-7">
<form
onSubmit={(event) => {
event.preventDefault();
setError("");
const budgets = Object.fromEntries(
Object.entries(limits).map(([key, value]) => [key, Number(value) || 0]),
);
void saveBudgets(budgets)
.then(() => setSaved(true))
.catch((cause: Error) => setError(cause.message));
}}
className="rounded-2xl border border-line bg-card p-5 sm:p-7"
>
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
<div>
<h2 className="font-bold text-ink">Category limits</h2>
Expand All @@ -43,40 +54,69 @@ export default function BudgetsPage() {
const limit = Number(limits[category.key] || 0);
const spentPercent = limit ? Math.round((category.amount / limit) * 100) : 0;
return (
<div key={category.name} className="grid grid-cols-[minmax(0,1fr)_minmax(110px,150px)] items-center gap-3 py-3.5 sm:gap-5">
<div
key={category.name}
className="grid grid-cols-[minmax(0,1fr)_minmax(110px,150px)] items-center gap-3 py-3.5 sm:gap-5"
>
<label htmlFor={`budget-${category.key}`} className="flex min-w-0 items-center gap-3">
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: category.color }} />
<span className="truncate font-medium text-ink">{category.name}</span>
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-quiet">{currencySymbol(currency)}</span>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-quiet">
{currencySymbol(currency)}
</span>
<input
id={`budget-${category.key}`}
inputMode="numeric"
type="number"
min="0"
step="100"
value={limits[category.key] ?? ""}
onChange={(event) => { setSaved(false); setLimits({ ...limits, [category.key]: event.target.value }); }}
onChange={(event) => {
setSaved(false);
setLimits({ ...limits, [category.key]: event.target.value });
}}
className="w-full rounded-lg border border-line bg-paper py-2 pl-8 pr-3 text-right tabular-nums outline-none focus:border-ledger"
/>
</div>
<div className="col-span-2 -mt-1 sm:col-span-2 sm:pl-6">
<div className="flex justify-between text-xs text-quiet">
<span>Spent {money(category.amount, currency)}</span>
<span className={spentPercent >= 80 ? "font-semibold text-amber" : ""}>{limit ? `${spentPercent}% used` : "No limit"}</span>
<span className={spentPercent >= 80 ? "font-semibold text-amber" : ""}>
{limit ? `${spentPercent}% used` : "No limit"}
</span>
</div>
{limit ? <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line">
<div className={`h-full rounded-full ${spentPercent >= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`} style={{ width: `${Math.min(spentPercent, 100)}%` }} />
</div> : null}
{limit ? (
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line">
<div
className={`h-full rounded-full ${spentPercent >= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`}
style={{ width: `${Math.min(spentPercent, 100)}%` }}
/>
</div>
) : null}
</div>
</div>
);
})}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-5">
<p role="status" className={`text-sm ${error ? "text-stamp" : "text-ledger"}`}>{error || (saved ? (source === "connected" ? "Budgets saved." : "Budgets saved in this preview.") : source === "connected" ? "Changes will update this space." : "Changes are local to this preview.")}</p>
<button type="submit" className="rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]">Save budgets</button>
<p role="status" className={`text-sm ${error ? "text-stamp" : "text-ledger"}`}>
{error ||
(saved
? source === "connected"
? "Budgets saved."
: "Budgets saved in this preview."
: source === "connected"
? "Changes will update this space."
: "Changes are local to this preview.")}
</p>
<button
type="submit"
className="rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]"
>
Save budgets
</button>
</div>
</form>
</div>
Expand Down
132 changes: 99 additions & 33 deletions app/connect/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
"use client";

import { useEffect, useState, type FormEvent } from "react";
import { api, DEMO_ACCOUNT_EMAIL, isDemoSession, oidcConfig, saveTokens, session, startDemoSession } from "@/src/api/client";
import type { ClientConfig } from "@/src/api/types";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import {
api,
DEMO_ACCOUNT_EMAIL,
isDemoSession,
oidcConfig,
saveTokens,
session,
startDemoSession,
} from "@/src/api/client";
import type { DevToken } from "@/src/api/generated";
import type { ClientConfig } from "@/src/api/types";
import * as oidc from "@/src/oidc";

export default function ConnectPage() {
Expand All @@ -12,27 +20,29 @@ export default function ConnectPage() {
const [error, setError] = useState("");
const [ready, setReady] = useState(false);

const destination = () => {
const destination = useCallback(() => {
const requested = new URLSearchParams(window.location.search).get("returnTo");
return requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/";
};
}, []);

useEffect(() => {
void api<ClientConfig>("/v1/config").then(async (clientConfig) => {
session.config = clientConfig;
setConfig(clientConfig);
const oidcSettings = oidcConfig();
if (oidcSettings) {
const tokens = await oidc.finish(oidcSettings);
if (tokens) saveTokens(tokens);
}
if (session.token || isDemoSession()) window.location.assign(destination());
setReady(true);
}).catch(() => {
setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`);
setReady(true);
});
}, []);
void api<ClientConfig>("/v1/config")
.then(async (clientConfig) => {
session.config = clientConfig;
setConfig(clientConfig);
const oidcSettings = oidcConfig();
if (oidcSettings) {
const tokens = await oidc.finish(oidcSettings);
if (tokens) saveTokens(tokens);
}
if (session.token || isDemoSession()) window.location.assign(destination());
setReady(true);
})
.catch(() => {
setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`);
setReady(true);
});
}, [destination]);

const signInDev = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand All @@ -52,7 +62,7 @@ export default function ConnectPage() {
body: JSON.stringify({ email: email.trim() }),
});
if (!response.ok) throw new Error("Couldn't sign in. Check the email address.");
const token = await response.json() as DevToken;
const token = (await response.json()) as DevToken;
saveTokens({ access_token: token.access_token, expires_in: 12 * 3600 });
window.location.assign(destination());
} catch (cause) {
Expand All @@ -62,15 +72,23 @@ export default function ConnectPage() {

const login = (prompt?: string) => {
const settings = oidcConfig();
if (settings) void oidc.login(settings, { prompt }).catch(() => setError("Couldn't reach the sign-in page. Try again."));
if (settings)
void oidc
.login(settings, { prompt })
.catch(() => setError("Couldn't reach the sign-in page. Try again."));
};

return (
<main className="relative grid min-h-screen place-items-center overflow-hidden px-4 py-10">
<div aria-hidden="true" className="pointer-events-none absolute -top-36 left-1/2 h-80 w-[34rem] -translate-x-1/2 rounded-full bg-ledger/10 blur-3xl" />
<div
aria-hidden="true"
className="pointer-events-none absolute -top-36 left-1/2 h-80 w-[34rem] -translate-x-1/2 rounded-full bg-ledger/10 blur-3xl"
/>
<section className="relative w-full max-w-md overflow-hidden rounded-2xl border border-line bg-card p-6 shadow-[0_28px_90px_rgba(0,0,0,0.4)] sm:p-8">
<div className="flex items-center gap-3">
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--brand-tile)] text-lg font-bold text-[var(--brand-ink)]">S</span>
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--brand-tile)] text-lg font-bold text-[var(--brand-ink)]">
S
</span>
<div>
<p className="font-semibold tracking-tight text-ink">ScribbleScan</p>
<p className="text-xs text-quiet">Personal finance, in focus</p>
Expand All @@ -84,26 +102,74 @@ export default function ConnectPage() {
{ready && (config?.auth === "dev" || config === null) ? (
<form onSubmit={(event) => void signInDev(event)} className="mt-6 space-y-4">
<div>
<label htmlFor="email" className="text-sm font-semibold text-ink">Email</label>
<input id="email" type="email" required autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder={DEMO_ACCOUNT_EMAIL} className="mt-1.5 w-full rounded-lg border border-line bg-paper px-3 py-2.5 outline-none focus:border-ledger" />
<label htmlFor="email" className="text-sm font-semibold text-ink">
Email
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder={DEMO_ACCOUNT_EMAIL}
className="mt-1.5 w-full rounded-lg border border-line bg-paper px-3 py-2.5 outline-none focus:border-ledger"
/>
<p className="mt-1.5 text-xs text-quiet">Sample account: {DEMO_ACCOUNT_EMAIL}</p>
</div>
<button type="submit" className="w-full rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]">Sign in</button>
<button
type="submit"
className="w-full rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]"
>
Sign in
</button>
</form>
) : null}
{ready && config?.auth === "oidc" ? (
<div className="mt-6 space-y-3">
<button type="button" onClick={() => login()} className="w-full rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]">Sign in</button>
<button type="button" onClick={() => login("create")} className="w-full rounded-lg border border-line px-5 py-2.5 font-semibold text-ink hover:bg-paper">Create an account</button>
<button
type="button"
onClick={() => login()}
className="w-full rounded-lg bg-ledger-button px-5 py-2.5 font-semibold text-white transition hover:bg-[#0a5948]"
>
Sign in
</button>
<button
type="button"
onClick={() => login("create")}
className="w-full rounded-lg border border-line px-5 py-2.5 font-semibold text-ink hover:bg-paper"
>
Create an account
</button>
</div>
) : null}
<div className="mt-6 rounded-xl border border-line bg-paper/60 p-3">
<p className="text-xs text-quiet">Want to explore the app first?</p>
<button type="button" onClick={() => { startDemoSession(DEMO_ACCOUNT_EMAIL); window.location.assign(destination()); }} className="mt-2 w-full rounded-lg border border-line px-4 py-2.5 text-sm font-semibold text-ink hover:bg-paper">Preview sample account · {DEMO_ACCOUNT_EMAIL}</button>
<button
type="button"
onClick={() => {
startDemoSession(DEMO_ACCOUNT_EMAIL);
window.location.assign(destination());
}}
className="mt-2 w-full rounded-lg border border-line px-4 py-2.5 text-sm font-semibold text-ink hover:bg-paper"
>
Preview sample account · {DEMO_ACCOUNT_EMAIL}
</button>
</div>
{error ? <p role="alert" className="mt-4 rounded-lg bg-amber-soft px-3 py-2.5 text-sm text-amber">{error}</p> : null}
{ready && !config && !error ? <p role="alert" className="mt-4 text-sm text-quiet">Sign in is unavailable while the API is offline.</p> : null}
<p className="mt-6 border-t border-line pt-4 text-xs leading-5 text-quiet">Development sign-in is for local use only. Production sign-in uses your configured identity provider.</p>
{error ? (
<p role="alert" className="mt-4 rounded-lg bg-amber-soft px-3 py-2.5 text-sm text-amber">
{error}
</p>
) : null}
{ready && !config && !error ? (
<p role="alert" className="mt-4 text-sm text-quiet">
Sign in is unavailable while the API is offline.
</p>
) : null}
<p className="mt-6 border-t border-line pt-4 text-xs leading-5 text-quiet">
Development sign-in is for local use only. Production sign-in uses your configured identity
provider.
</p>
</section>
</main>
);
Expand Down
4 changes: 1 addition & 3 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,7 @@ body {
}

body {
background:
radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%),
var(--paper);
background: radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%), var(--paper);
color: var(--ink);
font-family: var(--font-hind);
-webkit-font-smoothing: antialiased;
Expand Down
Loading
Loading