Compare commits

...

5 Commits

Author SHA1 Message Date
Khalid Abdi 62a5f39683 chore: release v0.14.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:22 +03:00
Khalid Abdi 6127a0ac42 frontend: build with webpack so Docker images build on arm64
Next 16's default Turbopack build has no native bindings for linux/arm64 in
the Alpine image, so `next build` failed in Docker ("Turbopack is not
supported on this platform"). Switch the production build to webpack, which
Next recommends for this case and which builds on every arch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:02 +03:00
Khalid Abdi 352ca65838 chore: release v0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:31:54 +03:00
Khalid Abdi 44d653ffbd frontend: compose settings frames from CardFrame primitives
SettingsFrame used a hand-rolled <div className="p-5"> body and
SettingsCard was a plain div (no data-slot=card), so the frame's card
styling never applied.

- Add CardFramePanel (data-slot="card-frame-panel") as the padded frame
  body, mirroring the other CardFrame* subcomponents.
- SettingsFrame now composes CardFrameHeader + CardFramePanel (same p-5
  padding as before) instead of a raw div.
- SettingsCard renders a real COSS Card (data-slot=card). Since Card is
  flex flex-col, the horizontal-row call sites (ToggleRow, billing and
  preferences rows) now pass flex-row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:19 +03:00
Khalid Abdi 76da310766 frontend: fix wallet push silently skipping in form dialogs
The "send to wallet" step in the appointment/invoice/prescription/
patient-edit/scribe dialogs was gated on sync.linked, which resolves
asynchronously via getWalletLink in an effect. A fast save (or a
transient failure that collapsed it to false) left a wallet-linked
patient looking unlinked, so the dialog took the else branch and pushed
nothing.

- Add ensureLinked() to useWalletSync: awaits getWalletLink at submit and
  returns the resolved status. Each of the 5 dialogs now awaits it before
  deciding whether to show the wallet step.
- Harden push() to drop empty/whitespace changes (the backend 400s on an
  empty change set) and add a translated summaryFallback so the step
  never sends an empty summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:04 +03:00
20 changed files with 112 additions and 29 deletions
+24
View File
@@ -7,6 +7,30 @@ for how releases are cut and published.
## [Unreleased]
## [0.14.2] — 2026-07-15
### Fixed
- **Docker images build on ARM64 again.** Next 16's default Turbopack production build has no native
bindings for `linux/arm64` in the Alpine image, so `docker compose up --build` failed with
"Turbopack is not supported on this platform". The frontend now builds with Webpack
(`next build --webpack`), which builds on every architecture (`frontend/package.json`).
## [0.14.1] — 2026-07-15
### Fixed
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
failed), the dialog silently closed without pushing. The dialogs now await the link check before
deciding, and an empty change summary can no longer be sent
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
### Changed
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
## [0.14.0] — 2026-07-13
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.14.0",
"version": "0.14.2",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
@@ -195,7 +195,7 @@ export function AddAppointmentDialog({
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.appointment", { date: keyOf(date), time }),
);
@@ -396,7 +396,7 @@ export function PatientFormDialog({
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setStep("wallet");
return;
}
@@ -273,7 +273,7 @@ export function InvoiceFormDialog({
})
: await createInvoice(payload);
onSaved(saved);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
mode === "edit"
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
@@ -233,7 +233,7 @@ export function ScribeDialog({
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
if (sync.linked) {
if (await sync.ensureLinked()) {
setPhase("review");
setWalletStep(true);
} else {
@@ -347,7 +347,7 @@ export function AddPrescriptionDialog({
t("prescriptions.dialog.addedTitle"),
`${medication.trim()} · ${selected.name}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.prescription", { drug: medication.trim() }),
);
@@ -176,7 +176,7 @@ export function SigningPanel() {
description={t("settings.network.description")}
title={t("settings.network.title")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-5">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
@@ -251,7 +251,7 @@ export function SigningPanel() {
description={t("settings.signing.backupDescription")}
title={t("settings.signing.backupTitle")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.signing.backupLabel")}
@@ -5,12 +5,13 @@ import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
Card,
CardFrame,
CardFrameAction,
CardFrameDescription,
CardFrameHeader,
CardFramePanel,
CardFrameTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
@@ -43,7 +44,7 @@ export function SettingsFrame({
) : null}
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
</CardFrameHeader>
<div className={cn("p-5", bodyClassName)}>{children}</div>
<CardFramePanel className={bodyClassName}>{children}</CardFramePanel>
</CardFrame>
);
}
@@ -74,6 +75,10 @@ export function SettingsSection({
);
}
// A card surface used inside settings panels. Rendering a real COSS `Card` (with
// `data-slot="card"`) keeps settings cards consistent with the rest of the app
// and lets them pick up the frame's card treatment. `Card` is `flex flex-col`,
// so row layouts must pass `flex-row` in their className.
export function SettingsCard({
className,
children,
@@ -81,11 +86,7 @@ export function SettingsCard({
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
{children}
</div>
);
return <Card className={className}>{children}</Card>;
}
export function ToggleRow({
@@ -103,7 +104,7 @@ export function ToggleRow({
onCheckedChange?: (checked: boolean) => void;
}) {
return (
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 px-4 py-3.5">
<div className="space-y-0.5">
<p className="text-sm font-medium">{title}</p>
{description ? (
@@ -375,7 +375,7 @@ export function ProfilePanel() {
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
+20
View File
@@ -136,6 +136,26 @@ export function CardFrameFooter({
});
}
// The padded body of a frame: content that sits between the frame header and
// footer. Mirrors the other CardFrame* subcomponents (useRender + data-slot) so
// a frame can be composed entirely from primitives instead of a raw <div>.
export function CardFramePanel({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn("p-5", className),
"data-slot": "card-frame-panel",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function CardHeader({
className,
render,
+32 -2
View File
@@ -74,13 +74,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
};
}, [state, update]);
// Authoritatively resolve link status at submit time. The `linked` state above
// is populated asynchronously by the effect, so a fast save (or a transient
// failure that collapsed it to false) can leave a wallet-backed patient looking
// unlinked. Callers await this before deciding whether to show the wallet step,
// so the decision is never made on an unresolved check.
const ensureLinked = useCallback(async (): Promise<boolean> => {
if (!fileNumber) {
setLinked(false);
return false;
}
setChecking(true);
try {
await getWalletLink(fileNumber);
setLinked(true);
return true;
} catch {
setLinked(false);
return false;
} finally {
setChecking(false);
}
}, [fileNumber]);
const push = useCallback(
async (changes: string[]) => {
if (!fileNumber) return;
// Never push an empty/whitespace change set — the backend rejects it (400).
const clean = changes.map((c) => c.trim()).filter(Boolean);
if (clean.length === 0) {
setError("generic");
setState("error");
return;
}
setError(null);
setState("pending");
try {
const created = await pushWalletUpdate({ fileNumber, changes });
const created = await pushWalletUpdate({ fileNumber, changes: clean });
setUpdate(created);
} catch (err) {
setError(err instanceof ApiError ? err.message : "generic");
@@ -96,7 +126,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
setError(null);
}, []);
return { linked, checking, state, update, error, push, reset };
return { linked, checking, state, update, error, ensureLinked, push, reset };
}
export type UseWalletSync = ReturnType<typeof useWalletSync>;
@@ -60,6 +60,9 @@ export function WalletSyncStep({
const { t } = useTranslation();
const { state, update, error, push } = sync;
const status = update?.status ?? "pending";
// The summary is the human-readable change the patient approves. Guard against
// an empty one (the backend 400s on empty changes) with a translated fallback.
const changeSummary = summary.trim() || t("walletSync.summaryFallback");
return (
<>
@@ -79,7 +82,7 @@ export function WalletSyncStep({
{t("walletSync.changesLabel")}
</span>
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
{summary}
{changeSummary}
</div>
</div>
{error && (
@@ -119,7 +122,7 @@ export function WalletSyncStep({
<Button onClick={onDone} type="button" variant="outline">
{t("walletSync.skip")}
</Button>
<Button onClick={() => push([summary])} type="button">
<Button onClick={() => push([changeSummary])} type="button">
<Send className="size-4" />
{t("walletSync.send")}
</Button>
@@ -2257,6 +2257,7 @@
"prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة"
}
},
"summaryFallback": "تم تحديث السجل"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz"
}
},
"summaryFallback": "Datensatz aktualisiert"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated",
"note": "New clinical note"
}
},
"summaryFallback": "Record updated"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique"
}
},
"summaryFallback": "Dossier mis à jour"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Rijeeto cusub: {{drug}}",
"demographics": "Xogta bukaanka la cusboonaysiiyay",
"note": "Qoraal caafimaad cusub"
}
},
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "frontend",
"version": "0.14.0",
"version": "0.14.2",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"check-locales": "node scripts/check-locales.mjs"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.14.0",
"version": "0.14.2",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"