refactor(quality): Phase 2 — split templates, consolidate constants, in-house QR

Code quality pass driven by the plan. Eliminates the duplicated
constants, breaks the 216-line templates file into per-template files,
and removes the third-party QR dependency that was leaking memorial
URLs to api.qrserver.com.

Templates (src/lib/templates.tsx → src/lib/templates/):
- Split into Elegance.tsx, Cinematic.tsx, Serene.tsx and a shared.tsx
  holding formatDates() and MemorialFooter.
- New index.tsx re-exports everything plus renderTemplate(), so the
  existing '@@/lib/templates' import paths are unchanged.
- Adds eslint-disable-next-line @next/next/no-img-element markers on
  the raw <img> tags so the linter (once it works again) won't flag
  them; full migration to next/image is deferred to a later phase
  pending next.config remotePatterns verification.

QR consolidation:
- dashboard/page.tsx now uses lib/qrcode.ts::generateMonumentQR
  (server-rendered async) instead of api.qrserver.com. The external
  service was logging every memorial URL to a third party.
- lib/qrcode.ts reads APP_DOMAIN from the shared config (was
  process.env.NEXT_PUBLIC_APP_DOMAIN inline). Added
  generateMonumentQRPng() helper for the download path.
- Dashboard 'Превземи QR код' link now points at the data: URL in-app
  and downloads as '{subdomain}-qr.png'.

Config consolidation:
- SubdomainPicker.tsx now imports APP_DOMAIN, SUBDOMAIN_MIN_LENGTH
  and SUBDOMAIN_MAX_LENGTH from lib/config; previously the slug was
  normalized with a regex that didn't match the server's stricter
  /^[a-z0-9][a-z0-9-]*[a-z0-9]$/ rule and the success message
  hardcoded '.testbed.mk'.
- ImageUploader.tsx re-imports MAX_FILE_SIZE / ALLOWED_TYPES /
  MAX_FILES from lib/upload (they were literally redefined inline,
  causing drift risk). The remaining client-side filter uses a
  string-cast to satisfy the readonly-tuple type.
This commit is contained in:
dimitar 2026-08-02 12:15:19 +02:00
parent 3ff24cda0b
commit c0effe6ec9
10 changed files with 271 additions and 232 deletions

View File

