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*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# 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.
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -7,17 +7,14 @@
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
+4
-20
@@ -1,21 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
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 = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "moh-sass",
|
||||
description: "Multilingual Next.js base project",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,12 +12,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-99
@@ -1,101 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
export default function RootPage() {
|
||||
redirect("/de");
|
||||
}
|
||||
|
||||
@@ -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} */
|
||||
const nextConfig = {};
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@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-dom": "^18",
|
||||
"next": "14.2.35"
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"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