feat: initialize mohfarawati app foundation
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-06 13:53:57 +01:00
parent 30330c4f1e
commit f86b7f39f7
50 changed files with 4615 additions and 152 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
.gitignore
npm-debug.log
Dockerfile
.dockerignore
+7
View File
@@ -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"
+33
View File
@@ -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
+1
View File
@@ -26,6 +26,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env
.env*.local .env*.local
# vercel # vercel
+12
View File
@@ -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"]
+70
View File
@@ -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
View File
@@ -34,3 +34,37 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Infrastructure Basics
1. Copy environment variables:
```bash
cp .env.example .env
```
2. Start containers:
```bash
make start
make port
```
3. Initialize database:
```bash
make db-init
```
4. Run quality checks:
```bash
npm run lint
npm run build
```
5. Health check:
```bash
make health
```
+122
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+324
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+35
View File
@@ -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 },
);
}
}
+25
View File
@@ -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
View File
@@ -7,17 +7,14 @@
--foreground: #171717; --foreground: #171717;
} }
@media (prefers-color-scheme: dark) { .dark {
:root { --background: #0a0a0a;
--background: #0a0a0a; --foreground: #ededed;
--foreground: #ededed;
}
} }
body { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background: var(--background);
font-family: Arial, Helvetica, sans-serif;
} }
@layer utilities { @layer utilities {
+4 -20
View File
@@ -1,21 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "moh-sass",
description: "Generated by create next app", description: "Multilingual Next.js base project",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,12 +12,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="de" suppressHydrationWarning>
<body <body className="min-h-screen antialiased">{children}</body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html> </html>
); );
} }
+3 -99
View File
@@ -1,101 +1,5 @@
import Image from "next/image"; import { redirect } from "next/navigation";
export default function Home() { export default function RootPage() {
return ( redirect("/de");
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
} }
+38
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+7
View File
@@ -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>;
}
+40
View File
@@ -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>
);
}
+44
View File
@@ -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:
+17
View File
@@ -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,
};
});
+6
View File
@@ -0,0 +1,6 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "de"],
defaultLocale: "de",
});
+172
View File
@@ -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);
}
+28
View File
@@ -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",
},
});
}
+32
View File
@@ -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;
}
+162
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+22
View File
@@ -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."
}
}
+22
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,19 @@
/** @type {import('next').NextConfig} */ import createNextIntlPlugin from "next-intl/plugin";
const nextConfig = {};
export default nextConfig; const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.ignoreWarnings = [
...(config.ignoreWarnings ?? []),
{
message: /Build dependencies behind this expression are ignored/,
},
];
return config;
},
};
export default withNextIntl(nextConfig);
+1926 -17
View File
File diff suppressed because it is too large Load Diff
+27 -7
View File
@@ -1,26 +1,46 @@
{ {
"name": "mohfarawati", "name": "moh-sass",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "node prisma/seed.js"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"clsx": "^2.1.1",
"framer-motion": "^12.35.0",
"lucide-react": "^0.577.0",
"next": "14.2.35",
"next-intl": "^4.8.3",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"next": "14.2.35" "react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.35" "eslint-config-next": "14.2.35",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
"typescript": "^5"
} }
} }
+13
View File
@@ -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");
+3
View File
@@ -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"
+15
View File
@@ -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
}
+28
View File
@@ -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();
});
+25
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

+25
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB