Initial release of Moniki paid services site and CMS (v1.0.0).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
ComingSoon,
|
||||
InfoList,
|
||||
SourceNote,
|
||||
} from "@/components/organization/OrganizationLayout";
|
||||
import { RichText } from "@/components/pages/RichText";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import type { SitePage } from "@/lib/site-pages";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function PageAnchor({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
if (href.startsWith("/")) {
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleBlock({ page }: { page: SitePage }) {
|
||||
const hasPhotos = page.people.some((person) => person.photo);
|
||||
if (hasPhotos) {
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{page.people.map((person) => (
|
||||
<li
|
||||
key={`${person.name}-${person.position}`}
|
||||
className="min-w-0 overflow-hidden rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)]"
|
||||
>
|
||||
{person.photo ? (
|
||||
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--neutral-100)]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
width={720}
|
||||
height={960}
|
||||
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-4">
|
||||
{person.position ? (
|
||||
<p className="break-words text-sm text-[var(--neutral-500)]">{person.position}</p>
|
||||
) : null}
|
||||
<h2 className="mt-1 break-words text-base font-semibold text-[var(--neutral-900)]">
|
||||
{person.name}
|
||||
</h2>
|
||||
{person.degree ? (
|
||||
<p className="mt-1 break-words text-sm text-[var(--neutral-700)]">{person.degree}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const hasSchedule = page.people.some((person) => person.hours || person.address);
|
||||
if (!hasSchedule) {
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{page.people.map((person) => (
|
||||
<li key={`${person.name}-${person.position}`}>
|
||||
<p className="font-medium text-[var(--neutral-900)]">{person.name}</p>
|
||||
{person.position ? (
|
||||
<p className="text-sm text-[var(--neutral-600)]">{person.position}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return <ReceptionPeople page={page} />;
|
||||
}
|
||||
|
||||
async function ReceptionPeople({ page }: { page: SitePage }) {
|
||||
const t = await getTranslations("organization");
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<ul className="space-y-3 md:hidden">
|
||||
{page.people.map((row) => (
|
||||
<li
|
||||
key={`${row.name}-${row.position}`}
|
||||
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
|
||||
>
|
||||
<p className="whitespace-pre-line font-semibold text-[var(--neutral-900)]">
|
||||
{row.position}
|
||||
</p>
|
||||
<p className="mt-1 text-[var(--neutral-800)]">{row.name}</p>
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("reception.hours")}</dt>
|
||||
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
|
||||
{row.hours || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
|
||||
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
|
||||
{row.address || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
|
||||
<table className="w-full min-w-[36rem] border-collapse text-left text-sm">
|
||||
<thead className="bg-[var(--neutral-50)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("reception.position")}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("reception.hours")}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("address")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.people.map((row) => (
|
||||
<tr key={`${row.name}-${row.position}`} className="border-t border-[var(--border)] align-top">
|
||||
<td className="px-4 py-3 text-[var(--neutral-800)]">
|
||||
<p className="whitespace-pre-line font-medium">{row.position}</p>
|
||||
<p className="mt-1 whitespace-pre-line">{row.name}</p>
|
||||
</td>
|
||||
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
|
||||
{row.hours || "—"}
|
||||
</td>
|
||||
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
|
||||
{row.address || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function CardsBlock({ page }: { page: SitePage }) {
|
||||
const t = await getTranslations("organization");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{page.cards.map((item) => (
|
||||
<Card key={item.title}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
|
||||
{item.status ? (
|
||||
<p className="text-sm font-medium text-[var(--brand-700)]">{item.status}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{item.role ? <p className="mt-2 text-sm text-[var(--text-muted)]">{item.role}</p> : null}
|
||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
{item.number ? (
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("licenseNumber")}</dt>
|
||||
<dd className="mt-1 font-medium text-[var(--neutral-800)]">{item.number}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.issuedAt ? (
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("issuedAt")}</dt>
|
||||
<dd className="mt-1 text-[var(--neutral-800)]">{item.issuedAt}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.authority ? (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[var(--neutral-500)]">{t("authority")}</dt>
|
||||
<dd className="mt-1 text-[var(--neutral-800)]">{item.authority}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.activity ? (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[var(--neutral-500)]">{t("activity")}</dt>
|
||||
<dd className="mt-1 text-[var(--neutral-800)]">{item.activity}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.phone ? (
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("phone")}</dt>
|
||||
<dd className="mt-1 text-[var(--neutral-800)]">{item.phone}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.address ? (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
|
||||
<dd className="mt-1 text-[var(--neutral-800)]">{item.address}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.email ? (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[var(--neutral-500)]">{t("email")}</dt>
|
||||
<dd className="mt-1">
|
||||
<a href={`mailto:${item.email}`} className="text-[var(--brand-700)] hover:underline">
|
||||
{item.email}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{item.extract ? <p className="mt-4 text-sm text-[var(--text-muted)]">{item.extract}</p> : null}
|
||||
{item.href ? (
|
||||
<PageAnchor href={item.href} className="mt-4 inline-flex">
|
||||
{item.linkLabel && !item.number && !item.authority ? (
|
||||
<span className="text-sm font-medium text-[var(--brand-700)] hover:underline">
|
||||
{item.linkLabel}
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
{item.linkLabel || item.href}
|
||||
</Button>
|
||||
)}
|
||||
</PageAnchor>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function TableBlock({ page }: { page: SitePage }) {
|
||||
const t = await getTranslations("legal.discounts");
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
{page.tableCaption ? (
|
||||
<p className="text-[var(--neutral-700)]">{page.tableCaption}</p>
|
||||
) : null}
|
||||
|
||||
<ul className="space-y-3 md:hidden">
|
||||
{page.tableRows.map((row) => (
|
||||
<li
|
||||
key={`${row.category}-${row.condition}-${row.discount}`}
|
||||
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
|
||||
>
|
||||
<p className="font-semibold text-[var(--neutral-900)]">{row.category}</p>
|
||||
{row.condition ? (
|
||||
<p className="mt-1 text-sm text-[var(--neutral-700)]">{row.condition}</p>
|
||||
) : null}
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("discount")}</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--brand-700)]">{row.discount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--neutral-500)]">{t("documents")}</dt>
|
||||
<dd className="mt-0.5 text-[var(--neutral-800)]">{row.documents}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-left text-sm">
|
||||
<caption className="sr-only">{page.tableCaption || t("heading")}</caption>
|
||||
<thead className="bg-[var(--neutral-50)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("category")}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("discount")}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
|
||||
{t("documents")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.tableRows.map((row) => (
|
||||
<tr
|
||||
key={`${row.category}-${row.condition}-${row.discount}`}
|
||||
className="border-t border-[var(--border)] align-top"
|
||||
>
|
||||
<th scope="row" className="px-4 py-3 font-medium text-[var(--neutral-800)]">
|
||||
<p>{row.category}</p>
|
||||
{row.condition ? (
|
||||
<p className="mt-1 font-normal text-[var(--neutral-600)]">{row.condition}</p>
|
||||
) : null}
|
||||
</th>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-semibold text-[var(--brand-700)]">
|
||||
{row.discount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--neutral-800)]">{row.documents}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function CmsPageBody({ page }: { page: SitePage }) {
|
||||
const t = await getTranslations("organization");
|
||||
const ctaLabel = page.ctaLabel || page.ctaHref;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{page.heading}</h1>
|
||||
{page.subtitle ? (
|
||||
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{page.subtitle}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
{page.tableRows.length === 0 && page.timeline.length === 0 ? (
|
||||
<RichText value={page.content} />
|
||||
) : null}
|
||||
|
||||
{page.tableRows.length ? <TableBlock page={page} /> : null}
|
||||
|
||||
{page.facts.length ? <InfoList items={page.facts} /> : null}
|
||||
|
||||
{page.timeline.length ? (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--neutral-900)]">
|
||||
{page.timelineTitle || t("history")}
|
||||
</h2>
|
||||
<ol className="mt-4 space-y-3">
|
||||
{page.timeline.map((item) => (
|
||||
<li key={`${item.year}-${item.text}`} className="flex gap-4">
|
||||
<span className="w-16 shrink-0 font-semibold text-[var(--brand-700)]">{item.year}</span>
|
||||
<span className="text-[var(--neutral-700)]">{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page.timeline.length || page.tableRows.length ? <RichText value={page.content} /> : null}
|
||||
|
||||
{page.people.length ? <PeopleBlock page={page} /> : null}
|
||||
{page.cards.length ? <CardsBlock page={page} /> : null}
|
||||
|
||||
{page.faq.length ? (
|
||||
<div className="space-y-4">
|
||||
{page.faq.map((item) => (
|
||||
<Card key={item.question}>
|
||||
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.question}</h2>
|
||||
<p className="mt-2 leading-relaxed text-[var(--neutral-700)]">{item.answer}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page.notice ? <ComingSoon text={page.notice} /> : null}
|
||||
|
||||
{page.ctaHref && ctaLabel ? (
|
||||
page.ctaHint ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{page.ctaHint}{" "}
|
||||
<PageAnchor href={page.ctaHref} className="font-medium text-[var(--brand-700)] hover:underline">
|
||||
{ctaLabel}
|
||||
</PageAnchor>
|
||||
</p>
|
||||
) : (
|
||||
<PageAnchor href={page.ctaHref} className="inline-flex">
|
||||
{page.ctaHref.startsWith("http") && !page.facts.length ? (
|
||||
<span className="font-medium text-[var(--brand-700)] hover:underline">{ctaLabel}</span>
|
||||
) : (
|
||||
<Button variant="outline">{ctaLabel}</Button>
|
||||
)}
|
||||
</PageAnchor>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{page.sourceUrl ? <SourceNote href={page.sourceUrl} label={t("source")} /> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user