Keep cabinet data private per patient, email new accounts after a request, and store gallery photos as files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moniki/web",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getMyDocuments } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
|
||||
export default async function CabinetDocumentsPage({
|
||||
@@ -8,11 +10,27 @@ export default async function CabinetDocumentsPage({
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("cabinet");
|
||||
const documents = await getMyDocuments();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold">{t("documents")}</h2>
|
||||
<p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p>
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<li key={doc.id} className="rounded-[12px] border border-[var(--border)] bg-white px-4 py-3">
|
||||
<a href={doc.href} className="font-semibold text-[var(--brand-700)]" target="_blank" rel="noreferrer">
|
||||
{doc.title}
|
||||
</a>
|
||||
{doc.issuedAt ? (
|
||||
<p className="mt-1 text-sm text-[var(--text-muted)]">{formatDate(doc.issuedAt, locale)}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { getMyBookings } from "@/lib/api";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { getMyInvoices } from "@/lib/api";
|
||||
import { formatDate, formatPrice } from "@/lib/utils";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
|
||||
export default async function CabinetInvoicesPage({
|
||||
@@ -11,24 +11,27 @@ export default async function CabinetInvoicesPage({
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("cabinet");
|
||||
const bookings = await getMyBookings();
|
||||
const invoices = await getMyInvoices();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold">{t("invoices")}</h2>
|
||||
{bookings.length === 0 ? (
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)]">{t("emptyInvoices")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bookings.map((b) => (
|
||||
<Card key={b.id} className="flex items-center justify-between gap-4">
|
||||
{invoices.map((invoice) => (
|
||||
<Card key={invoice.id} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">{b.serviceTitle}</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">{b.datetime}</p>
|
||||
<p className="font-semibold">{invoice.title}</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{invoice.number}
|
||||
{invoice.datetime ? ` · ${formatDate(invoice.datetime, locale)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-[var(--brand-700)]">{formatPrice(b.price, locale)}</p>
|
||||
<p className="text-xs text-[var(--neutral-500)]">{b.paid ? t("paid") : t("unpaid")}</p>
|
||||
<p className="font-bold text-[var(--brand-700)]">{formatPrice(invoice.amount, locale)}</p>
|
||||
<p className="text-xs text-[var(--neutral-500)]">{invoice.paid ? t("paid") : t("unpaid")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CabinetNav } from "@/components/cabinet/CabinetNav";
|
||||
import { namedPageMetadata } from "@/lib/page-metadata";
|
||||
import { me } from "@/lib/api";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -22,6 +24,20 @@ export default async function CabinetLayout({
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("cabinet");
|
||||
const auth = await getTranslations("auth");
|
||||
const user = await me();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-12 sm:px-6">
|
||||
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
|
||||
<p className="mt-4 text-[var(--text-muted)]">{t("loginHint")}</p>
|
||||
<Link href="/login" className="mt-4 inline-block font-semibold text-[var(--brand-700)]">
|
||||
{auth("login")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function GalleryPage({
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.image} alt={item.title} className="mx-auto block w-full object-cover object-center" />
|
||||
<figcaption className="bg-white px-3 py-2 text-sm text-[var(--neutral-600)]">
|
||||
{item.title} · {item.category}
|
||||
{item.title}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -24,19 +24,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
return response;
|
||||
} catch {
|
||||
// Demo login fallback when CMS is down
|
||||
if (body.email && body.password) {
|
||||
const response = NextResponse.json({
|
||||
user: { id: "demo", email: body.email, name: "Пациент" },
|
||||
});
|
||||
response.cookies.set(AUTH_COOKIE, "demo-token", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return NextResponse.json({ message: "Login failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,7 @@ import { NextResponse } from "next/server";
|
||||
export async function GET() {
|
||||
const store = await cookies();
|
||||
const token = store.get(AUTH_COOKIE)?.value;
|
||||
if (!token) return NextResponse.json({ user: null }, { status: 401 });
|
||||
|
||||
if (token === "demo-token") {
|
||||
return NextResponse.json({
|
||||
user: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" },
|
||||
});
|
||||
}
|
||||
if (!token || token === "demo-token") return NextResponse.json({ user: null }, { status: 401 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CMS_URL}/api/users/me`, {
|
||||
@@ -21,21 +15,21 @@ export async function GET() {
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
user: { id: "demo", email: "patient@example.com", name: "Пациент" },
|
||||
});
|
||||
return NextResponse.json({ user: null }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const store = await cookies();
|
||||
const token = store.get(AUTH_COOKIE)?.value;
|
||||
if (!token) return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
if (!token || token === "demo-token") return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
if (token === "demo-token") {
|
||||
return NextResponse.json({ user: { id: "demo", ...body } });
|
||||
}
|
||||
const payload = {
|
||||
...body,
|
||||
fullName: body.fullName || body.name,
|
||||
};
|
||||
delete payload.name;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CMS_URL}/api/users/me`, {
|
||||
@@ -44,11 +38,11 @@ export async function PATCH(request: Request) {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json({ user: { id: "demo", ...body } });
|
||||
return NextResponse.json({ message: "Update failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,33 @@ import { NextResponse } from "next/server";
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
try {
|
||||
const payload = {
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
fullName: body.fullName || body.name,
|
||||
phone: body.phone,
|
||||
role: "patient",
|
||||
};
|
||||
const res = await fetch(`${CMS_URL}/api/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
// Auto-login after register
|
||||
const loginRes = await fetch(`${CMS_URL}/api/users/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: body.email, password: body.password }),
|
||||
});
|
||||
const loginData = await loginRes.json().catch(() => ({}));
|
||||
const token = loginData.token || data.token || "demo-token";
|
||||
const token = loginData.token || data.token;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "Register succeeded but login failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ user: data.doc || data.user || { email: body.email } });
|
||||
response.cookies.set(AUTH_COOKIE, token, {
|
||||
@@ -33,15 +42,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
return response;
|
||||
} catch {
|
||||
const response = NextResponse.json({
|
||||
user: { id: "demo", email: body.email, name: body.name },
|
||||
});
|
||||
response.cookies.set(AUTH_COOKIE, "demo-token", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return response;
|
||||
return NextResponse.json({ message: "Register failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +95,20 @@ export function BookingActions({ booking }: { booking: Booking }) {
|
||||
<p className="mt-1 text-sm text-[var(--text-muted)]">{booking.doctorName}</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">{booking.branchName}</p>
|
||||
<p className="mt-2 text-sm font-medium">{formatDate(booking.datetime, locale)}</p>
|
||||
{booking.source === "inquiry" ? (
|
||||
<p className="mt-2 text-sm text-[var(--text-muted)]">{t("requestPending")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-[var(--brand-700)]">{statusLabel}</p>
|
||||
<p className="mt-1 text-sm">{formatPrice(booking.price, locale)}</p>
|
||||
<p className="text-xs text-[var(--neutral-500)]">{paid ? t("paid") : t("unpaid")}</p>
|
||||
{booking.price ? <p className="mt-1 text-sm">{formatPrice(booking.price, locale)}</p> : null}
|
||||
{booking.source === "inquiry" ? null : (
|
||||
<p className="text-xs text-[var(--neutral-500)]">{paid ? t("paid") : t("unpaid")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "upcoming" ? (
|
||||
{status === "upcoming" && booking.source !== "inquiry" ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{!paid ? (
|
||||
<Button size="sm" onClick={pay} disabled={pending}>
|
||||
@@ -161,7 +166,7 @@ export function BookingActions({ booking }: { booking: Booking }) {
|
||||
|
||||
{reviewDone ? <p className="mt-3 text-sm text-[var(--success)]">{tr("thanks")}</p> : null}
|
||||
|
||||
{status === "upcoming" ? (
|
||||
{status === "upcoming" && booking.source !== "inquiry" ? (
|
||||
<p className="mt-3 text-xs text-[var(--neutral-500)]">
|
||||
<Link href="/booking" className="underline">
|
||||
{t("reschedule")}
|
||||
|
||||
+188
-17
@@ -1,7 +1,6 @@
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
demoBanners,
|
||||
demoBookings,
|
||||
demoBranches,
|
||||
demoDoctors,
|
||||
demoGallery,
|
||||
@@ -170,6 +169,83 @@ function asText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function isPatientToken(token: string | null): token is string {
|
||||
return Boolean(token) && token !== "demo-token";
|
||||
}
|
||||
|
||||
function relRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function mapInquiryBooking(raw: Record<string, unknown>): Booking {
|
||||
const service = relRecord(raw.service);
|
||||
const doctor = relRecord(raw.doctor);
|
||||
const branch = relRecord(raw.branch);
|
||||
return {
|
||||
id: `inquiry-${raw.id}`,
|
||||
serviceTitle: stripPriceListIndex(asText(service?.title)) || (doctor ? "Приём врача" : "Заявка на запись"),
|
||||
doctorName: asText(doctor?.fullName) || "—",
|
||||
branchName: asText(branch?.name),
|
||||
datetime: asText(raw.createdAt),
|
||||
status: asText(raw.status) === "done" ? "completed" : "upcoming",
|
||||
price: Number(service?.price) || 0,
|
||||
paid: false,
|
||||
source: "inquiry",
|
||||
};
|
||||
}
|
||||
|
||||
function mapCmsBooking(raw: Record<string, unknown>): Booking {
|
||||
const service = relRecord(raw.service);
|
||||
const doctor = relRecord(raw.doctor);
|
||||
const branch = relRecord(raw.branch);
|
||||
const statusRaw = asText(raw.status);
|
||||
const status: Booking["status"] =
|
||||
statusRaw === "completed" ? "completed" : statusRaw === "cancelled" || statusRaw === "expired" ? "cancelled" : "upcoming";
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
serviceTitle: stripPriceListIndex(asText(service?.title)) || "Услуга",
|
||||
doctorName: asText(doctor?.fullName) || "—",
|
||||
branchName: asText(branch?.name),
|
||||
datetime: asText(raw.startsAt) || asText(raw.createdAt),
|
||||
status,
|
||||
price: Number(raw.price) || Number(service?.price) || 0,
|
||||
paid: asText(raw.paymentStatus) === "paid",
|
||||
source: "booking",
|
||||
};
|
||||
}
|
||||
|
||||
function mapFavorite(raw: Record<string, unknown>): Service | Doctor | null {
|
||||
const doctor = relRecord(raw.doctor);
|
||||
if (doctor) {
|
||||
return {
|
||||
id: String(doctor.id ?? raw.id ?? ""),
|
||||
slug: asText(doctor.slug),
|
||||
name: asText(doctor.fullName) || asText(doctor.name),
|
||||
specialty: asText(doctor.specialty) || asText(doctor.position),
|
||||
experienceYears: Number(doctor.experienceYears) || 0,
|
||||
photo: mediaSrc(doctor.photo),
|
||||
bio: asText(doctor.bio),
|
||||
rating: Number(doctor.rating) || 0,
|
||||
reviewsCount: Number(doctor.reviewsCount) || 0,
|
||||
services: [],
|
||||
branchIds: [],
|
||||
};
|
||||
}
|
||||
const service = relRecord(raw.service);
|
||||
if (service) {
|
||||
return {
|
||||
id: String(service.id ?? raw.id ?? ""),
|
||||
slug: asText(service.slug),
|
||||
title: asText(service.title),
|
||||
category: asText(service.groupTitle) || asText(service.category),
|
||||
price: Number(service.price) || 0,
|
||||
durationMin: Number(service.durationMin) || 0,
|
||||
description: asText(service.description),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => {
|
||||
@@ -664,10 +740,24 @@ export async function getNewsBySlug(slug: string, locale = "ru"): Promise<NewsIt
|
||||
}
|
||||
|
||||
export async function getGallery(locale = "ru"): Promise<GalleryItem[]> {
|
||||
const query = withLocale(new URLSearchParams({ limit: "100", depth: "1" }), locale);
|
||||
return cmsFetch<GalleryItem[]>(`/api/gallery?${query}`, { fallback: demoGallery }).then((data) =>
|
||||
unwrapList(data as PayloadList<GalleryItem> | GalleryItem[]),
|
||||
);
|
||||
const query = withLocale(new URLSearchParams({ limit: "100", depth: "1", sort: "sortOrder" }), locale);
|
||||
try {
|
||||
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(`/api/gallery?${query}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const items = unwrapList(data)
|
||||
.map((raw) => ({
|
||||
id: String(raw.id ?? ""),
|
||||
title: asText(raw.title),
|
||||
image: mediaSrc(raw.image),
|
||||
category: asText(raw.category),
|
||||
}))
|
||||
.filter((item) => item.id && item.image);
|
||||
if (items.length) return items;
|
||||
} catch {
|
||||
// fall through to demo
|
||||
}
|
||||
return demoGallery;
|
||||
}
|
||||
|
||||
export async function getReviews(
|
||||
@@ -788,7 +878,7 @@ export async function createInquiry(payload: {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
cache: "no-store",
|
||||
timeoutMs: 20000,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -852,11 +942,16 @@ export async function createReview(payload: {
|
||||
|
||||
export async function getFavorites(): Promise<(Service | Doctor)[]> {
|
||||
const token = await getAuthToken();
|
||||
return cmsFetch(`/api/favorites`, {
|
||||
if (!isPatientToken(token)) return [];
|
||||
return cmsFetch(`/api/favorites?depth=1&limit=50`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
fallback: [],
|
||||
}).then((data) => unwrapList(data as PayloadList<Service | Doctor> | (Service | Doctor)[]));
|
||||
fallback: { docs: [] },
|
||||
}).then((data) =>
|
||||
unwrapList(data as PayloadList<Record<string, unknown>> | Record<string, unknown>[])
|
||||
.map(mapFavorite)
|
||||
.filter((item): item is Service | Doctor => Boolean(item)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function addFavorite(payload: {
|
||||
@@ -885,11 +980,78 @@ export async function removeFavorite(id: string): Promise<void> {
|
||||
|
||||
export async function getMyBookings(): Promise<Booking[]> {
|
||||
const token = await getAuthToken();
|
||||
return cmsFetch<Booking[]>(`/api/bookings?mine=true`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
fallback: demoBookings,
|
||||
}).then((data) => unwrapList(data as PayloadList<Booking> | Booking[]));
|
||||
if (!isPatientToken(token)) return [];
|
||||
const [inquiries, bookings] = await Promise.all([
|
||||
cmsFetch<PayloadList<Record<string, unknown>>>(`/api/inquiries?limit=50&depth=2&sort=-createdAt`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
fallback: { docs: [] },
|
||||
}).catch(() => ({ docs: [] })),
|
||||
cmsFetch<PayloadList<Record<string, unknown>>>(`/api/bookings?limit=50&depth=2&sort=-startsAt`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
fallback: { docs: [] },
|
||||
}).catch(() => ({ docs: [] })),
|
||||
]);
|
||||
return [
|
||||
...unwrapList(inquiries).map(mapInquiryBooking),
|
||||
...unwrapList(bookings).map(mapCmsBooking),
|
||||
].sort((a, b) => Date.parse(b.datetime) - Date.parse(a.datetime));
|
||||
}
|
||||
|
||||
export type CabinetDocument = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
issuedAt?: string;
|
||||
};
|
||||
|
||||
export async function getMyDocuments(): Promise<CabinetDocument[]> {
|
||||
const token = await getAuthToken();
|
||||
if (!isPatientToken(token)) return [];
|
||||
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
|
||||
`/api/documents?depth=1&limit=50&sort=-issuedAt&where[isPublic][equals]=false`,
|
||||
{ token, cache: "no-store", fallback: { docs: [] } },
|
||||
).catch(() => ({ docs: [] as Record<string, unknown>[] }));
|
||||
return unwrapList(data)
|
||||
.map((raw) => {
|
||||
const file = raw.file && typeof raw.file === "object" ? (raw.file as Record<string, unknown>) : null;
|
||||
const href = mediaSrc(file);
|
||||
const title = asText(raw.title);
|
||||
if (!title || !href) return null;
|
||||
return { id: String(raw.id ?? ""), title, href, issuedAt: asText(raw.issuedAt) || undefined };
|
||||
})
|
||||
.filter((item): item is CabinetDocument => Boolean(item));
|
||||
}
|
||||
|
||||
export type CabinetInvoice = {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
paid: boolean;
|
||||
datetime: string;
|
||||
};
|
||||
|
||||
export async function getMyInvoices(): Promise<CabinetInvoice[]> {
|
||||
const token = await getAuthToken();
|
||||
if (!isPatientToken(token)) return [];
|
||||
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
|
||||
`/api/invoices?depth=2&limit=50&sort=-createdAt`,
|
||||
{ token, cache: "no-store", fallback: { docs: [] } },
|
||||
).catch(() => ({ docs: [] as Record<string, unknown>[] }));
|
||||
return unwrapList(data).map((raw) => {
|
||||
const booking = relRecord(raw.booking);
|
||||
const service = booking ? relRecord(booking.service) : null;
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
number: asText(raw.number) || String(raw.id ?? ""),
|
||||
title: asText(service?.title) || asText(raw.number) || "Счёт",
|
||||
amount: Number(raw.amount) || 0,
|
||||
paid: asText(raw.status) === "paid",
|
||||
datetime: asText(raw.paidAt) || asText(raw.createdAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function me(): Promise<{
|
||||
@@ -899,13 +1061,22 @@ export async function me(): Promise<{
|
||||
phone?: string;
|
||||
} | null> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) return null;
|
||||
if (!isPatientToken(token)) return null;
|
||||
try {
|
||||
return await cmsFetch(`/api/users/me`, {
|
||||
const data = await cmsFetch<Record<string, unknown>>(`/api/users/me`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
fallback: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" },
|
||||
});
|
||||
const user = relRecord(data.user) || data;
|
||||
const email = asText(user.email);
|
||||
const id = user.id != null ? String(user.id) : "";
|
||||
if (!email || !id || id === "demo") return null;
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
name: asText(user.fullName) || asText(user.name) || undefined,
|
||||
phone: asText(user.phone) || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export type Booking = {
|
||||
status: "upcoming" | "completed" | "cancelled";
|
||||
price: number;
|
||||
paid: boolean;
|
||||
source?: "inquiry" | "booking";
|
||||
};
|
||||
|
||||
export const demoServices: Service[] = [
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "Абранае пустое",
|
||||
"emptyDocuments": "Дакументаў пакуль няма",
|
||||
"emptyInvoices": "Рахункаў пакуль няма",
|
||||
"loginHint": "Увайдзіце ў асабісты кабінет, каб убачыць свае запісы, дакументы і рахункі.",
|
||||
"requestPending": "Заяўка прынята. Мы звяжамся з вамі, каб удакладніць дату і час.",
|
||||
"save": "Захаваць",
|
||||
"saved": "Захавана"
|
||||
},
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "No favorites yet",
|
||||
"emptyDocuments": "No documents yet",
|
||||
"emptyInvoices": "No invoices yet",
|
||||
"loginHint": "Sign in to see your bookings, documents and invoices. Each patient has a personal account.",
|
||||
"requestPending": "Your request was received. We will contact you to confirm the date and time.",
|
||||
"save": "Save",
|
||||
"saved": "Saved"
|
||||
},
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "Тандалмалар бош",
|
||||
"emptyDocuments": "Азырынча документ жок",
|
||||
"emptyInvoices": "Азырынча эсеп жок",
|
||||
"loginHint": "Жазууларыңызды, документтерди жана эсептерди көрүү үчүн жеке кабинетке кириңиз.",
|
||||
"requestPending": "Өтүнмө кабыл алынды. Биз дата менен убакытты тактоо үчүн байланышабыз.",
|
||||
"save": "Сактоо",
|
||||
"saved": "Сакталды"
|
||||
},
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "Избранное пусто",
|
||||
"emptyDocuments": "Документов пока нет",
|
||||
"emptyInvoices": "Счетов пока нет",
|
||||
"loginHint": "Войдите в личный кабинет, чтобы увидеть свои записи, документы и счета. У каждого пациента свой кабинет.",
|
||||
"requestPending": "Заявка принята. Мы свяжемся с вами, чтобы уточнить дату и время визита.",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено"
|
||||
},
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "Дӯстдоштаҳо холӣ",
|
||||
"emptyDocuments": "Ҳанӯз ҳуҷҷат нест",
|
||||
"emptyInvoices": "Ҳанӯз ҳисоб нест",
|
||||
"loginHint": "Ба кабинети шахсӣ ворид шавед, то дархостҳо, ҳуҷҷатҳо ва ҳисобҳои худро бубинед.",
|
||||
"requestPending": "Дархост қабул шуд. Мо барои дақиқ кардани сана ва вақт бо шумо тамос мегирем.",
|
||||
"save": "Нигоҳ доштан",
|
||||
"saved": "Нигоҳ дошта шуд"
|
||||
},
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
"emptyFavorites": "暂无收藏",
|
||||
"emptyDocuments": "暂无文件",
|
||||
"emptyInvoices": "暂无账单",
|
||||
"loginHint": "请登录个人账户查看您的预约、文件和账单。每位患者都有独立账户。",
|
||||
"requestPending": "申请已受理。我们会联系您确认就诊日期和时间。",
|
||||
"save": "保存",
|
||||
"saved": "已保存"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user