This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public"
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NEXT_TELEMETRY_DISABLED="1"
|
||||||
|
ADMIN_PASSWORD="123Yolo!321"
|
||||||
|
ADMIN_AUTH_SECRET="Us0z76jwlTQLOeQWAGGxAxDcc0rHwp4q"
|
||||||
|
ROOT_BASIC_AUTH_USER="root"
|
||||||
|
ROOT_BASIC_AUTH_PASS="123Yolo!321"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: npm run prisma:generate
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
@@ -26,6 +26,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.PHONY: start stop restart logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-seed prisma-generate prisma-migrate help
|
||||||
|
|
||||||
|
MIGRATION_NAME ?= init
|
||||||
|
|
||||||
|
start:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
restart: stop start
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f --tail=200
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
port:
|
||||||
|
docker compose port app 3000
|
||||||
|
|
||||||
|
clean-orphans:
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
app-shell:
|
||||||
|
docker compose exec app sh
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U postgres -d moh_sass
|
||||||
|
|
||||||
|
db-init:
|
||||||
|
docker compose exec app sh -lc "npx prisma generate && npx prisma migrate deploy && npx prisma db seed"
|
||||||
|
|
||||||
|
db-migrate:
|
||||||
|
docker compose exec app npx prisma migrate deploy
|
||||||
|
|
||||||
|
db-seed:
|
||||||
|
docker compose exec app npx prisma db seed
|
||||||
|
|
||||||
|
prisma-generate:
|
||||||
|
docker compose exec app npx prisma generate
|
||||||
|
|
||||||
|
prisma-migrate:
|
||||||
|
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
|
||||||
|
|
||||||
|
health:
|
||||||
|
@PORT=$$(docker compose port app 3000 | sed 's/.*://'); \
|
||||||
|
curl -sS "http://localhost:$$PORT/api/health"
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " make start Start all containers"
|
||||||
|
@echo " make stop Stop and remove containers"
|
||||||
|
@echo " make restart Restart all containers"
|
||||||
|
@echo " make logs Follow container logs"
|
||||||
|
@echo " make build Build images"
|
||||||
|
@echo " make ps Show container status"
|
||||||
|
@echo " make port Show random host port for app"
|
||||||
|
@echo " make clean-orphans Remove orphaned old containers"
|
||||||
|
@echo " make app-shell Open shell in app container"
|
||||||
|
@echo " make db-shell Open PostgreSQL shell"
|
||||||
|
@echo " make db-init Generate client, apply migrations, run seed"
|
||||||
|
@echo " make db-migrate Apply prisma migrations"
|
||||||
|
@echo " make db-seed Seed database data"
|
||||||
|
@echo " make prisma-generate Run prisma generate"
|
||||||
|
@echo " make prisma-migrate Create/apply dev migration"
|
||||||
|
@echo " make health Check app health endpoint"
|
||||||
@@ -34,3 +34,37 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
|
## Infrastructure Basics
|
||||||
|
|
||||||
|
1. Copy environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start
|
||||||
|
make port
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Initialize database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db-init
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run quality checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Health check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make health
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Compass, Layers3, Users } from "lucide-react";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type AboutPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutPage({ params: { locale } }: AboutPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Ueber uns",
|
||||||
|
intro:
|
||||||
|
"Wir bauen klare digitale Erlebnisse fuer Marken, Produkte und Teams.",
|
||||||
|
valuesTitle: "Wie wir arbeiten",
|
||||||
|
valueA: "Strategie zuerst",
|
||||||
|
valueAText: "Jedes Projekt startet mit Zielbild, Scope und Prioritaeten.",
|
||||||
|
valueB: "Saubere Systeme",
|
||||||
|
valueBText: "Wir setzen auf wartbare Komponenten und klare Strukturen.",
|
||||||
|
valueC: "Enge Zusammenarbeit",
|
||||||
|
valueCText: "Kurze Schleifen mit direktem Feedback im gesamten Ablauf.",
|
||||||
|
processTitle: "Unser Ablauf",
|
||||||
|
processOne: "Discovery und Zieldefinition",
|
||||||
|
processTwo: "Design und Prototyping",
|
||||||
|
processThree: "Build, Test und Launch",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "About",
|
||||||
|
intro:
|
||||||
|
"We build clear digital experiences for brands, products and teams.",
|
||||||
|
valuesTitle: "How we work",
|
||||||
|
valueA: "Strategy first",
|
||||||
|
valueAText: "Each project starts with goals, scope and priorities.",
|
||||||
|
valueB: "Clean systems",
|
||||||
|
valueBText: "We rely on maintainable components and clear structure.",
|
||||||
|
valueC: "Close collaboration",
|
||||||
|
valueCText: "Short loops with direct feedback across the full process.",
|
||||||
|
processTitle: "Our process",
|
||||||
|
processOne: "Discovery and goal definition",
|
||||||
|
processTwo: "Design and prototyping",
|
||||||
|
processThree: "Build, test and launch",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-3xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.intro}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<h2 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.valuesTitle}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Compass className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<h3 className="mt-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.valueA}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.valueAText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Layers3 className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<h3 className="mt-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.valueB}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.valueBText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Users className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<h3 className="mt-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.valueC}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.valueCText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<h2 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.processTitle}
|
||||||
|
</h2>
|
||||||
|
<ol className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||||
|
{[copy.processOne, copy.processTwo, copy.processThree].map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={step}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-zinc-200 text-xs font-semibold text-zinc-800 dark:bg-zinc-800 dark:text-zinc-100">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<p>{step}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type ContactPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactPage({ params: { locale } }: ContactPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Kontakt",
|
||||||
|
intro: "Schreib uns kurz dein Ziel und wir melden uns zeitnah.",
|
||||||
|
name: "Name",
|
||||||
|
email: "E-Mail",
|
||||||
|
message: "Nachricht",
|
||||||
|
submit: "Senden",
|
||||||
|
preview: "Success Seite ansehen",
|
||||||
|
phone: "+49 30 123456",
|
||||||
|
mail: "hello@moh-sass.dev",
|
||||||
|
city: "Berlin, Germany",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Contact",
|
||||||
|
intro: "Share your goal and we will get back quickly.",
|
||||||
|
name: "Name",
|
||||||
|
email: "Email",
|
||||||
|
message: "Message",
|
||||||
|
submit: "Submit",
|
||||||
|
preview: "Open success page",
|
||||||
|
phone: "+49 30 123456",
|
||||||
|
mail: "hello@moh-sass.dev",
|
||||||
|
city: "Berlin, Germany",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-6xl gap-6 px-4 py-10 sm:px-6 lg:grid-cols-2 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.intro}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-6 space-y-3 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
<li className="inline-flex items-center gap-2">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
{copy.phone}
|
||||||
|
</li>
|
||||||
|
<li className="inline-flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{copy.mail}
|
||||||
|
</li>
|
||||||
|
<li className="inline-flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
{copy.city}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<form className="grid gap-4">
|
||||||
|
<label className="grid gap-2 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
{copy.name}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
placeholder={copy.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
{copy.email}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
placeholder={copy.email}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
{copy.message}
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
placeholder={copy.message}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{copy.submit}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/success`}
|
||||||
|
className="inline-flex rounded-lg border border-zinc-300 px-4 py-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{copy.preview}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Footer } from "@/components/footer";
|
||||||
|
import { Navbar } from "@/components/navbar";
|
||||||
|
|
||||||
|
type SiteLayoutProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SiteLayout({ children }: SiteLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BriefcaseBusiness,
|
||||||
|
Boxes,
|
||||||
|
Mail,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { pickText, portfolioItems, productItems, resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type HomePageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HomePage({ params: { locale } }: HomePageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
const featuredProjects = portfolioItems.slice(0, 3);
|
||||||
|
const featuredProducts = productItems.slice(0, 3);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
heroKicker: "Digital Studio",
|
||||||
|
heroTitle: "Webseiten und Produkte, die schnell liefern.",
|
||||||
|
heroText:
|
||||||
|
"Diese Startseite ist die Basis fuer ein mehrsprachiges Marketing- und Produkt-Setup.",
|
||||||
|
portfolioTitle: "Featured Projects",
|
||||||
|
portfolioText: "Platzhalter fuer ausgewaehlte Kundenprojekte.",
|
||||||
|
productsTitle: "Featured Products",
|
||||||
|
productsText: "Platzhalter fuer die wichtigsten Produktangebote.",
|
||||||
|
ctaTitle: "Bereit fuer den naechsten Schritt?",
|
||||||
|
ctaText: "Wir planen zusammen den passenden Scope fuer dein Projekt.",
|
||||||
|
toPortfolio: "Portfolio ansehen",
|
||||||
|
toProducts: "Produkte ansehen",
|
||||||
|
toContact: "Kontakt aufnehmen",
|
||||||
|
heroCardTitle: "Schneller Rollout",
|
||||||
|
heroCardText: "Struktur, Content und Komponenten fuer schnelles Wachstum.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
heroKicker: "Digital Studio",
|
||||||
|
heroTitle: "Websites and products that ship fast.",
|
||||||
|
heroText:
|
||||||
|
"This homepage is a starter for a multilingual marketing and product setup.",
|
||||||
|
portfolioTitle: "Featured Projects",
|
||||||
|
portfolioText: "Placeholder area for highlighted client projects.",
|
||||||
|
productsTitle: "Featured Products",
|
||||||
|
productsText: "Placeholder area for top product offerings.",
|
||||||
|
ctaTitle: "Ready for your next step?",
|
||||||
|
ctaText: "We can shape the right project scope together.",
|
||||||
|
toPortfolio: "View portfolio",
|
||||||
|
toProducts: "View products",
|
||||||
|
toContact: "Contact us",
|
||||||
|
heroCardTitle: "Fast rollout",
|
||||||
|
heroCardText: "Structure, content and components for rapid growth.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="grid gap-6 rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-950 lg:grid-cols-[1.3fr_0.7fr] lg:p-10">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{copy.heroKicker}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">
|
||||||
|
{copy.heroTitle}
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.heroText}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/portfolio`}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{copy.toPortfolio}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/products`}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 px-4 py-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{copy.toProducts}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
<BriefcaseBusiness className="h-4 w-4" />
|
||||||
|
{copy.heroCardTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.heroCardText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.portfolioTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.portfolioText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BriefcaseBusiness className="h-5 w-5 text-zinc-500 dark:text-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{featuredProjects.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.slug}
|
||||||
|
href={`/${localeKey}/portfolio/${item.slug}`}
|
||||||
|
className="group rounded-2xl border border-zinc-200 bg-zinc-50 p-4 transition hover:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{pickText(item.category, localeKey)} - {item.year}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{pickText(item.title, localeKey)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-700 group-hover:text-zinc-900 dark:text-zinc-200 dark:group-hover:text-white">
|
||||||
|
{copy.toPortfolio}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.productsTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.productsText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Boxes className="h-5 w-5 text-zinc-500 dark:text-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{featuredProducts.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.slug}
|
||||||
|
href={`/${localeKey}/products/${item.slug}`}
|
||||||
|
className="group rounded-2xl border border-zinc-200 bg-zinc-50 p-4 transition hover:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{pickText(item.segment, localeKey)}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{pickText(item.name, localeKey)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
{pickText(item.price, localeKey)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.15}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-zinc-900 p-6 text-zinc-100 dark:border-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 lg:p-8">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium opacity-80">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{copy.ctaTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm opacity-90 sm:text-base">
|
||||||
|
{copy.ctaText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/contact`}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-200 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{copy.toContact}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { ArrowLeft, CalendarDays, FolderKanban, Tag } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { getPortfolioItem, pickText, portfolioItems, resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type PortfolioItemPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.flatMap((locale) =>
|
||||||
|
portfolioItems.map((item) => ({
|
||||||
|
locale,
|
||||||
|
slug: item.slug,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortfolioItemPage({
|
||||||
|
params: { locale, slug },
|
||||||
|
}: PortfolioItemPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
const item = getPortfolioItem(slug);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
back: "Zurueck zum Portfolio",
|
||||||
|
challenge: "Herausforderung",
|
||||||
|
solution: "Loesung",
|
||||||
|
outcome: "Ergebnis",
|
||||||
|
challengeText:
|
||||||
|
"Das Projekt brauchte eine klare Informationsarchitektur und schnellere Ladezeiten.",
|
||||||
|
solutionText:
|
||||||
|
"Wir haben Design, Komponenten und Content in einem modularen System aufgebaut.",
|
||||||
|
outcomeText:
|
||||||
|
"Das Team kann Inhalte schneller ausrollen und Nutzer finden schneller zum Ziel.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
back: "Back to portfolio",
|
||||||
|
challenge: "Challenge",
|
||||||
|
solution: "Solution",
|
||||||
|
outcome: "Outcome",
|
||||||
|
challengeText:
|
||||||
|
"The project needed clearer information architecture and faster performance.",
|
||||||
|
solutionText:
|
||||||
|
"We built design, components and content in a modular system.",
|
||||||
|
outcomeText:
|
||||||
|
"The team ships content faster and users reach goals more quickly.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/portfolio`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{copy.back}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-5 text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{pickText(item.title, localeKey)}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
{pickText(item.category, localeKey)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
{item.year}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<FolderKanban className="h-4 w-4" />
|
||||||
|
{item.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.challenge}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.challengeText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.solution}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.solutionText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
<MotionFade delay={0.15}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.outcome}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.outcomeText}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ArrowUpRight, CalendarDays, FolderKanban } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { pickText, portfolioItems, resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type PortfolioPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PortfolioPage({ params: { locale } }: PortfolioPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Portfolio",
|
||||||
|
intro: "Eine Auswahl von Projekten mit Fokus auf Klarheit und Ergebnis.",
|
||||||
|
open: "Projekt oeffnen",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Portfolio",
|
||||||
|
intro: "Selected projects with a focus on clarity and outcomes.",
|
||||||
|
open: "Open project",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FolderKanban className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 max-w-3xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.intro}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{portfolioItems.map((item, index) => (
|
||||||
|
<MotionFade key={item.slug} delay={index * 0.05}>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/portfolio/${item.slug}`}
|
||||||
|
className="group block rounded-2xl border border-zinc-200 bg-white p-5 transition hover:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{pickText(item.category, localeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="inline-flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
|
{item.year}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{pickText(item.title, localeKey)}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-700 group-hover:text-zinc-900 dark:text-zinc-200 dark:group-hover:text-white">
|
||||||
|
{copy.open}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { ArrowLeft, BadgeEuro, Boxes, Layers2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { getProductItem, pickText, productItems, resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type ProductPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.flatMap((locale) =>
|
||||||
|
productItems.map((item) => ({
|
||||||
|
locale,
|
||||||
|
slug: item.slug,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductPage({ params: { locale, slug } }: ProductPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
const item = getProductItem(slug);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
back: "Zurueck zu Produkten",
|
||||||
|
included: "Inklusive",
|
||||||
|
stepOne: "Kickoff und Scope Klarheit",
|
||||||
|
stepTwo: "Setup von Design und Komponenten",
|
||||||
|
stepThree: "Implementierung und Uebergabe",
|
||||||
|
action: "Kontakt fuer Angebot",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
back: "Back to products",
|
||||||
|
included: "Included",
|
||||||
|
stepOne: "Kickoff and scope clarity",
|
||||||
|
stepTwo: "Design and component setup",
|
||||||
|
stepThree: "Implementation and handover",
|
||||||
|
action: "Contact for proposal",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/products`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{copy.back}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-5 text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{pickText(item.name, localeKey)}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Layers2 className="h-4 w-4" />
|
||||||
|
{pickText(item.segment, localeKey)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<BadgeEuro className="h-4 w-4" />
|
||||||
|
{pickText(item.price, localeKey)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Boxes className="h-4 w-4" />
|
||||||
|
{item.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{copy.included}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
{[copy.stepOne, copy.stepTwo, copy.stepThree].map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={step}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-zinc-200 text-xs font-semibold text-zinc-800 dark:bg-zinc-800 dark:text-zinc-100">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<p>{step}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/contact`}
|
||||||
|
className="mt-6 inline-flex rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{copy.action}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ArrowUpRight, Boxes, CircleDollarSign } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { pickText, productItems, resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type ProductsPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductsPage({ params: { locale } }: ProductsPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Produkte",
|
||||||
|
intro: "Pakete fuer Teams von Start bis Skalierung.",
|
||||||
|
open: "Produkt oeffnen",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Products",
|
||||||
|
intro: "Packages for teams from early stage to scale.",
|
||||||
|
open: "Open product",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Boxes className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 max-w-3xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.intro}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{productItems.map((item, index) => (
|
||||||
|
<MotionFade key={item.slug} delay={index * 0.05}>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/products/${item.slug}`}
|
||||||
|
className="group block rounded-2xl border border-zinc-200 bg-white p-5 transition hover:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{pickText(item.segment, localeKey)}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{pickText(item.name, localeKey)}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{pickText(item.summary, localeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<CircleDollarSign className="h-4 w-4" />
|
||||||
|
{pickText(item.price, localeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-700 group-hover:text-zinc-900 dark:text-zinc-200 dark:group-hover:text-white">
|
||||||
|
{copy.open}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Boxes,
|
||||||
|
FolderKanban,
|
||||||
|
LockKeyhole,
|
||||||
|
LogOut,
|
||||||
|
Power,
|
||||||
|
ShieldAlert,
|
||||||
|
Users2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import {
|
||||||
|
clearAdminSessionCookie,
|
||||||
|
getAdminLockState,
|
||||||
|
isAdminAuthConfigured,
|
||||||
|
isAdminAuthenticated,
|
||||||
|
isPasswordValid,
|
||||||
|
registerFailedAdminAttempt,
|
||||||
|
resetAdminFailedAttempts,
|
||||||
|
setAdminSessionCookie,
|
||||||
|
} from "@/lib/admin-auth";
|
||||||
|
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
|
||||||
|
import { resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type RootPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
searchParams?: {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RootPage({
|
||||||
|
params: { locale },
|
||||||
|
searchParams,
|
||||||
|
}: RootPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
const authConfigured = isAdminAuthConfigured();
|
||||||
|
const basicConfigured = Boolean(
|
||||||
|
process.env.ROOT_BASIC_AUTH_USER && process.env.ROOT_BASIC_AUTH_PASS,
|
||||||
|
);
|
||||||
|
const authenticated = isAdminAuthenticated();
|
||||||
|
const lockState = getAdminLockState();
|
||||||
|
const maintenanceEnabled = authenticated ? await getMaintenanceMode() : false;
|
||||||
|
|
||||||
|
async function loginAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const currentLockState = getAdminLockState();
|
||||||
|
|
||||||
|
if (currentLockState.locked) {
|
||||||
|
redirect(`/${localeKey}/root?error=locked`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminAuthConfigured() || !isPasswordValid(password)) {
|
||||||
|
const failState = registerFailedAdminAttempt();
|
||||||
|
if (failState.locked) {
|
||||||
|
redirect(`/${localeKey}/root?error=locked`);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/${localeKey}/root?error=invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdminFailedAttempts();
|
||||||
|
setAdminSessionCookie();
|
||||||
|
redirect(`/${localeKey}/root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect(`/${localeKey}/root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMaintenanceMode(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect(`/${localeKey}/root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = formData.get("enabled") === "true";
|
||||||
|
|
||||||
|
await setMaintenanceMode(nextValue);
|
||||||
|
revalidatePath(`/${localeKey}/root`);
|
||||||
|
revalidatePath(`/${localeKey}/coming-soon`);
|
||||||
|
redirect(`/${localeKey}/root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Root",
|
||||||
|
subtitle: "Interner Bereich fuer Kennzahlen und Wartungssteuerung.",
|
||||||
|
activeUsers: "Aktive Nutzer",
|
||||||
|
projects: "Laufende Projekte",
|
||||||
|
products: "Aktive Produkte",
|
||||||
|
conversion: "Conversion",
|
||||||
|
updates: "Letzte Updates",
|
||||||
|
updateOne: "Kontaktformular wurde ueberarbeitet.",
|
||||||
|
updateTwo: "Neue Produktseite fuer Growth Kit vorbereitet.",
|
||||||
|
updateThree: "Portfolio-Daten fuer Q2 aktualisiert.",
|
||||||
|
maintenanceTitle: "Wartungsmodus",
|
||||||
|
maintenanceText:
|
||||||
|
"Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
||||||
|
maintenanceOn: "Aktiv",
|
||||||
|
maintenanceOff: "Inaktiv",
|
||||||
|
enableMaintenance: "Wartungsmodus aktivieren",
|
||||||
|
disableMaintenance: "Wartungsmodus deaktivieren",
|
||||||
|
loginTitle: "Root Login",
|
||||||
|
loginText: "Nur autorisierte Nutzer duerfen diesen Bereich verwenden.",
|
||||||
|
passwordLabel: "Passwort",
|
||||||
|
loginButton: "Einloggen",
|
||||||
|
invalidLogin: "Falsches Passwort.",
|
||||||
|
lockedLogin: "Zu viele Fehlversuche. Bitte spaeter erneut versuchen.",
|
||||||
|
configMissing: "ADMIN_PASSWORD und ADMIN_AUTH_SECRET fehlen in env.",
|
||||||
|
basicAuthMissing:
|
||||||
|
"ROOT_BASIC_AUTH_USER und ROOT_BASIC_AUTH_PASS fehlen in env.",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Root",
|
||||||
|
subtitle: "Internal area for metrics and maintenance controls.",
|
||||||
|
activeUsers: "Active users",
|
||||||
|
projects: "Running projects",
|
||||||
|
products: "Active products",
|
||||||
|
conversion: "Conversion",
|
||||||
|
updates: "Latest updates",
|
||||||
|
updateOne: "Contact form layout updated.",
|
||||||
|
updateTwo: "New Growth Kit product page prepared.",
|
||||||
|
updateThree: "Portfolio data updated for Q2.",
|
||||||
|
maintenanceTitle: "Maintenance mode",
|
||||||
|
maintenanceText:
|
||||||
|
"When enabled, all site routes are redirected to the coming soon page.",
|
||||||
|
maintenanceOn: "Enabled",
|
||||||
|
maintenanceOff: "Disabled",
|
||||||
|
enableMaintenance: "Enable maintenance mode",
|
||||||
|
disableMaintenance: "Disable maintenance mode",
|
||||||
|
loginTitle: "Root login",
|
||||||
|
loginText: "Only authorized users can access this area.",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
loginButton: "Sign in",
|
||||||
|
invalidLogin: "Invalid password.",
|
||||||
|
lockedLogin: "Too many failed attempts. Please try again later.",
|
||||||
|
configMissing: "ADMIN_PASSWORD and ADMIN_AUTH_SECRET are missing in env.",
|
||||||
|
basicAuthMissing:
|
||||||
|
"ROOT_BASIC_AUTH_USER and ROOT_BASIC_AUTH_PASS are missing in env.",
|
||||||
|
logout: "Sign out",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-xl flex-col gap-6 px-4 py-12 sm:px-6 lg:px-8">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<LockKeyhole className="h-4 w-4" />
|
||||||
|
{copy.loginTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">{copy.loginText}</p>
|
||||||
|
|
||||||
|
{!authConfigured ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{copy.configMissing}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!basicConfigured ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{copy.basicAuthMissing}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error === "invalid" ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
|
||||||
|
{copy.invalidLogin}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error === "locked" || lockState.locked ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
|
||||||
|
{copy.lockedLogin}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form action={loginAction} className="mt-5 space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
{copy.passwordLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-zinc-300 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-700"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
disabled={!authConfigured || !basicConfigured || lockState.locked}
|
||||||
|
>
|
||||||
|
<LockKeyhole className="h-4 w-4" />
|
||||||
|
{copy.loginButton}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: copy.activeUsers, value: "1,280", icon: Users2 },
|
||||||
|
{ label: copy.projects, value: "24", icon: FolderKanban },
|
||||||
|
{ label: copy.products, value: "8", icon: Boxes },
|
||||||
|
{ label: copy.conversion, value: "4.8%", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-10">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
<form action={logoutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{copy.logout}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 max-w-3xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.subtitle}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
{copy.maintenanceTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.maintenanceText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
maintenanceEnabled
|
||||||
|
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={updateMaintenanceMode} className="mt-5">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="enabled"
|
||||||
|
value={maintenanceEnabled ? "false" : "true"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:border-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<MotionFade key={item.label} delay={index * 0.05}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<Icon className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
|
||||||
|
<p className="mt-3 text-sm text-zinc-500 dark:text-zinc-400">{item.label}</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950 lg:p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{copy.updates}</h2>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{[copy.updateOne, copy.updateTwo, copy.updateThree].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type SuccessPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SuccessPage({ params: { locale } }: SuccessPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
title: "Danke fuer deine Nachricht",
|
||||||
|
text: "Wir haben deine Anfrage erhalten und melden uns zeitnah.",
|
||||||
|
home: "Zur Startseite",
|
||||||
|
contact: "Zur Kontaktseite",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Thank you for your message",
|
||||||
|
text: "We received your request and will reply shortly.",
|
||||||
|
home: "Go to homepage",
|
||||||
|
contact: "Back to contact",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
|
||||||
|
<MotionFade className="w-full">
|
||||||
|
<section className="w-full rounded-3xl border border-zinc-200 bg-white p-8 text-center dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-500" />
|
||||||
|
<h1 className="mt-4 text-3xl font-semibold text-zinc-900 dark:text-zinc-100 sm:text-4xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mt-3 max-w-2xl text-base text-zinc-600 dark:text-zinc-300">
|
||||||
|
{copy.text}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}`}
|
||||||
|
className="inline-flex rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{copy.home}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/contact`}
|
||||||
|
className="inline-flex rounded-lg border border-zinc-300 px-4 py-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{copy.contact}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { CalendarClock, Rocket, ShieldCheck, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { resolveLocale } from "@/lib/site-data";
|
||||||
|
|
||||||
|
type ComingSoonPageProps = {
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ComingSoonPage({ params: { locale } }: ComingSoonPageProps) {
|
||||||
|
const localeKey = resolveLocale(locale);
|
||||||
|
|
||||||
|
const copy =
|
||||||
|
localeKey === "de"
|
||||||
|
? {
|
||||||
|
badge: "In Vorbereitung",
|
||||||
|
title: "Wir bauen gerade etwas Grosses.",
|
||||||
|
description:
|
||||||
|
"Die Website ist aktuell im Wartungsmodus. Wir finalisieren Inhalte, Feinschliff und Integrationen fuer den Launch.",
|
||||||
|
cardOneTitle: "Launch Fokus",
|
||||||
|
cardOneText: "Performance, UX und klare Conversion-Flows.",
|
||||||
|
cardTwoTitle: "Naechster Schritt",
|
||||||
|
cardTwoText: "Deployment-Checks und finale Inhalte.",
|
||||||
|
cardThreeTitle: "Status",
|
||||||
|
cardThreeText: "Systeme sind online und werden vorbereitet.",
|
||||||
|
adminLink: "Zum Root Bereich",
|
||||||
|
footer: "Danke fuer deine Geduld.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: "Preparing",
|
||||||
|
title: "We are building something big.",
|
||||||
|
description:
|
||||||
|
"The website is currently in maintenance mode. We are finalizing content, polish and integrations before launch.",
|
||||||
|
cardOneTitle: "Launch focus",
|
||||||
|
cardOneText: "Performance, UX and clear conversion flows.",
|
||||||
|
cardTwoTitle: "Next step",
|
||||||
|
cardTwoText: "Deployment checks and final content updates.",
|
||||||
|
cardThreeTitle: "Status",
|
||||||
|
cardThreeText: "Systems are online and being prepared.",
|
||||||
|
adminLink: "Open root area",
|
||||||
|
footer: "Thank you for your patience.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(24,24,27,0.1),transparent_55%)] dark:bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_55%)]" />
|
||||||
|
<div className="relative mx-auto flex min-h-[70vh] w-full max-w-6xl flex-col justify-center gap-8 px-4 py-14 sm:px-6 lg:px-8 lg:py-20">
|
||||||
|
<MotionFade>
|
||||||
|
<section className="rounded-3xl border border-zinc-200 bg-white/95 p-8 shadow-sm backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95 lg:p-12">
|
||||||
|
<p className="inline-flex items-center gap-2 rounded-full border border-zinc-300 bg-zinc-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{copy.badge}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mt-5 max-w-3xl text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl lg:text-6xl">
|
||||||
|
{copy.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-5 max-w-2xl text-base text-zinc-600 dark:text-zinc-300 sm:text-lg">
|
||||||
|
{copy.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link
|
||||||
|
href={`/${localeKey}/root`}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
{copy.adminLink}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
{copy.cardOneTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300">{copy.cardOneText}</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
{copy.cardTwoTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300">{copy.cardTwoText}</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.15}>
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
{copy.cardThreeTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300">{copy.cardThreeText}</p>
|
||||||
|
</article>
|
||||||
|
</MotionFade>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MotionFade delay={0.2}>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.footer}</p>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
|
||||||
|
type LocaleLayoutProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
params: {
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params: { locale },
|
||||||
|
}: LocaleLayoutProps) {
|
||||||
|
if (!routing.locales.includes(locale as (typeof routing.locales)[number])) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
timestamp,
|
||||||
|
checks: {
|
||||||
|
database: "up",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "degraded",
|
||||||
|
timestamp,
|
||||||
|
checks: {
|
||||||
|
database: "down",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getMaintenanceMode } from "@/lib/app-config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const enabled = await getMaintenanceMode();
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-4
@@ -7,17 +7,14 @@
|
|||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|||||||
+4
-20
@@ -1,21 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = localFont({
|
|
||||||
src: "./fonts/GeistVF.woff",
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
const geistMono = localFont({
|
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "moh-sass",
|
||||||
description: "Generated by create next app",
|
description: "Multilingual Next.js base project",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,12 +12,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body
|
<body className="min-h-screen antialiased">{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-99
@@ -1,101 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function RootPage() {
|
||||||
return (
|
redirect("/de");
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="https://nextjs.org/icons/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
|
||||||
app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="https://nextjs.org/icons/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ key: "home", path: "" },
|
||||||
|
{ key: "portfolio", path: "/portfolio" },
|
||||||
|
{ key: "products", path: "/products" },
|
||||||
|
{ key: "about", path: "/about" },
|
||||||
|
{ key: "contact", path: "/contact" },
|
||||||
|
{ key: "root", path: "/root" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const tNav = useTranslations("navigation");
|
||||||
|
const tFooter = useTranslations("footer");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<nav className="flex flex-wrap gap-4 text-sm">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={`/${locale}${item.path}`}
|
||||||
|
className="text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{tNav(item.key)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{tFooter("copyright", { year: new Date().getFullYear() })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type MotionFadeProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MotionFade({ children, className, delay = 0 }: MotionFadeProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
|
transition={{ duration: 0.45, delay, ease: "easeOut" }}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ key: "home", path: "" },
|
||||||
|
{ key: "portfolio", path: "/portfolio" },
|
||||||
|
{ key: "products", path: "/products" },
|
||||||
|
{ key: "about", path: "/about" },
|
||||||
|
{ key: "contact", path: "/contact" },
|
||||||
|
{ key: "root", path: "/root" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("navigation");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 border-b border-zinc-200 bg-white/95 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-flex items-center rounded-md border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logos/light-primary.svg"
|
||||||
|
alt="mohfarawati logo"
|
||||||
|
width={140}
|
||||||
|
height={24}
|
||||||
|
className="block h-6 w-auto dark:hidden"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/logos/dark-primary.svg"
|
||||||
|
alt="mohfarawati logo"
|
||||||
|
width={140}
|
||||||
|
height={24}
|
||||||
|
className="hidden h-6 w-auto dark:block"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden items-center gap-5 md:flex">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={`/${locale}${item.path}`}
|
||||||
|
className="text-sm text-zinc-700 transition hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{t(item.key)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-xs font-medium text-zinc-600 dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{t("languagePlaceholder")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-300 text-zinc-700 md:hidden dark:border-zinc-700 dark:text-zinc-200"
|
||||||
|
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="border-t border-zinc-200 px-4 py-4 md:hidden dark:border-zinc-800">
|
||||||
|
<nav className="flex flex-col gap-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={`/${locale}${item.path}`}
|
||||||
|
className="text-sm text-zinc-700 dark:text-zinc-300"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{t(item.key)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-xs font-medium text-zinc-600 dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{t("languagePlaceholder")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 dark:border-zinc-700 dark:text-zinc-200"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||||
|
const isDark = activeTheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-300 text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: moh-sass-app
|
||||||
|
command: sh -c "npm install && npm run dev -- --hostname 0.0.0.0 --port 3000"
|
||||||
|
environment:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me}
|
||||||
|
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:-change-me-long-secret}
|
||||||
|
ROOT_BASIC_AUTH_USER: ${ROOT_BASIC_AUTH_USER:-root}
|
||||||
|
ROOT_BASIC_AUTH_PASS: ${ROOT_BASIC_AUTH_PASS:-change-me-root}
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- app_node_modules:/app/node_modules
|
||||||
|
- app_next:/app/.next
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: moh-sass-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: moh_sass
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d moh_sass"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_node_modules:
|
||||||
|
app_next:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
const requestedLocale = await requestLocale;
|
||||||
|
const locale =
|
||||||
|
requestedLocale &&
|
||||||
|
routing.locales.includes(requestedLocale as (typeof routing.locales)[number])
|
||||||
|
? requestedLocale
|
||||||
|
: routing.defaultLocale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ["en", "de"],
|
||||||
|
defaultLocale: "de",
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
|
const ADMIN_FAIL_COOKIE = "moh_admin_fail";
|
||||||
|
const ADMIN_SESSION_VALUE = "superadmin";
|
||||||
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_SECONDS = 15 * 60;
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPassword(): string {
|
||||||
|
return process.env.ADMIN_PASSWORD ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function signValue(value: string): string {
|
||||||
|
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToken(): string {
|
||||||
|
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token: string): boolean {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value, signature] = parts;
|
||||||
|
if (value !== ADMIN_SESSION_VALUE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = signValue(value);
|
||||||
|
const left = Buffer.from(signature);
|
||||||
|
const right = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminAuthConfigured(): boolean {
|
||||||
|
return getPassword().length > 0 && getSecret().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordValid(password: string): boolean {
|
||||||
|
if (!isAdminAuthConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = Buffer.from(password);
|
||||||
|
const expected = Buffer.from(getPassword());
|
||||||
|
|
||||||
|
if (provided.length !== expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(provided, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminSessionCookie(): void {
|
||||||
|
const store = cookies();
|
||||||
|
store.set(ADMIN_SESSION_COOKIE, buildToken(), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAdminSessionCookie(): void {
|
||||||
|
const store = cookies();
|
||||||
|
store.set(ADMIN_SESSION_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type FailState = {
|
||||||
|
attempts: number;
|
||||||
|
lockUntil: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseFailState(rawValue: string | undefined): FailState {
|
||||||
|
if (!rawValue) {
|
||||||
|
return { attempts: 0, lockUntil: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue) as Partial<FailState>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempts: Number(parsed.attempts ?? 0),
|
||||||
|
lockUntil: Number(parsed.lockUntil ?? 0),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { attempts: 0, lockUntil: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminLockState(): { locked: boolean; remainingSeconds: number } {
|
||||||
|
const store = cookies();
|
||||||
|
const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (state.lockUntil > now) {
|
||||||
|
return {
|
||||||
|
locked: true,
|
||||||
|
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked: false, remainingSeconds: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFailedAdminAttempt(): { locked: boolean; remainingSeconds: number } {
|
||||||
|
const store = cookies();
|
||||||
|
const now = Date.now();
|
||||||
|
const current = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||||
|
const attempts = current.lockUntil > now ? current.attempts : current.attempts + 1;
|
||||||
|
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||||
|
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||||
|
|
||||||
|
store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: LOCKOUT_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locked,
|
||||||
|
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAdminFailedAttempts(): void {
|
||||||
|
const store = cookies();
|
||||||
|
store.set(ADMIN_FAIL_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminAuthenticated(): boolean {
|
||||||
|
if (!isAdminAuthConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = cookies();
|
||||||
|
const token = store.get(ADMIN_SESSION_COOKIE)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyToken(token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
||||||
|
|
||||||
|
export async function getMaintenanceMode(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const config = await prisma.appConfig.findUnique({
|
||||||
|
where: { key: MAINTENANCE_MODE_KEY },
|
||||||
|
});
|
||||||
|
|
||||||
|
return config?.value === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { key: MAINTENANCE_MODE_KEY },
|
||||||
|
update: {
|
||||||
|
value: enabled ? "true" : "false",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key: MAINTENANCE_MODE_KEY,
|
||||||
|
value: enabled ? "true" : "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
prismaPool: Pool | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
|
||||||
|
|
||||||
|
const pool =
|
||||||
|
globalForPrisma.prismaPool ??
|
||||||
|
new Pool({
|
||||||
|
connectionString,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: ["warn", "error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prismaPool = pool;
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
export type AppLocale = "de" | "en";
|
||||||
|
|
||||||
|
type LocalizedText = Record<AppLocale, string>;
|
||||||
|
|
||||||
|
export type PortfolioItem = {
|
||||||
|
slug: string;
|
||||||
|
title: LocalizedText;
|
||||||
|
summary: LocalizedText;
|
||||||
|
category: LocalizedText;
|
||||||
|
year: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductItem = {
|
||||||
|
slug: string;
|
||||||
|
name: LocalizedText;
|
||||||
|
summary: LocalizedText;
|
||||||
|
segment: LocalizedText;
|
||||||
|
price: LocalizedText;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const portfolioItems: PortfolioItem[] = [
|
||||||
|
{
|
||||||
|
slug: "brand-redesign",
|
||||||
|
title: {
|
||||||
|
de: "Brand Redesign",
|
||||||
|
en: "Brand Redesign",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Modernes Redesign fuer eine digitale Marke mit klarer Struktur.",
|
||||||
|
en: "Modern redesign for a digital brand with a clear system.",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
de: "Branding",
|
||||||
|
en: "Branding",
|
||||||
|
},
|
||||||
|
year: "2025",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "commerce-relaunch",
|
||||||
|
title: {
|
||||||
|
de: "Commerce Relaunch",
|
||||||
|
en: "Commerce Relaunch",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Relaunch eines Shops mit Fokus auf Performance und Conversion.",
|
||||||
|
en: "Store relaunch focused on performance and conversion.",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
de: "E-Commerce",
|
||||||
|
en: "E-Commerce",
|
||||||
|
},
|
||||||
|
year: "2024",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "saas-dashboard",
|
||||||
|
title: {
|
||||||
|
de: "SaaS Dashboard",
|
||||||
|
en: "SaaS Dashboard",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Admin Dashboard fuer Teams mit klaren KPIs und Reports.",
|
||||||
|
en: "Admin dashboard for teams with clear KPIs and reports.",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
de: "Web App",
|
||||||
|
en: "Web App",
|
||||||
|
},
|
||||||
|
year: "2024",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "campaign-site",
|
||||||
|
title: {
|
||||||
|
de: "Campaign Site",
|
||||||
|
en: "Campaign Site",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Landing Seite fuer Produktkampagnen mit schneller Iteration.",
|
||||||
|
en: "Landing experience for product campaigns and quick iteration.",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
de: "Marketing",
|
||||||
|
en: "Marketing",
|
||||||
|
},
|
||||||
|
year: "2023",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const productItems: ProductItem[] = [
|
||||||
|
{
|
||||||
|
slug: "starter-kit",
|
||||||
|
name: {
|
||||||
|
de: "Starter Kit",
|
||||||
|
en: "Starter Kit",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Basis Paket fuer den schnellen Start von neuen Projekten.",
|
||||||
|
en: "Base package for launching new projects quickly.",
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
de: "Small Teams",
|
||||||
|
en: "Small Teams",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
de: "ab 990 EUR",
|
||||||
|
en: "from 990 EUR",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "growth-kit",
|
||||||
|
name: {
|
||||||
|
de: "Growth Kit",
|
||||||
|
en: "Growth Kit",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Skalierbares Paket fuer wachsende Produkte und Prozesse.",
|
||||||
|
en: "Scalable package for growing products and processes.",
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
de: "Scaleups",
|
||||||
|
en: "Scaleups",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
de: "ab 2490 EUR",
|
||||||
|
en: "from 2490 EUR",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "enterprise-kit",
|
||||||
|
name: {
|
||||||
|
de: "Enterprise Kit",
|
||||||
|
en: "Enterprise Kit",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
de: "Massgeschneiderte Loesung fuer grosse Teams und komplexe Systeme.",
|
||||||
|
en: "Tailored solution for large teams and complex systems.",
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
de: "Enterprise",
|
||||||
|
en: "Enterprise",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
de: "auf Anfrage",
|
||||||
|
en: "on request",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveLocale(locale: string): AppLocale {
|
||||||
|
return locale === "en" ? "en" : "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickText(text: LocalizedText, locale: AppLocale): string {
|
||||||
|
return text[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortfolioItem(slug: string) {
|
||||||
|
return portfolioItems.find((item) => item.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductItem(slug: string) {
|
||||||
|
return productItems.find((item) => item.slug === slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"home": "Start",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"products": "Produkte",
|
||||||
|
"about": "Ueber uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"root": "Root",
|
||||||
|
"openMenu": "Menue oeffnen",
|
||||||
|
"closeMenu": "Menue schliessen",
|
||||||
|
"languagePlaceholder": "DE | EN"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} moh-sass. Alle Rechte vorbehalten."
|
||||||
|
},
|
||||||
|
"homepage": {
|
||||||
|
"kicker": "Willkommen",
|
||||||
|
"title": "Mehrsprachiger Next.js Start",
|
||||||
|
"description": "Diese Startseite nutzt next-intl und ist fuer weitere Sprachen vorbereitet.",
|
||||||
|
"sectionPlaceholder": "Diese Seite ist bereit fuer deinen Inhalt."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"products": "Products",
|
||||||
|
"about": "About",
|
||||||
|
"contact": "Contact",
|
||||||
|
"root": "Root",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"languagePlaceholder": "DE | EN"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {year} moh-sass. All rights reserved."
|
||||||
|
},
|
||||||
|
"homepage": {
|
||||||
|
"kicker": "Welcome",
|
||||||
|
"title": "Modern multilingual starter",
|
||||||
|
"description": "This homepage is wired with next-intl and ready for additional locales.",
|
||||||
|
"sectionPlaceholder": "This page is ready for your content."
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { routing } from "./i18n/routing";
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
|
|
||||||
|
async function isMaintenanceModeEnabled(request: NextRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${request.nextUrl.origin}/api/maintenance`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"x-middleware-cache": "bypass",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { enabled?: boolean };
|
||||||
|
return data.enabled === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocaleFromPath(pathname: string): (typeof routing.locales)[number] {
|
||||||
|
const firstSegment = pathname.split("/").filter(Boolean)[0];
|
||||||
|
|
||||||
|
if (routing.locales.includes(firstSegment as (typeof routing.locales)[number])) {
|
||||||
|
return firstSegment as (typeof routing.locales)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
return routing.defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRootBasicAuthConfigured(): boolean {
|
||||||
|
return Boolean(process.env.ROOT_BASIC_AUTH_USER && process.env.ROOT_BASIC_AUTH_PASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRootBasicAuthValid(request: NextRequest): boolean {
|
||||||
|
if (!isRootBasicAuthConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = request.headers.get("authorization");
|
||||||
|
if (!header || !header.startsWith("Basic ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = atob(header.slice(6));
|
||||||
|
const index = decoded.indexOf(":");
|
||||||
|
if (index === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = decoded.slice(0, index);
|
||||||
|
const pass = decoded.slice(index + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
user === process.env.ROOT_BASIC_AUTH_USER &&
|
||||||
|
pass === process.env.ROOT_BASIC_AUTH_PASS
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSuperAdminSessionValid(request: NextRequest): Promise<boolean> {
|
||||||
|
const token = request.cookies.get(ADMIN_SESSION_COOKIE)?.value;
|
||||||
|
const secret = process.env.ADMIN_AUTH_SECRET;
|
||||||
|
|
||||||
|
if (!token || !secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value, signature] = parts;
|
||||||
|
if (!value || !signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
const signed = await crypto.subtle.sign(
|
||||||
|
"HMAC",
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(value),
|
||||||
|
);
|
||||||
|
const expected = Array.from(new Uint8Array(signed))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return signature === expected;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const locale = getLocaleFromPath(pathname);
|
||||||
|
const isSuperAdmin = await isSuperAdminSessionValid(request);
|
||||||
|
|
||||||
|
const isRootRoute =
|
||||||
|
pathname === `/${locale}/root` || pathname.startsWith(`/${locale}/root/`);
|
||||||
|
const isComingSoonRoute =
|
||||||
|
pathname === `/${locale}/coming-soon` ||
|
||||||
|
pathname.startsWith(`/${locale}/coming-soon/`);
|
||||||
|
|
||||||
|
if (isRootRoute && isRootBasicAuthConfigured() && !isRootBasicAuthValid(request)) {
|
||||||
|
return new NextResponse("Authentication required", {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRootRoute && !isComingSoonRoute && !isSuperAdmin) {
|
||||||
|
const maintenanceModeEnabled = await isMaintenanceModeEnabled(request);
|
||||||
|
|
||||||
|
if (maintenanceModeEnabled) {
|
||||||
|
return NextResponse.redirect(new URL(`/${locale}/coming-soon`, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intlMiddleware(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
|
||||||
|
};
|
||||||
+18
-3
@@ -1,4 +1,19 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
const nextConfig = {};
|
|
||||||
|
|
||||||
export default nextConfig;
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
webpack: (config) => {
|
||||||
|
config.ignoreWarnings = [
|
||||||
|
...(config.ignoreWarnings ?? []),
|
||||||
|
{
|
||||||
|
message: /Build dependencies behind this expression are ignored/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
Generated
+1926
-17
File diff suppressed because it is too large
Load Diff
+27
-7
@@ -1,26 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "mohfarawati",
|
"name": "moh-sass",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate deploy",
|
||||||
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
"db:seed": "prisma db seed"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node prisma/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
|
"@prisma/client": "^7.4.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.35.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"next-intl": "^4.8.3",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"react-hook-form": "^7.71.2",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.35"
|
"eslint-config-next": "14.2.35",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prisma": "^7.4.2",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
seed: "node prisma/seed.js",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"] ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AppConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AppConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AppConfig_key_key" ON "AppConfig"("key");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const { Pool } = require("pg");
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { key: "siteName" },
|
||||||
|
update: { value: "moh-sass" },
|
||||||
|
create: { key: "siteName", value: "moh-sass" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Seed failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
Executable
+25
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
Executable
+25
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
Reference in New Issue
Block a user