template refinement

This commit is contained in:
echo 2026-06-20 19:28:05 +02:00
parent 4fdb51f583
commit ff93e8c5be
17 changed files with 837 additions and 305 deletions

334
coolify.md Normal file
View File

@ -0,0 +1,334 @@
# Deploying SpomeniQR on Coolify
This guide covers deploying SpomeniQR to a VPS using [Coolify](https://coolify.io/) — a self-hosting platform similar to Heroku/Vercel that manages Docker deployments, SSL, and databases.
## Prerequisites
- A VPS with Docker installed (Coolify handles this)
- Coolify installed on your VPS (see [coolify.io/docs](https://coolify.io/docs/installation))
- Domain `testbed.mk` with DNS configured:
- **A record** `@` → your VPS IP
- **A record** `*` → your VPS IP (wildcard for subdomains)
- A Contabo S3 bucket set up with public read policy
## Architecture Overview
Coolify will run two containers:
1. **app** — Next.js standalone build (Dockerfile)
2. **db** — PostgreSQL 16 (Coolify managed database)
We do **not** deploy Nginx via Docker — Coolify has its own reverse proxy (Traefik/Caddy) that handles SSL, subdomain routing, and the `X-Subdomain` header.
```
Internet
├── *.testbed.mk ──► Coolify Proxy (:80/:443)
│ ├── Extracts subdomain → sets X-Subdomain header
│ └── Proxies to app:3000
└── testbed.mk ──► Coolify Proxy ──► app:3000
```
## Step 1: Add a New Project in Coolify
1. Open your Coolify dashboard
2. Click **+ Add New Project**
3. Name it `SpomeniQR`
## Step 2: Create the Database
1. Inside the project, click **+ Add New Resource** → **Database**
2. Select **PostgreSQL**
3. Configure:
- **Name:** `spomeniqr-db`
- **PostgreSQL Version:** 16
- **Database Name:** `monuments`
- **Username:** `postgres`
- **Password:** generate a strong password or set your own
4. Click **Deploy**
5. After deployment, note the **Internal Connection String** — it looks like:
```
postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments
```
You'll need this for `DATABASE_URL`.
## Step 3: Add the Application
1. Inside the project, click **+ Add New Resource** → **Application**
2. Select **Public Repository** (or **Private** if your repo is private)
3. Configure:
- **Name:** `spomeniqr`
- **Repository URL:** your Git repo URL
- **Branch:** `main`
- **Build Pack:** **Nixpacks** (auto-detected) or **Docker** (uses the Dockerfile)
### Option A: Nixpacks (Recommended — simpler)
Leave the build pack as **Nixpacks**. Coolify will auto-detect Next.js and build it.
Add these **Build Commands:**
```
npx prisma generate && npm run build
```
Add this **Start Command:**
```
npx prisma migrate deploy && node .next/standalone/server.js
```
### Option B: Docker (uses the project's Dockerfile)
Set **Build Pack** to **Docker**. Coolify will use the `Dockerfile` in the repo root. No additional configuration needed — it already includes Prisma migration and standalone server startup.
**Note:** If using Docker build pack, the `DATABASE_URL` must use `spomeniqr-db` as the host (Coolify internal network), not `localhost`.
## Step 4: Configure Environment Variables
In the application settings, go to **Environment Variables** and add:
```env
# Database — use the Coolify internal connection string
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/
# Contabo S3
S3_ENDPOINT=https://eu2.contabostorage.com
S3_REGION=eu-2
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Node
NODE_ENV=production
```
**Important:**
- `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`.
- Use your **production** Clerk keys (`pk_live_` / `sk_live_`), not the test ones.
## Step 5: Configure Domain & Subdomain Routing
### Set the Main Domain
1. In the application settings, go to **Configuration** → **Domains**
2. Add: `testbed.mk`
3. Enable **HTTPS** — Coolify will auto-provision a Let's Encrypt certificate
### Enable Wildcard Subdomain Routing
This is the critical part. Coolify's proxy needs to:
1. Accept requests for `*.testbed.mk`
2. Extract the subdomain and pass it as the `X-Subdomain` header to the Next.js app
#### Option A: Coolify Proxy Configuration (Recommended)
1. In your application, go to **Configuration** → **Domains**
2. Add both domains:
- `testbed.mk`
- `*.testbed.mk`
3. Coolify will request a wildcard SSL certificate. If your DNS provider supports DNS-01 challenges (Cloudflare, Route53, etc.), this works automatically. Otherwise, you may need to add each subdomain manually.
#### Option B: Custom Proxy Configuration
If Coolify doesn't support wildcard domains easily, add a **custom Caddy/Traefik configuration** in the Coolify settings:
For **Caddy** (Coolify's default proxy):
Create a file at `/data/coolify/proxy/caddy/custom/testbed.mk`:
```
*.testbed.mk {
reverse_proxy app:3000 {
header_up X-Subdomain {http.request.host.labels.2}
header_up X-Forwarded-Proto {scheme}
}
}
testbed.mk {
reverse_proxy app:3000 {
header_up X-Forwarded-Proto {scheme}
}
}
```
For **Traefik** (alternative proxy), you'd add labels to the container:
```yaml
traefik.http.routers.spomeniqr.rule: HostRegexp(`{subdomain:[a-z0-9-]+}.testbed.mk`) || Host(`testbed.mk`)
traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subdomain:
```
> **Note:** The proxy configuration varies based on your Coolify version and proxy choice. Check the Coolify docs for the latest instructions on wildcard domains.
## Step 6: Configure Clerk
1. Go to [Clerk Dashboard](https://dashboard.clerk.com)
2. Switch to your **Production** instance
3. Under **Paths**, set:
- Sign-in: `/sign-in`
- Sign-up: `/sign-up`
4. Under **Domains**, add:
- `testbed.mk`
- `*.testbed.mk`
## Step 7: Configure Contabo S3
1. Log into Contabo Object Storage
2. Create a bucket named `monuments-images` in region `eu-2`
3. Set the **bucket policy** for public read:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::monuments-images/*"]
}
]
}
```
4. Set **CORS** to allow uploads:
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
5. Create an API key with read/write permissions
## Step 8: Deploy
1. Click **Deploy** in the Coolify dashboard
2. Watch the build logs — it should:
- Install dependencies
- Run `npx prisma generate`
- Build the Next.js app
- Run `npx prisma migrate deploy`
- Start the server on port 3000
3. Once deployed, visit `https://testbed.mk` to verify
## Step 9: Verify Subdomain Routing
Test that wildcard subdomains work:
1. Create a memorial page with subdomain `test-memorial`
2. Visit `https://test-memorial.testbed.mk`
3. Check browser DevTools network tab — the `X-Subdomain` header should be set by the proxy
4. The Next.js middleware reads `X-Subdomain` and rewrites to the correct page
If subdomains aren't working:
- Check DNS: `dig *.testbed.mk +short` should return your VPS IP
- Check Coolify proxy logs for the wildcard domain configuration
- Verify the `X-Subdomain` header is being set in the proxy config
## Step 10: Verify Everything
```bash
# App health
curl -s https://testbed.mk | head -5
# Subdomain routing
curl -sI https://test-memorial.testbed.mk | head -5
# API health
curl -s https://testbed.mk/api/check-subdomain?slug=test | python3 -m json.tool
# Database connection (from Coolify terminal)
docker exec spomeniqr-app npx prisma db push --accept-data-loss
```
## Troubleshooting
### Build fails with Prisma errors
Make sure `DATABASE_URL` points to the Coolify internal hostname (e.g., `spomeniqr-db:5432`), not `localhost`. The app and database must be on the same Coolify network.
### Images return 401 from S3
Make sure you've applied the bucket policy for public read. See Step 7. If Contabo doesn't serve public objects via URL, the app uses an `/api/image?key=...` proxy route as a fallback.
### Subdomain routing not working
- Verify DNS wildcard `*.testbed.mk` points to your VPS
- Check that Coolify's proxy config includes both `testbed.mk` and `*.testbed.mk`
- Check the proxy access logs — the `X-Subdomain` header should appear
- If using a custom Caddyfile, make sure it's in the right directory and reload the proxy
### Clerk authentication issues
- Ensure production Clerk keys are set (not `pk_test_` / `sk_test_`)
- Verify `testbed.mk` and `*.testbed.mk` are added in Clerk dashboard domains
- Check that `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_DOMAIN` are set correctly
### Database migration issues
If migrations fail on deploy, you can run them manually from the Coolify terminal:
```bash
# SSH into the app container
docker exec -it spomeniqr-app sh
# Run migrations
npx prisma migrate deploy
# Or push schema changes directly
npx prisma db push
```
## Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string (Coolify internal) |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (`pk_live_...`) |
| `CLERK_SECRET_KEY` | Yes | Clerk secret key (`sk_live_...`) |
| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | Yes | `/sign-in` |
| `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | Yes | `/sign-up` |
| `S3_ENDPOINT` | Yes | Contabo S3 endpoint URL |
| `S3_REGION` | Yes | Contabo S3 region (e.g. `eu-2`) |
| `S3_ACCESS_KEY_ID` | Yes | S3 access key |
| `S3_SECRET_ACCESS_KEY` | Yes | S3 secret key |
| `S3_BUCKET_NAME` | Yes | S3 bucket name |
| `NEXT_PUBLIC_APP_URL` | Yes | `https://testbed.mk` |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | `testbed.mk` |
| `NODE_ENV` | Yes | `production` |
## Useful Coolify Commands
- **Redeploy:** Project → Application → Deploy
- **View Logs:** Project → Application → Logs
- **SSH into container:** Project → Application → Terminal
- **Database Management:** Project → Database → Admin (pgAdmin or Prisma Studio)
- **SSL Certificates:** Managed automatically by Coolify for configured domains
## Updates
To update the application:
1. Push changes to your Git repository
2. Coolify will auto-deploy if **Auto Deploy** is enabled, or click **Deploy** manually
3. The `start.sh` script runs `npx prisma migrate deploy` on every startup, so schema changes are applied automatically

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bornDate" TEXT,
ADD COLUMN "passedDate" TEXT;

View File

@ -16,6 +16,8 @@ model User {
templateId Int @default(1)
title String?
description String?
bornDate String?
passedDate String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -15,11 +15,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
});
if (!user || !user.published) {
return { title: "Monument Not Found" };
return { title: "Memorial Not Found" };
}
const title = user.title || "City Monument";
const description = user.description?.slice(0, 160) || `Visit ${title} — a monument page on SpomeniQR.`;
const title = user.title || "Memorial";
const description = user.description?.slice(0, 160) || `In Loving Memory of ${title}`;
return {
title,
@ -33,7 +33,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
export default async function MonumentPage({ params }: Props) {
export default async function MemorialPage({ params }: Props) {
const { subdomain } = await params;
const user = await prisma.user.findUnique({
@ -49,6 +49,8 @@ export default async function MonumentPage({ params }: Props) {
id: user.id,
title: user.title,
description: user.description,
bornDate: user.bornDate,
passedDate: user.passedDate,
subdomain: user.subdomain,
templateId: user.templateId,
published: user.published,

View File

@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { title, description, subdomain, templateId, images } = body;
const { title, description, bornDate, passedDate, subdomain, templateId, images } = body;
if (!title?.trim()) {
return NextResponse.json({ error: "Title is required" }, { status: 400 });
@ -45,6 +45,8 @@ export async function POST(req: NextRequest) {
data: {
title,
description,
bornDate: bornDate || null,
passedDate: passedDate || null,
subdomain,
templateId,
published: true,
@ -64,6 +66,8 @@ export async function POST(req: NextRequest) {
clerkId: userId,
title,
description,
bornDate: bornDate || null,
passedDate: passedDate || null,
subdomain,
templateId,
published: true,

View File

@ -62,13 +62,15 @@ export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const { title, description, templateId, published } = body;
const { title, description, bornDate, passedDate, templateId, published } = body;
const user = await prisma.user.update({
where: { clerkId: userId },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(bornDate !== undefined && { bornDate }),
...(passedDate !== undefined && { passedDate }),
...(templateId !== undefined && { templateId }),
...(published !== undefined && { published }),
},

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import ImageUploader from "@/components/ImageUploader";
import TemplatePicker from "@/components/TemplatePicker";
interface MonumentImage {
id: string;
@ -15,14 +16,17 @@ interface EditData {
title: string;
description: string;
templateId: number;
bornDate: string;
passedDate: string;
}
export default function EditPage() {
const router = useRouter();
const [data, setData] = useState<EditData>({ title: "", description: "", templateId: 1 });
const [data, setData] = useState<EditData>({ title: "", description: "", templateId: 1, bornDate: "", passedDate: "" });
const [images, setImages] = useState<MonumentImage[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
fetch("/api/user/monument")
@ -32,6 +36,8 @@ export default function EditPage() {
title: d.user.title || "",
description: d.user.description || "",
templateId: d.user.templateId || 1,
bornDate: d.user.bornDate || "",
passedDate: d.user.passedDate || "",
});
setImages(d.user.images || []);
})
@ -69,20 +75,29 @@ export default function EditPage() {
return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
}
const imagePreviews = images.map((img) => ({ url: img.url, order: img.order }));
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-2xl px-6 py-4">
<h1 className="text-xl font-semibold text-stone-900">Edit Monument</h1>
<div className="mx-auto max-w-6xl px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-semibold text-stone-900">Edit Memorial</h1>
<button
onClick={() => setShowPreview(!showPreview)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 lg:hidden"
>
{showPreview ? "Edit" : "Preview"}
</button>
</div>
</header>
<main className="mx-auto max-w-2xl px-6 py-8">
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="flex gap-8">
{/* Form - hidden on mobile when preview is shown */}
<div className={`flex-1 min-w-0 ${showPreview ? "hidden lg:block" : ""}`}>
<div className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
Monument Name
</label>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">Name</label>
<input
id="title"
type="text"
@ -93,10 +108,35 @@ export default function EditPage() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
Description
</label>
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">Born</label>
<input
id="bornDate"
type="text"
value={data.bornDate}
onChange={(e) => setData({ ...data, bornDate: e.target.value })}
placeholder="e.g. 1960"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={50}
/>
</div>
<div>
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">Passed Away</label>
<input
id="passedDate"
type="text"
value={data.passedDate}
onChange={(e) => setData({ ...data, passedDate: e.target.value })}
placeholder="e.g. 2024"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={50}
/>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-stone-700">Epitaph / Life Story</label>
<textarea
id="description"
value={data.description}
@ -111,9 +151,9 @@ export default function EditPage() {
<label className="block text-sm font-medium text-stone-700 mb-2">Template</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 1, name: "Classic" },
{ id: 2, name: "Modern" },
{ id: 3, name: "Minimal" },
{ id: 1, name: "Elegance" },
{ id: 2, name: "Cinematic" },
{ id: 3, name: "Serene" },
].map((t) => (
<button
key={t.id}
@ -131,7 +171,7 @@ export default function EditPage() {
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">Current Images</label>
<label className="block text-sm font-medium text-stone-700 mb-2">Photos</label>
{images.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{images.map((img) => (
@ -147,38 +187,50 @@ export default function EditPage() {
))}
</div>
) : (
<p className="text-sm text-stone-400">No images yet.</p>
<p className="text-sm text-stone-400">No photos yet.</p>
)}
{images.length < 3 && (
<div className="mt-4">
<ImageUploader
images={images.map((img) => ({ key: img.key, order: img.order }))}
onImagesChange={(newImages) => {
router.refresh();
}}
onImagesChange={() => router.refresh()}
/>
</div>
)}
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:opacity-50"
>
<button onClick={handleSave} disabled={saving} className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:opacity-50">
{saving ? "Saving..." : "Save Changes"}
</button>
<button
onClick={() => router.push("/dashboard")}
className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
<button onClick={() => router.push("/dashboard")} className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50">
Cancel
</button>
</div>
</div>
</main>
</div>
{/* Live preview - always visible on desktop, toggle on mobile */}
<div className={`w-[420px] flex-shrink-0 ${showPreview ? "" : "hidden lg:block"}`}>
<div className="sticky top-8">
<p className="mb-2 text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
<div className="h-[600px] overflow-y-auto">
<TemplatePicker
value={data.templateId}
onChange={(id) => setData({ ...data, templateId: id })}
title={data.title}
description={data.description}
bornDate={data.bornDate}
passedDate={data.passedDate}
images={imagePreviews}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -33,7 +33,12 @@ export default async function DashboardPage() {
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="rounded-lg border border-stone-200 bg-white p-6">
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Untitled Monument"}</h2>
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Untitled Memorial"}</h2>
{(user.bornDate || user.passedDate) && (
<p className="mt-1 text-sm tracking-wider text-stone-400">
{user.bornDate}{user.bornDate && user.passedDate ? " — " : ""}{user.passedDate}
</p>
)}
<p className="mt-2 text-sm text-stone-500">
{user.published ? "Published" : "Draft"} &middot; Subdomain: <span className="font-mono">{user.subdomain}</span>
</p>

View File

@ -12,6 +12,7 @@
--color-surface: #f9fafb;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-serif: Georgia, "Times New Roman", serif;
}
body {

View File

@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "SpomeniQR — City Monuments Memories",
description: "Create beautiful monument pages with QR codes. Share memories of city monuments with the world.",
title: "SpomeniQR — In Loving Memory",
description: "Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.",
};
export default function RootLayout({

View File

@ -3,8 +3,8 @@ import Link from "next/link";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6">
<h1 className="text-4xl font-bold text-stone-900">Monument Not Found</h1>
<p className="mt-4 text-stone-600">This monument page does not exist or has not been published yet.</p>
<h1 className="text-4xl font-bold text-stone-900">Memorial Not Found</h1>
<p className="mt-4 text-stone-600">This memorial page does not exist or has not been published yet.</p>
<Link
href="/"
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"

View File

@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader";
import SubdomainPicker from "@/components/SubdomainPicker";
import TemplatePicker from "@/components/TemplatePicker";
const STEPS = ["Details", "Photos", "Subdomain", "Template"] as const;
const STEPS = ["Details", "Dates", "Photos", "Subdomain", "Template"] as const;
export default function OnboardingWizard() {
const router = useRouter();
@ -16,22 +16,20 @@ export default function OnboardingWizard() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<{ key: string; order: number }[]>([]);
const [bornDate, setBornDate] = useState("");
const [passedDate, setPassedDate] = useState("");
const [images, setImages] = useState<{ key: string; order: number; url?: string }[]>([]);
const [subdomain, setSubdomain] = useState("");
const [templateId, setTemplateId] = useState(1);
const canProceed = () => {
switch (step) {
case 0:
return title.trim().length > 0;
case 1:
return images.length > 0;
case 2:
return subdomain.length >= 3;
case 3:
return templateId >= 1 && templateId <= 3;
default:
return false;
case 0: return title.trim().length > 0;
case 1: return true;
case 2: return images.length > 0;
case 3: return subdomain.length >= 3;
case 4: return templateId >= 1 && templateId <= 3;
default: return false;
}
};
@ -45,9 +43,11 @@ export default function OnboardingWizard() {
body: JSON.stringify({
title,
description,
bornDate: bornDate || undefined,
passedDate: passedDate || undefined,
subdomain,
templateId,
images,
images: images.map(({ key, order }) => ({ key, order })),
}),
});
if (!res.ok) {
@ -62,34 +62,34 @@ export default function OnboardingWizard() {
}
};
const imagePreviews = images
.filter((img) => img.url)
.map((img) => ({ url: img.url!, order: img.order }));
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-2xl px-6 py-4">
<h1 className="text-xl font-semibold text-stone-900">Create Your Monument Page</h1>
<h1 className="text-xl font-semibold text-stone-900">Create a Memorial Page</h1>
</div>
</header>
<div className="mx-auto max-w-2xl px-6 py-8">
<div className="mb-8 flex gap-2">
<div className="mb-8 flex gap-1">
{STEPS.map((label, i) => (
<div key={label} className="flex flex-1 items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
i <= step ? "bg-primary text-white" : "bg-stone-200 text-stone-500"
}`}
>
{i + 1}
</div>
<span
className={`ml-2 hidden text-sm sm:inline ${
i <= step ? "text-stone-900" : "text-stone-400"
}`}
>
<span className={`ml-1.5 hidden text-xs sm:inline ${i <= step ? "text-stone-900" : "text-stone-400"}`}>
{label}
</span>
{i < STEPS.length - 1 && (
<div className={`mx-2 h-px flex-1 ${i < step ? "bg-primary" : "bg-stone-200"}`} />
<div className={`mx-1 h-px flex-1 ${i < step ? "bg-primary" : "bg-stone-200"}`} />
)}
</div>
))}
@ -103,29 +103,29 @@ export default function OnboardingWizard() {
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
Monument Name
Name
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Eiffel Tower"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="e.g. Maria Novakova"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={100}
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
Description
Epitaph / Life Story
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Tell the story of this monument..."
rows={5}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="A few words, a poem, or a biography to honor their memory..."
rows={6}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={2000}
/>
</div>
@ -133,18 +133,62 @@ export default function OnboardingWizard() {
)}
{step === 1 && (
<ImageUploader
images={images}
onImagesChange={setImages}
<div className="space-y-4">
<p className="text-sm text-stone-500">
These are optional. You can enter exact dates, approximate years, or leave them blank.
</p>
<div>
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">
Born
</label>
<input
id="bornDate"
type="text"
value={bornDate}
onChange={(e) => setBornDate(e.target.value)}
placeholder="e.g. 1960 or March 15, 1960"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={50}
/>
</div>
<div>
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">
Passed Away
</label>
<input
id="passedDate"
type="text"
value={passedDate}
onChange={(e) => setPassedDate(e.target.value)}
placeholder="e.g. 2024 or November 20, 2024"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={50}
/>
</div>
</div>
)}
{step === 2 && (
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
<ImageUploader
images={images}
onImagesChange={(newImages) => setImages(newImages as typeof images)}
/>
)}
{step === 3 && (
<TemplatePicker value={templateId} onChange={setTemplateId} />
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
)}
{step === 4 && (
<TemplatePicker
value={templateId}
onChange={setTemplateId}
title={title}
description={description}
bornDate={bornDate}
passedDate={passedDate}
images={imagePreviews}
/>
)}
<div className="mt-8 flex justify-between">
@ -170,9 +214,9 @@ export default function OnboardingWizard() {
<button
onClick={handlePublish}
disabled={!canProceed() || loading}
className="rounded-lg bg-accent px-6 py-2 text-sm font-medium text-primary transition-colors hover:bg-accent-light disabled:cursor-not-allowed disabled:opacity-50"
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Publishing..." : "Publish Monument"}
{loading ? "Publishing..." : "Publish Memorial"}
</button>
)}
</div>

View File

@ -15,12 +15,12 @@ export default async function HomePage() {
<main className="flex flex-1 items-center justify-center px-6">
<div className="mx-auto max-w-3xl text-center">
<div className="mb-6 text-6xl">&#127963;</div>
<div className="mb-6 text-5xl">&#128142;</div>
<h2 className="text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
City Monuments Memories
In Loving Memory
</h2>
<p className="mt-4 text-lg text-stone-600">
Create beautiful monument pages with QR codes. Share memories of city monuments with the world.
Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.
</p>
<div className="mt-8">
<LandingAuth />
@ -29,25 +29,25 @@ export default async function HomePage() {
<div className="mt-16 grid grid-cols-1 gap-8 text-left sm:grid-cols-3">
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#9997;</div>
<h3 className="font-semibold text-stone-900">Describe Your Monument</h3>
<p className="mt-1 text-sm text-stone-500">Write the story of any city monument and share it with visitors.</p>
<h3 className="font-semibold text-stone-900">Tell Their Story</h3>
<p className="mt-1 text-sm text-stone-500">Write an epitaph, biography, or poem to honor their memory.</p>
</div>
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#128247;</div>
<h3 className="font-semibold text-stone-900">Upload Photos</h3>
<p className="mt-1 text-sm text-stone-500">Add up to 3 beautiful photos to showcase the monument.</p>
<h3 className="font-semibold text-stone-900">Share Photos</h3>
<p className="mt-1 text-sm text-stone-500">Add up to 3 photos that capture their spirit and legacy.</p>
</div>
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#128241;</div>
<h3 className="font-semibold text-stone-900">Get a QR Code</h3>
<p className="mt-1 text-sm text-stone-500">Each monument gets a unique subdomain and QR code for easy access.</p>
<h3 className="font-semibold text-stone-900">QR Code at the Grave</h3>
<p className="mt-1 text-sm text-stone-500">Visitors scan the QR code to read their story, right at the memorial site.</p>
</div>
</div>
</div>
</main>
<footer className="border-t border-stone-200 py-6 text-center text-sm text-stone-400">
SpomeniQR City Monuments Memories
SpomeniQR In Loving Memory
</footer>
</div>
);

View File

@ -0,0 +1,49 @@
"use client";
import { TemplateElegance, TemplateCinematic, TemplateSerene } from "@/lib/templates";
import type { MemorialData } from "@/types";
interface MemorialPreviewProps {
templateId: number;
title: string;
description: string;
bornDate: string;
passedDate: string;
images: { url: string; order: number }[];
}
const PLACEHOLDER_IMAGES = [
{ id: "p1", url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23d4d4d8' width='800' height='600'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%2371717a'%3EPhoto%3C/text%3E%3C/svg%3E", key: "p1", order: 1 },
{ id: "p2", url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23a1a1aa' width='800' height='600'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%23ffffff'%3EPhoto%3C/text%3E%3C/svg%3E", key: "p2", order: 2 },
];
export default function MemorialPreview({
templateId,
title,
description,
bornDate,
passedDate,
images,
}: MemorialPreviewProps) {
const data: MemorialData = {
id: "preview",
title: title || "Name Surname",
description: description || "A beloved soul who touched the lives of many and will forever be remembered.",
bornDate: bornDate || null,
passedDate: passedDate || null,
subdomain: "preview",
templateId,
published: true,
images: images.length > 0
? images.map((img, i) => ({ id: `img-${i}`, url: img.url, key: `key-${i}`, order: img.order }))
: PLACEHOLDER_IMAGES,
};
return (
<div className="pointer-events-none select-none">
{templateId === 1 && <TemplateElegance data={data} />}
{templateId === 2 && <TemplateCinematic data={data} />}
{templateId === 3 && <TemplateSerene data={data} />}
</div>
);
}

View File

@ -1,86 +1,62 @@
"use client";
import MemorialPreview from "./MemorialPreview";
interface TemplatePickerProps {
value: number;
onChange: (value: number) => void;
title: string;
description: string;
bornDate: string;
passedDate: string;
images: { url: string; order: number }[];
}
const TEMPLATES = [
{
id: 1,
name: "Classic",
description: "Clean serif typography with a historical archive feel. Hero image with description and photo grid.",
preview: (
<div className="space-y-2 p-3">
<div className="h-12 w-full rounded bg-stone-200" />
<div className="space-y-1">
<div className="h-1.5 w-3/4 rounded bg-stone-300" />
<div className="h-1.5 w-full rounded bg-stone-200" />
<div className="h-1.5 w-full rounded bg-stone-200" />
</div>
<div className="flex gap-1.5">
<div className="h-8 w-8 rounded bg-stone-200" />
<div className="h-8 w-8 rounded bg-stone-200" />
</div>
</div>
),
},
{
id: 2,
name: "Modern",
description: "Bold, full-bleed images with dark overlay. Cinematic and immersive feel.",
preview: (
<div className="space-y-2 p-3">
<div className="h-16 w-full rounded bg-zinc-800" />
<div className="flex gap-1.5">
<div className="h-6 w-6 rounded bg-zinc-700" />
<div className="h-6 w-6 rounded bg-zinc-700" />
</div>
</div>
),
},
{
id: 3,
name: "Minimal",
description: "Whitespace-heavy, single-column journal style. Clean and elegant.",
preview: (
<div className="space-y-2 p-3">
<div className="h-2 w-1/2 rounded bg-stone-300" />
<div className="space-y-1">
<div className="h-1.5 w-full rounded bg-stone-100" />
<div className="h-1.5 w-2/3 rounded bg-stone-100" />
</div>
<div className="flex gap-1">
<div className="h-4 w-8 rounded bg-stone-100" />
<div className="h-4 w-8 rounded bg-stone-100" />
<div className="h-4 w-8 rounded bg-stone-100" />
</div>
</div>
),
},
{ id: 1, name: "Elegance", tagline: "Serif warmth, dignified tribute" },
{ id: 2, name: "Cinematic", tagline: "Bold, immersive, full-screen" },
{ id: 3, name: "Serene", tagline: "Peaceful, centered, spiritual" },
];
export default function TemplatePicker({ value, onChange }: TemplatePickerProps) {
export default function TemplatePicker({ value, onChange, title, description, bornDate, passedDate, images }: TemplatePickerProps) {
return (
<div className="space-y-3">
<p className="text-sm text-stone-600">Choose how your monument page will look.</p>
<div className="space-y-4">
<p className="text-sm text-stone-600">Choose how the memorial page will look. Scroll the preview to see the full page.</p>
<div className="grid grid-cols-3 gap-3">
{TEMPLATES.map((t) => (
<button
key={t.id}
onClick={() => onChange(t.id)}
className={`rounded-lg border-2 p-1 text-left transition-colors ${
className={`rounded-lg border-2 px-3 py-2 text-left transition-colors ${
value === t.id
? "border-primary bg-primary/5"
: "border-stone-200 hover:border-stone-300"
}`}
>
<div className="mb-2 rounded bg-white">{t.preview}</div>
<p className="px-2 text-sm font-medium text-stone-900">{t.name}</p>
<p className="mt-0.5 px-2 text-xs text-stone-500">{t.description}</p>
<p className="text-sm font-semibold text-stone-900">{t.name}</p>
<p className="mt-0.5 text-xs text-stone-500">{t.tagline}</p>
</button>
))}
</div>
{value && (
<div className="space-y-2">
<p className="text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
<div className="h-[500px] overflow-y-auto">
<MemorialPreview
templateId={value}
title={title}
description={description}
bornDate={bornDate}
passedDate={passedDate}
images={images}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,161 +1,215 @@
import type { MonumentData } from "@/types";
import type { MemorialData } from "@/types";
export function renderTemplate(templateId: number, data: MonumentData) {
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">In Loving Memory</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 <TemplateClassic data={data} />;
return <TemplateElegance data={data} />;
case 2:
return <TemplateModern data={data} />;
return <TemplateCinematic data={data} />;
case 3:
return <TemplateMinimal data={data} />;
return <TemplateSerene data={data} />;
default:
return <TemplateClassic data={data} />;
return <TemplateElegance data={data} />;
}
}
function TemplateClassic({ data }: { data: MonumentData }) {
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">
<div className="min-h-screen bg-stone-50 font-serif">
{heroImage && (
<div className="relative h-[50vh] w-full overflow-hidden">
<img
src={heroImage.url}
alt={data.title || "Monument"}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/60 to-transparent" />
<div className="absolute bottom-8 left-8">
<h1 className="font-serif text-4xl font-bold text-white md:text-5xl">
{data.title}
<div className="relative h-[55vh] w-full overflow-hidden">
<img src={heroImage.url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
<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 || "In Loving Memory"}
</h1>
{dates && (
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
{dates}
</p>
)}
</div>
</div>
)}
<div className="mx-auto max-w-4xl px-6 py-12">
<div className="prose prose-lg prose-stone mx-auto">
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap">
{!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 || "In Loving Memory"}
</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-12 grid grid-cols-1 gap-4 sm:grid-cols-2">
<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-lg">
<img
src={img.url}
alt={data.title || "Monument"}
className="h-64 w-full object-cover transition-transform duration-300 hover:scale-105"
/>
<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>
)}
<footer className="mt-16 border-t border-stone-200 pt-8 text-center text-sm text-stone-400">
<p>Scan the QR code to visit this monument page</p>
</footer>
<MemorialFooter name={data.title} />
</div>
</div>
);
}
function TemplateModern({ data }: { data: MonumentData }) {
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 || "Monument"}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-12 left-8 right-8">
<h1 className="text-5xl font-black tracking-tight md:text-7xl">
{data.title}
<img src={sortedImages[0].url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
<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 || "In Loving Memory"}
</h1>
<p className="mt-4 max-w-xl text-lg text-zinc-300 line-clamp-3">
{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 || "In Loving Memory"}
</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">In Loving Memory</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 || "Memorial"} className="h-full w-full object-cover" />
</div>
)}
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
{data.title || "In Loving Memory"}
</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="grid grid-cols-2 gap-1 md:grid-cols-3">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="relative aspect-square overflow-hidden">
<img
src={img.url}
alt={data.title || "Monument"}
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">
<h1 className="text-5xl font-black tracking-tight md:text-7xl">
{data.title}
</h1>
<p className="mt-6 text-lg text-zinc-400 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
)}
<footer className="px-8 py-12 text-center text-sm text-zinc-600">
<p>Scan the QR code to visit this monument page</p>
</footer>
</div>
);
}
function TemplateMinimal({ data }: { data: MonumentData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
return (
<div className="min-h-screen bg-white">
<div className="mx-auto max-w-2xl px-6 py-24">
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-4xl">
{data.title}
</h1>
<div className="mt-8 border-t border-zinc-100 pt-8">
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap">
{data.description}
</p>
</div>
{sortedImages.length > 0 && (
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
{sortedImages.map((img) => (
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="flex-shrink-0">
<img
src={img.url}
alt={data.title || "Monument"}
className="h-72 w-auto rounded-sm object-cover"
/>
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
</div>
))}
</div>
)}
<footer className="mt-24 border-t border-zinc-100 pt-8 text-center text-xs text-zinc-400">
<p>Scan the QR code to visit this monument page</p>
</footer>
<MemorialFooter name={data.title} />
</div>
</div>
);

View File

@ -1,7 +1,9 @@
export interface MonumentData {
export interface MemorialData {
id: string;
title: string | null;
description: string | null;
bornDate: string | null;
passedDate: string | null;
subdomain: string;
templateId: number;
published: boolean;
@ -18,6 +20,8 @@ export interface ImageData {
export interface PublishPayload {
title: string;
description: string;
bornDate?: string;
passedDate?: string;
subdomain: string;
templateId: number;
images: { key: string; order: number }[];