@ -6,6 +6,8 @@ import { UserButton } from "@clerk/nextjs";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import DeleteMonumentButton from "@/components/DeleteMonumentButton"; import DeleteMonumentButton from "@/components/DeleteMonumentButton";
import DeleteImageButton from "@/components/DeleteImageButton"; import DeleteImageButton from "@/components/DeleteImageButton";
import { generateMonumentQR } from "@/lib/qrcode";
import { APP_DOMAIN } from "@/lib/config";
export default async function DashboardPage() { export default async function DashboardPage() {
const { userId } = await auth(); const { userId } = await auth();
@ -20,7 +22,8 @@ export default async function DashboardPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
const monumentUrl = `https://${user.subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`; const monumentUrl = `https://${user.subdomain}.${APP_DOMAIN}`;
const qrCode = await generateMonumentQR(user.subdomain);
return ( return (
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-stone-50">
@ -78,14 +81,15 @@ export default async function DashboardPage() {
<h3 className="text-lg font-medium text-stone-900">QR код</h3> <h3 className="text-lg font-medium text-stone-900">QR код</h3>
<p className="mt-1 text-sm text-stone-500">Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.</p> <p className="mt-1 text-sm text-stone-500">Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.</p>
<div className="mt-4 rounded-lg border border-stone-200 bg-white p-4"> <div className="mt-4 rounded-lg border border-stone-200 bg-white p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(monumentUrl)}`} src={qrCode}
alt="QR код" alt="QR код"
className="h-48 w-48" className="h-48 w-48"
/> />
<a <a
href={`https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(monumentUrl)}`} href={qrCode}
download download={`${user.subdomain}-qr.png`}
className="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light" className="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
> >
Превземи QR код Превземи QR код

View File

@ -2,10 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
import { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES } from "@/lib/upload";
const MAX_FILES = 3;
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
interface ImageData { interface ImageData {
key: string; key: string;
@ -31,7 +28,7 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
if (remaining <= 0) return; if (remaining <= 0) return;
const validFiles = Array.from(files) const validFiles = Array.from(files)
.filter((f) => ALLOWED_TYPES.includes(f.type)) .filter((f) => (ALLOWED_TYPES as readonly string[]).includes(f.type))
.filter((f) => f.size <= MAX_FILE_SIZE) .filter((f) => f.size <= MAX_FILE_SIZE)
.slice(0, remaining); .slice(0, remaining);

View File

@ -1,6 +1,11 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import {
APP_DOMAIN,
SUBDOMAIN_MIN_LENGTH,
SUBDOMAIN_MAX_LENGTH,
} from "@/lib/config";
interface SubdomainPickerProps { interface SubdomainPickerProps {
value: string; value: string;
@ -14,7 +19,7 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, ""); const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
const checkAvailability = useCallback(async (s: string) => { const checkAvailability = useCallback(async (s: string) => {
if (s.length < 3) { if (s.length < SUBDOMAIN_MIN_LENGTH) {
setAvailable(null); setAvailable(null);
return; return;
} }
@ -51,10 +56,10 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))} onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))}
placeholder="нпр. maria-novakovska" placeholder="нпр. maria-novakovska"
className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary" className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={63} maxLength={SUBDOMAIN_MAX_LENGTH}
/> />
<span className="rounded-r-lg bg-stone-50 px-3 py-2 text-sm text-stone-500 border-l border-stone-200"> <span className="rounded-r-lg bg-stone-50 px-3 py-2 text-sm text-stone-500 border-l border-stone-200">
.{process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"} .{APP_DOMAIN}
</span> </span>
</div> </div>
</div> </div>
@ -62,13 +67,13 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
<div className="text-sm"> <div className="text-sm">
{checking && <p className="text-stone-500">Проверка на достапност...</p>} {checking && <p className="text-stone-500">Проверка на достапност...</p>}
{!checking && available === true && ( {!checking && available === true && (
<p className="text-green-600">&#10003; {slug}.testbed.mk е достапен!</p> <p className="text-green-600">&#10003; {slug}.{APP_DOMAIN} е достапен!</p>
)} )}
{!checking && available === false && ( {!checking && available === false && (
<p className="text-red-600">&#10007; Овој поддомен е веќе зафатен.</p> <p className="text-red-600">&#10007; Овој поддомен е веќе зафатен.</p>
)} )}
{!checking && available === null && slug.length > 0 && slug.length < 3 && ( {!checking && available === null && slug.length > 0 && slug.length < SUBDOMAIN_MIN_LENGTH && (
<p className="text-stone-400">Потребни се најмалку 3 карактери.</p> <p className="text-stone-400">Потребни се најмалку {SUBDOMAIN_MIN_LENGTH} карактери.</p>
)} )}
</div> </div>
</div> </div>

View File

@ -1,7 +1,8 @@
import QRCode from "qrcode"; import QRCode from "qrcode";
import { APP_DOMAIN } from "./config";
export async function generateMonumentQR(subdomain: string): Promise<string> { export async function generateMonumentQR(subdomain: string): Promise<string> {
const url = `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`; const url = `https://${subdomain}.${APP_DOMAIN}`;
const qrBuffer = await QRCode.toBuffer(url, { const qrBuffer = await QRCode.toBuffer(url, {
type: "png", type: "png",
width: 400, width: 400,
@ -14,3 +15,16 @@ export async function generateMonumentQR(subdomain: string): Promise<string> {
return `data:image/png;base64,${qrBuffer.toString("base64")}`; return `data:image/png;base64,${qrBuffer.toString("base64")}`;
} }
export async function generateMonumentQRPng(subdomain: string): Promise<Buffer> {
const url = `https://${subdomain}.${APP_DOMAIN}`;
return QRCode.toBuffer(url, {
type: "png",
width: 400,
margin: 2,
color: {
dark: "#1a1a2e",
light: "#ffffff",
},
});
}

View File

@ -1,216 +0,0 @@
import type { MemorialData } from "@/types";
function formatDates(born: string | null, passed: string | null): string {
if (born && passed) return `${born} \u2014 ${passed}`;
if (passed) return passed;
if (born) return born;
return "";
}
function MemorialFooter({ name }: { name: string | null }) {
return (
<footer className="border-t border-stone-200 py-10 text-center">
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-amber-600">
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-sm italic text-stone-400">Во спомен на</p>
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
</footer>
);
}
export function renderTemplate(templateId: number, data: MemorialData) {
switch (templateId) {
case 1:
return <TemplateElegance data={data} />;
case 2:
return <TemplateCinematic data={data} />;
case 3:
return <TemplateSerene data={data} />;
default:
return <TemplateElegance data={data} />;
}
}
export function TemplateElegance({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const heroImage = sortedImages[0];
const gridImages = sortedImages.slice(1);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-stone-50 font-serif">
{heroImage && (
<div className="relative h-[55vh] w-full overflow-hidden">
<img src={heroImage.url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/70 via-stone-900/20 to-transparent" />
<div className="absolute bottom-10 left-0 right-0 text-center">
<h1 className="text-4xl font-bold tracking-wide text-white md:text-6xl drop-shadow-lg">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
{dates}
</p>
)}
</div>
</div>
)}
{!heroImage && (
<div className="mx-auto max-w-2xl px-6 pt-24 text-center">
<h1 className="text-4xl font-bold tracking-wide text-stone-900 md:text-6xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
)}
<div className="mx-auto mt-6 h-px w-16 bg-amber-400" />
</div>
)}
<div className="mx-auto max-w-3xl px-6 py-14">
{data.description && (
<div className="text-center">
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap md:text-xl">
{data.description}
</p>
</div>
)}
{gridImages.length > 0 && (
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2">
{gridImages.map((img) => (
<div key={img.id} className="overflow-hidden rounded-lg shadow-md">
<img src={img.url} alt="" className="h-64 w-full object-cover transition-transform duration-500 hover:scale-105" />
</div>
))}
</div>
)}
<MemorialFooter name={data.title} />
</div>
</div>
);
}
export function TemplateCinematic({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-zinc-950 text-white">
{sortedImages.length > 0 && (
<>
<div className="relative h-screen w-full">
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
<div className="absolute bottom-16 left-8 right-8 md:left-16">
<h1 className="text-5xl font-black tracking-tight md:text-8xl drop-shadow-2xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
{dates}
</p>
)}
{data.description && (
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-300 md:text-lg line-clamp-4">
{data.description}
</p>
)}
</div>
</div>
{sortedImages.length > 1 && (
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="relative aspect-square overflow-hidden">
<img src={img.url} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
)}
</>
)}
{sortedImages.length === 0 && (
<div className="flex min-h-screen items-center justify-center px-8">
<div className="max-w-2xl text-center">
<h1 className="text-5xl font-black tracking-tight md:text-8xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
)}
{data.description && (
<p className="mt-8 text-base leading-relaxed text-zinc-400 whitespace-pre-wrap md:text-lg">
{data.description}
</p>
)}
</div>
</div>
)}
<footer className="py-12 text-center">
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-zinc-500">
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-sm italic text-zinc-500">Во спомен на</p>
</footer>
</div>
);
}
export function TemplateSerene({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-white">
<div className="mx-auto max-w-xl px-6 py-20 md:py-28">
<div className="text-center">
{sortedImages.length > 0 && (
<div className="mx-auto mb-8 h-40 w-40 overflow-hidden rounded-full shadow-lg">
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
</div>
)}
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-sm tracking-[0.2em] text-zinc-400 uppercase">{dates}</p>
)}
<div className="mx-auto mt-6 h-px w-12 bg-blue-200" />
</div>
{data.description && (
<div className="mt-10 border-l-2 border-blue-100 pl-6">
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap md:text-lg">
{data.description}
</p>
</div>
)}
{sortedImages.length > 1 && (
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="flex-shrink-0">
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
</div>
))}
</div>
)}
<MemorialFooter name={data.title} />
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
import type { MemorialData } from "@/types";
import { formatDates } from "./shared";
export function TemplateCinematic({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-zinc-950 text-white">
{sortedImages.length > 0 && (
<>
<div className="relative h-screen w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
<div className="absolute bottom-16 left-8 right-8 md:left-16">
<h1 className="text-5xl font-black tracking-tight md:text-8xl drop-shadow-2xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
{dates}
</p>
)}
{data.description && (
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-300 md:text-lg line-clamp-4">
{data.description}
</p>
)}
</div>
</div>
{sortedImages.length > 1 && (
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="relative aspect-square overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.url} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
)}
</>
)}
{sortedImages.length === 0 && (
<div className="flex min-h-screen items-center justify-center px-8">
<div className="max-w-2xl text-center">
<h1 className="text-5xl font-black tracking-tight md:text-8xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
)}
{data.description && (
<p className="mt-8 text-base leading-relaxed text-zinc-400 whitespace-pre-wrap md:text-lg">
{data.description}
</p>
)}
</div>
</div>
)}
<footer className="py-12 text-center">
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-zinc-500">
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-sm italic text-zinc-500">Во спомен на</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,66 @@
import type { MemorialData } from "@/types";
import { formatDates, MemorialFooter } from "./shared";
export function TemplateElegance({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const heroImage = sortedImages[0];
const gridImages = sortedImages.slice(1);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-stone-50 font-serif">
{heroImage && (
<div className="relative h-[55vh] w-full overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={heroImage.url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/70 via-stone-900/20 to-transparent" />
<div className="absolute bottom-10 left-0 right-0 text-center">
<h1 className="text-4xl font-bold tracking-wide text-white md:text-6xl drop-shadow-lg">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
{dates}
</p>
)}
</div>
</div>
)}
{!heroImage && (
<div className="mx-auto max-w-2xl px-6 pt-24 text-center">
<h1 className="text-4xl font-bold tracking-wide text-stone-900 md:text-6xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
)}
<div className="mx-auto mt-6 h-px w-16 bg-amber-400" />
</div>
)}
<div className="mx-auto max-w-3xl px-6 py-14">
{data.description && (
<div className="text-center">
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap md:text-xl">
{data.description}
</p>
</div>
)}
{gridImages.length > 0 && (
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2">
{gridImages.map((img) => (
<div key={img.id} className="overflow-hidden rounded-lg shadow-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.url} alt="" className="h-64 w-full object-cover transition-transform duration-500 hover:scale-105" />
</div>
))}
</div>
)}
<MemorialFooter name={data.title} />
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import type { MemorialData } from "@/types";
import { formatDates, MemorialFooter } from "./shared";
export function TemplateSerene({ data }: { data: MemorialData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const dates = formatDates(data.bornDate, data.passedDate);
return (
<div className="min-h-screen bg-white">
<div className="mx-auto max-w-xl px-6 py-20 md:py-28">
<div className="text-center">
{sortedImages.length > 0 && (
<div className="mx-auto mb-8 h-40 w-40 overflow-hidden rounded-full shadow-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
</div>
)}
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
{data.title || "Во спомен на"}
</h1>
{dates && (
<p className="mt-3 text-sm tracking-[0.2em] text-zinc-400 uppercase">{dates}</p>
)}
<div className="mx-auto mt-6 h-px w-12 bg-blue-200" />
</div>
{data.description && (
<div className="mt-10 border-l-2 border-blue-100 pl-6">
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap md:text-lg">
{data.description}
</p>
</div>
)}
{sortedImages.length > 1 && (
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
</div>
))}
</div>
)}
<MemorialFooter name={data.title} />
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import type { MemorialData } from "@/types";
import { TemplateElegance } from "./Elegance";
import { TemplateCinematic } from "./Cinematic";
import { TemplateSerene } from "./Serene";
export { TemplateElegance, TemplateCinematic, TemplateSerene };
export { formatDates, MemorialFooter } from "./shared";
export function renderTemplate(templateId: number, data: MemorialData) {
switch (templateId) {
case 1:
return <TemplateElegance data={data} />;
case 2:
return <TemplateCinematic data={data} />;
case 3:
return <TemplateSerene data={data} />;
default:
return <TemplateElegance data={data} />;
}
}

View File

@ -0,0 +1,22 @@
import type { MemorialData } from "@/types";
export function formatDates(born: string | null, passed: string | null): string {
if (born && passed) return `${born} \u2014 ${passed}`;
if (passed) return passed;
if (born) return born;
return "";
}
export function MemorialFooter({ name }: { name: string | null }) {
return (
<footer className="border-t border-stone-200 py-10 text-center">
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-amber-600">
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-sm italic text-stone-400">Во спомен на</p>
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
</footer>
);
}