Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7685fdb2f | |||
| 609422b5b9 | |||
| 76cf6e7b62 | |||
| cc04b71327 | |||
| 1d5d86d07c | |||
| e2b7131adc | |||
| c2ced7185b | |||
| fd8f068594 | |||
| 00bafa761b | |||
| d0d66dd85f | |||
| 6f5c9bd613 | |||
| 9f6168592c |
@@ -80,5 +80,5 @@ SENTRY_DSN=
|
||||
# GOTIFY_TOKEN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
|
||||
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
steps:
|
||||
- name: 🔍 Debug Info
|
||||
@@ -114,9 +115,11 @@ jobs:
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
|
||||
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
|
||||
TRAEFIK_MIDDLEWARES="compress"
|
||||
else
|
||||
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
|
||||
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
|
||||
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -129,6 +132,7 @@ jobs:
|
||||
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
|
||||
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
|
||||
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
|
||||
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
|
||||
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
|
||||
@@ -195,6 +199,7 @@ jobs:
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
|
||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
||||
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
||||
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
|
||||
--push .
|
||||
|
||||
@@ -227,6 +232,7 @@ jobs:
|
||||
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
|
||||
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
||||
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
|
||||
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
|
||||
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
|
||||
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
|
||||
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
|
||||
@@ -245,14 +251,13 @@ jobs:
|
||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||
|
||||
# SMTP Config
|
||||
SMTP_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
SMTP_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
SMTP_SECURE=${{ secrets.SMTP_SECURE || vars.SMTP_SECURE || 'false' }}
|
||||
SMTP_USER=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
SMTP_PASS=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
SMTP_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
CONTACT_RECIPIENT=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
# Mail
|
||||
MAIL_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
@@ -263,8 +268,8 @@ jobs:
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Project
|
||||
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
||||
@@ -283,7 +288,7 @@ jobs:
|
||||
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --remove-orphans
|
||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
EOF
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -8,19 +8,18 @@ RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG NPM_TOKEN
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV NPM_TOKEN=$NPM_TOKEN
|
||||
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
|
||||
# Enable corepack
|
||||
RUN corepack enable
|
||||
@@ -39,13 +38,19 @@ RUN pnpm build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
# Production environment configuration
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy standalone output and static files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Ensure the cache directory specifically is writeable (Mintel Standard #16)
|
||||
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -5,6 +5,7 @@ import "../globals.css";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LazyMotion, domAnimation } from "framer-motion";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -105,6 +106,13 @@ export default async function RootLayout({
|
||||
},
|
||||
};
|
||||
|
||||
// Track pageview on the server
|
||||
// This is safe to call here because layout is a Server Component
|
||||
const services = (
|
||||
await import("@/lib/services/create-services.server")
|
||||
).getServerAppServices();
|
||||
services.analytics.trackPageview();
|
||||
|
||||
return (
|
||||
<html lang={locale} className={`${inter.variable}`}>
|
||||
<head>
|
||||
@@ -115,7 +123,9 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Layout>{children}</Layout>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<Layout>{children}</Layout>
|
||||
</LazyMotion>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -47,7 +47,14 @@ export async function POST(req: Request) {
|
||||
logger.info("Contact submission saved to Directus");
|
||||
directusSaved = true;
|
||||
} catch (directusError) {
|
||||
logger.error("Failed to save to Directus", { error: directusError });
|
||||
const errorMessage =
|
||||
directusError instanceof Error
|
||||
? directusError.message
|
||||
: String(directusError);
|
||||
logger.error("Failed to save to Directus", {
|
||||
error: errorMessage,
|
||||
details: directusError,
|
||||
});
|
||||
services.errors.captureException(directusError, {
|
||||
phase: "directus_save",
|
||||
});
|
||||
@@ -56,19 +63,20 @@ export async function POST(req: Request) {
|
||||
|
||||
// 2. Email sending
|
||||
try {
|
||||
const { config } = await import("@/lib/config");
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
secure: config.mail.port === 465,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
user: config.mail.user,
|
||||
pass: config.mail.pass,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com",
|
||||
from: config.mail.from,
|
||||
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
|
||||
replyTo: email,
|
||||
subject: `Kontaktanfrage von ${name}`,
|
||||
text: `
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { m, LazyMotion, domAnimation } from "framer-motion";
|
||||
import { m } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
@@ -62,15 +62,13 @@ export const Button = ({
|
||||
);
|
||||
|
||||
const spotlight = (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
</LazyMotion>
|
||||
<m.div
|
||||
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttonProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { m, LazyMotion, domAnimation } from "framer-motion";
|
||||
import { m } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
@@ -14,7 +14,6 @@ import { Button } from "./Button";
|
||||
import { Counter } from "./Counter";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { TechBackground } from "./TechBackground";
|
||||
import { TileGrid } from "./TileGrid";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Home() {
|
||||
@@ -80,7 +79,6 @@ export default function Home() {
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
||||
<TechBackground />
|
||||
</div>
|
||||
<TileGrid />
|
||||
|
||||
<div className="container-custom relative z-10">
|
||||
<div className="text-left relative">
|
||||
@@ -326,55 +324,53 @@ export default function Home() {
|
||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
||||
<LazyMotion features={domAnimation}>
|
||||
<svg
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<m.circle
|
||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="400"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.5,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="300"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 1,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="200"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</LazyMotion>
|
||||
<svg
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<m.circle
|
||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="400"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.5,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="300"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 1,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="200"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { ArrowUp, Home, Info, Menu, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -116,38 +116,36 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
</Reveal>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
|
||||
>
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
||||
isActive(link.href)
|
||||
? "text-accent bg-accent/5"
|
||||
: "text-slate-600 hover:text-primary hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<link.icon size={24} />
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Button href="/kontakt" className="mt-4 w-full">
|
||||
{t("nav.cta")}
|
||||
</Button>
|
||||
</nav>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
|
||||
>
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
||||
isActive(link.href)
|
||||
? "text-accent bg-accent/5"
|
||||
: "text-slate-600 hover:text-primary hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<link.icon size={24} />
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Button href="/kontakt" className="mt-4 w-full">
|
||||
{t("nav.cta")}
|
||||
</Button>
|
||||
</nav>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<main className="flex-grow">{children}</main>
|
||||
|
||||
@@ -168,18 +166,16 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
|
||||
|
||||
{/* Animated Tech Lines */}
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
animate={{ x: ["-100%", "100%"] }}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
|
||||
/>
|
||||
<m.div
|
||||
animate={{ x: ["100%", "-100%"] }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
|
||||
/>
|
||||
</LazyMotion>
|
||||
<m.div
|
||||
animate={{ x: ["-100%", "100%"] }}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
|
||||
/>
|
||||
<m.div
|
||||
animate={{ x: ["100%", "-100%"] }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Corner Accents */}
|
||||
<div className="tech-corner top-8 left-8 border-t border-l border-white/10 group-hover:border-accent/30 transition-colors duration-700" />
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import React from "react";
|
||||
import { m } from "framer-motion";
|
||||
|
||||
interface RevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
direction?: "up" | "down" | "left" | "right";
|
||||
fullWidth?: boolean;
|
||||
viewportMargin?: string;
|
||||
trigger?: 'inView' | 'mount';
|
||||
trigger?: "inView" | "mount";
|
||||
}
|
||||
|
||||
export const Reveal = ({
|
||||
children,
|
||||
className = '',
|
||||
className = "",
|
||||
delay = 0,
|
||||
direction = 'up',
|
||||
direction = "up",
|
||||
fullWidth = false,
|
||||
viewportMargin = "-50px",
|
||||
trigger = 'inView'
|
||||
trigger = "inView",
|
||||
}: RevealProps) => {
|
||||
const directions = {
|
||||
up: { y: 30 },
|
||||
@@ -30,35 +30,45 @@ export const Reveal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
...directions[direction]
|
||||
...directions[direction],
|
||||
}}
|
||||
animate={trigger === 'mount' ? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0
|
||||
} : undefined}
|
||||
whileInView={trigger === 'inView' ? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0
|
||||
} : undefined}
|
||||
viewport={trigger === 'inView' ? { once: true, margin: viewportMargin } : undefined}
|
||||
animate={
|
||||
trigger === "mount"
|
||||
? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
whileInView={
|
||||
trigger === "inView"
|
||||
? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
viewport={
|
||||
trigger === "inView"
|
||||
? { once: true, margin: viewportMargin }
|
||||
: undefined
|
||||
}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 50,
|
||||
damping: 20,
|
||||
mass: 1,
|
||||
delay: delay
|
||||
delay: delay,
|
||||
}}
|
||||
className={`${fullWidth ? 'w-full' : ''} ${className} motion-fix`}
|
||||
className={`${fullWidth ? "w-full" : ""} ${className} motion-fix will-change-[transform,opacity]`}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,13 +78,12 @@ interface StaggerProps {
|
||||
staggerDelay?: number;
|
||||
}
|
||||
|
||||
export const Stagger = ({
|
||||
children,
|
||||
className = '',
|
||||
staggerDelay = 0.1
|
||||
export const Stagger = ({
|
||||
children,
|
||||
className = "",
|
||||
staggerDelay = 0.1,
|
||||
}: StaggerProps) => {
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
initial="initial"
|
||||
whileInView="animate"
|
||||
@@ -90,6 +99,5 @@ export const Stagger = ({
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
|
||||
export const TileGrid = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const rows = 15;
|
||||
const cols = 20;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className="flex flex-col gap-3 min-w-[120%] min-h-[120%] -left-[10%] -top-[10%] absolute">
|
||||
{[...Array(rows)].map((_, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="flex gap-3 justify-center"
|
||||
style={{
|
||||
transform: rowIndex % 2 === 0 ? 'translateX(0)' : 'translateX(80px)',
|
||||
}}
|
||||
>
|
||||
{[...Array(cols)].map((_, colIndex) => (
|
||||
<m.div
|
||||
key={`${rowIndex}-${colIndex}`}
|
||||
initial={{ opacity: 0.05 }}
|
||||
animate={{
|
||||
opacity: [0.05, Math.random() > 0.9 ? 0.25 : 0.05, 0.05],
|
||||
scale: [1, Math.random() > 0.9 ? 1.05 : 1, 1]
|
||||
}}
|
||||
transition={{
|
||||
duration: 5 + Math.random() * 5,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 20,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="w-24 h-24 md:w-40 md:h-40 bg-white/10 backdrop-blur-[2px] rounded-2xl md:rounded-3xl border border-white/20 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] shrink-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
components/analytics/AnalyticsProvider.tsx
Normal file
48
components/analytics/AnalyticsProvider.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { getAppServices } from "@/lib/services/create-services";
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* This component should be placed inside your layout to handle navigation events.
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* const { websiteId } = config.analytics.umami;
|
||||
* <AnalyticsProvider websiteId={websiteId} />
|
||||
* ```
|
||||
*/
|
||||
export default function AnalyticsProvider({
|
||||
websiteId,
|
||||
}: {
|
||||
websiteId?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||
|
||||
// Track pageview with the full URL
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[Umami] Tracked pageview:", url);
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
if (!websiteId) return null;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
||||
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${PROJECT_NAME}-auth"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
||||
@@ -27,6 +27,12 @@ services:
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
- "traefik.docker.network=infra"
|
||||
healthcheck:
|
||||
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
|
||||
@@ -27,11 +27,9 @@ function createConfig() {
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
// The proxied path used in the frontend
|
||||
proxyPath: "/stats/script.js",
|
||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,7 +151,7 @@ export function getMaskedConfig() {
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: mask(c.analytics.umami.websiteId),
|
||||
scriptUrl: c.analytics.umami.scriptUrl,
|
||||
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||
enabled: c.analytics.umami.enabled,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,15 +23,21 @@ export async function ensureAuthenticated() {
|
||||
if (adminEmail && password) {
|
||||
try {
|
||||
await client.login({ email: adminEmail, password: password });
|
||||
return;
|
||||
} catch (e) {
|
||||
if (typeof window === "undefined") {
|
||||
getServerAppServices().errors.captureException(e, {
|
||||
phase: "directus_auth",
|
||||
phase: "directus_auth_fallback",
|
||||
});
|
||||
}
|
||||
console.error("Failed to authenticate with Directus login fallback:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Missing Directus authentication credentials (token or admin email/password)",
|
||||
);
|
||||
}
|
||||
|
||||
export default client;
|
||||
|
||||
161
lib/env.ts
161
lib/env.ts
@@ -8,78 +8,99 @@ const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
|
||||
/**
|
||||
* Environment variable schema.
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
NEXT_PUBLIC_TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
export const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
NEXT_PUBLIC_TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
|
||||
// Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("https://analytics.infra.mintel.me/script.js"),
|
||||
),
|
||||
// Analytics
|
||||
UMAMI_WEBSITE_ID: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
UMAMI_API_ENDPOINT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("https://analytics.infra.mintel.me"),
|
||||
),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.coerce.number().default(587),
|
||||
),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.coerce.number().default(587),
|
||||
),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
|
||||
// Directus
|
||||
DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("http://localhost:8055"),
|
||||
),
|
||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_API_TOKEN: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
INTERNAL_DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
// Directus
|
||||
DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("http://localhost:8055"),
|
||||
),
|
||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_API_TOKEN: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
INTERNAL_DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
|
||||
// Deploy Target
|
||||
TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
// Gotify
|
||||
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
});
|
||||
// Deploy Target
|
||||
TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
// Gotify
|
||||
GOTIFY_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||
const isDev = target === "development" || !target;
|
||||
const isBuildTimeValidation =
|
||||
process.env.SKIP_RUNTIME_ENV_VALIDATION === "true";
|
||||
const isServer = typeof window === "undefined";
|
||||
|
||||
// Only enforce server-only variables when running on the server.
|
||||
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "MAIL_HOST is required in non-development environments",
|
||||
path: ["MAIL_HOST"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -92,8 +113,12 @@ export function getRawEnv() {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
UMAMI_WEBSITE_ID:
|
||||
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
UMAMI_API_ENDPOINT:
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.UMAMI_SCRIPT_URL ||
|
||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_HOST: process.env.MAIL_HOST,
|
||||
|
||||
445
lib/services/analytics/README.md
Normal file
445
lib/services/analytics/README.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Analytics Service Layer
|
||||
|
||||
This directory contains the service layer implementation for analytics tracking in the KLZ Cables application.
|
||||
|
||||
## Overview
|
||||
|
||||
The analytics service layer provides a clean abstraction over different analytics implementations (Umami, Google Analytics, etc.) while maintaining a consistent API.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/services/analytics/
|
||||
├── analytics-service.ts # Interface definition
|
||||
├── umami-analytics-service.ts # Umami implementation
|
||||
├── noop-analytics-service.ts # No-op fallback implementation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. AnalyticsService Interface (`analytics-service.ts`)
|
||||
|
||||
Defines the contract for all analytics services:
|
||||
|
||||
```typescript
|
||||
export interface AnalyticsService {
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||
trackPageview(url?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Type-safe event properties
|
||||
- Consistent API across implementations
|
||||
- Well-documented with JSDoc comments
|
||||
|
||||
### 2. UmamiAnalyticsService (`umami-analytics-service.ts`)
|
||||
|
||||
Implements the `AnalyticsService` interface for Umami analytics.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Type-safe event tracking
|
||||
- Automatic pageview tracking
|
||||
- Browser environment detection
|
||||
- Graceful error handling
|
||||
- Comprehensive JSDoc documentation
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { UmamiAnalyticsService } from "@/lib/services/analytics/umami-analytics-service";
|
||||
|
||||
const service = new UmamiAnalyticsService({ enabled: true });
|
||||
service.track("button_click", { button_id: "cta" });
|
||||
service.trackPageview("/products/123");
|
||||
```
|
||||
|
||||
### 3. NoopAnalyticsService (`noop-analytics-service.ts`)
|
||||
|
||||
A no-op implementation used as a fallback when analytics are disabled.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Maintains the same API as other services
|
||||
- Safe to call even when analytics are disabled
|
||||
- No performance impact
|
||||
- Comprehensive JSDoc documentation
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { NoopAnalyticsService } from "@/lib/services/analytics/noop-analytics-service";
|
||||
|
||||
const service = new NoopAnalyticsService();
|
||||
service.track("button_click", { button_id: "cta" }); // Does nothing
|
||||
service.trackPageview("/products/123"); // Does nothing
|
||||
```
|
||||
|
||||
## Service Selection
|
||||
|
||||
The service layer automatically selects the appropriate implementation based on environment variables:
|
||||
|
||||
```typescript
|
||||
// In lib/services/create-services.ts
|
||||
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||
|
||||
const analytics = umamiEnabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
: new NoopAnalyticsService();
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for Umami
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
```
|
||||
|
||||
### Optional (defaults provided)
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### AnalyticsService Interface
|
||||
|
||||
#### `track(eventName: string, props?: AnalyticsEventProperties): void`
|
||||
|
||||
Track a custom event with optional properties.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `eventName` - The name of the event to track
|
||||
- `props` - Optional event properties (metadata)
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
service.track("product_add_to_cart", {
|
||||
product_id: "123",
|
||||
product_name: "Cable",
|
||||
price: 99.99,
|
||||
quantity: 1,
|
||||
});
|
||||
```
|
||||
|
||||
#### `trackPageview(url?: string): void`
|
||||
|
||||
Track a pageview.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `url` - The URL to track (defaults to current location)
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// Track current page
|
||||
service.trackPageview();
|
||||
|
||||
// Track custom URL
|
||||
service.trackPageview("/products/123?category=cables");
|
||||
```
|
||||
|
||||
### UmamiAnalyticsService
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `enabled: boolean` - Whether analytics are enabled
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const service = new UmamiAnalyticsService({ enabled: true });
|
||||
```
|
||||
|
||||
### NoopAnalyticsService
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new NoopAnalyticsService();
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const service = new NoopAnalyticsService();
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### AnalyticsEventProperties
|
||||
|
||||
```typescript
|
||||
type AnalyticsEventProperties = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const properties: AnalyticsEventProperties = {
|
||||
product_id: "123",
|
||||
product_name: "Cable",
|
||||
price: 99.99,
|
||||
quantity: 1,
|
||||
in_stock: true,
|
||||
discount: null,
|
||||
};
|
||||
```
|
||||
|
||||
### UmamiAnalyticsServiceOptions
|
||||
|
||||
```typescript
|
||||
type UmamiAnalyticsServiceOptions = {
|
||||
enabled: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use the Service Layer
|
||||
|
||||
Always use the service layer instead of calling Umami directly:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import { getAppServices } from "@/lib/services/create-services";
|
||||
|
||||
const services = getAppServices();
|
||||
services.analytics.track("button_click", { button_id: "cta" });
|
||||
|
||||
// ❌ Avoid
|
||||
(window as any).umami?.track("button_click", { button_id: "cta" });
|
||||
```
|
||||
|
||||
### 2. Check Environment
|
||||
|
||||
The service layer automatically handles environment detection:
|
||||
|
||||
```typescript
|
||||
// ✅ Safe - works in both server and client
|
||||
const services = getAppServices();
|
||||
services.analytics.track("event", { prop: "value" });
|
||||
|
||||
// ❌ Unsafe - may fail in server environment
|
||||
if (typeof window !== "undefined") {
|
||||
window.umami?.track("event", { prop: "value" });
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Type-Safe Events
|
||||
|
||||
Import events from the centralized definitions:
|
||||
|
||||
```typescript
|
||||
import { AnalyticsEvents } from "@/components/analytics/analytics-events";
|
||||
|
||||
// ✅ Type-safe
|
||||
services.analytics.track(AnalyticsEvents.BUTTON_CLICK, {
|
||||
button_id: "cta",
|
||||
});
|
||||
|
||||
// ❌ Prone to typos
|
||||
services.analytics.track("button_click", {
|
||||
button_id: "cta",
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Handle Disabled Analytics
|
||||
|
||||
The service layer gracefully handles disabled analytics:
|
||||
|
||||
```typescript
|
||||
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
|
||||
// - NoopAnalyticsService is used
|
||||
// - All calls are safe (no-op)
|
||||
// - No errors are thrown
|
||||
|
||||
const services = getAppServices();
|
||||
services.analytics.track("event", { prop: "value" }); // Safe, does nothing
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Mocking for Tests
|
||||
|
||||
```typescript
|
||||
// __tests__/analytics-mock.ts
|
||||
export const mockAnalytics = {
|
||||
track: jest.fn(),
|
||||
trackPageview: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock("@/lib/services/create-services", () => ({
|
||||
getAppServices: () => ({
|
||||
analytics: mockAnalytics,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Usage in tests
|
||||
import { mockAnalytics } from "./analytics-mock";
|
||||
|
||||
test("tracks button click", () => {
|
||||
// ... test code ...
|
||||
expect(mockAnalytics.track).toHaveBeenCalledWith("button_click", {
|
||||
button_id: "cta",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
||||
In development, the service layer logs to console:
|
||||
|
||||
```bash
|
||||
# Console output:
|
||||
[Umami] Tracked event: button_click { button_id: 'cta' }
|
||||
[Umami] Tracked pageview: /products/123
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service layer includes built-in error handling:
|
||||
|
||||
1. **Environment Detection** - Checks for browser environment
|
||||
2. **Service Availability** - Checks if Umami is loaded
|
||||
3. **Graceful Degradation** - Falls back to NoopAnalyticsService if needed
|
||||
|
||||
```typescript
|
||||
// These are all safe:
|
||||
const services = getAppServices();
|
||||
services.analytics.track("event", { prop: "value" }); // Works or does nothing
|
||||
services.analytics.trackPageview("/path"); // Works or does nothing
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Singleton Pattern
|
||||
|
||||
The service layer uses a singleton pattern for performance:
|
||||
|
||||
```typescript
|
||||
// First call creates the singleton
|
||||
const services1 = getAppServices();
|
||||
|
||||
// Subsequent calls return the cached singleton
|
||||
const services2 = getAppServices();
|
||||
|
||||
// services1 === services2 (same instance)
|
||||
```
|
||||
|
||||
### Lazy Initialization
|
||||
|
||||
Services are only created when first accessed:
|
||||
|
||||
```typescript
|
||||
// Services are not created until getAppServices() is called
|
||||
// This keeps initial bundle size minimal
|
||||
```
|
||||
|
||||
## Integration with Components
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
|
||||
function MyComponent() {
|
||||
const handleClick = () => {
|
||||
const services = getAppServices();
|
||||
services.analytics.track('button_click', { button_id: 'my-button' });
|
||||
};
|
||||
|
||||
return <button onClick={handleClick}>Click Me</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Server Components
|
||||
|
||||
```typescript
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
|
||||
async function MyServerComponent() {
|
||||
const services = getAppServices();
|
||||
|
||||
// Note: Analytics won't work in server components
|
||||
// Use client components for analytics tracking
|
||||
// But you can still access other services like cache
|
||||
|
||||
const data = await services.cache.get('key');
|
||||
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Analytics Not Working
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify service selection:**
|
||||
|
||||
```typescript
|
||||
import { getAppServices } from "@/lib/services/create-services";
|
||||
|
||||
const services = getAppServices();
|
||||
console.log(services.analytics); // Should be UmamiAnalyticsService
|
||||
```
|
||||
|
||||
3. **Check Umami dashboard:**
|
||||
- Log into Umami
|
||||
- Verify website ID matches
|
||||
- Check if data is being received
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify service is being used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Related Files
|
||||
|
||||
- [`components/analytics/useAnalytics.ts`](../components/analytics/useAnalytics.ts) - Custom hook for easy event tracking
|
||||
- [`components/analytics/analytics-events.ts`](../components/analytics/analytics-events.ts) - Event definitions
|
||||
- [`components/analytics/UmamiScript.tsx`](../components/analytics/UmamiScript.tsx) - Script loader component
|
||||
- [`components/analytics/AnalyticsProvider.tsx`](../components/analytics/AnalyticsProvider.tsx) - Route change tracker
|
||||
- [`lib/services/create-services.ts`](../lib/services/create-services.ts) - Service factory
|
||||
|
||||
## Summary
|
||||
|
||||
The analytics service layer provides:
|
||||
|
||||
- ✅ **Type-safe API** - TypeScript throughout
|
||||
- ✅ **Clean abstraction** - Easy to switch analytics providers
|
||||
- ✅ **Graceful degradation** - Safe no-op fallback
|
||||
- ✅ **Comprehensive documentation** - JSDoc comments and examples
|
||||
- ✅ **Performance optimized** - Singleton pattern, lazy initialization
|
||||
- ✅ **Error handling** - Safe in all environments
|
||||
|
||||
This layer is the foundation for all analytics tracking in the application.
|
||||
@@ -1,3 +1,76 @@
|
||||
/**
|
||||
* Type definition for analytics event properties.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const properties: AnalyticsEventProperties = {
|
||||
* product_id: '123',
|
||||
* product_name: 'Cable',
|
||||
* price: 99.99,
|
||||
* quantity: 1,
|
||||
* in_stock: true,
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export type AnalyticsEventProperties = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Interface for analytics service implementations.
|
||||
*
|
||||
* This interface defines the contract for all analytics services,
|
||||
* allowing for different implementations (Umami, Google Analytics, etc.)
|
||||
* while maintaining a consistent API.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using the service directly
|
||||
* const service = new UmamiAnalyticsService({ enabled: true });
|
||||
* service.track('button_click', { button_id: 'cta' });
|
||||
* service.trackPageview('/products/123');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using the useAnalytics hook (recommended)
|
||||
* const { trackEvent, trackPageview } = useAnalytics();
|
||||
* trackEvent('button_click', { button_id: 'cta' });
|
||||
* trackPageview('/products/123');
|
||||
* ```
|
||||
*/
|
||||
export interface AnalyticsService {
|
||||
trackEvent(name: string, properties?: Record<string, unknown>): void;
|
||||
/**
|
||||
* Track a custom event with optional properties.
|
||||
*
|
||||
* @param eventName - The name of the event to track
|
||||
* @param props - Optional event properties (metadata)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* track('product_add_to_cart', {
|
||||
* product_id: '123',
|
||||
* product_name: 'Cable',
|
||||
* price: 99.99,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||
|
||||
/**
|
||||
* Track a pageview.
|
||||
*
|
||||
* @param url - The URL to track (defaults to current location)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Track current page
|
||||
* trackPageview();
|
||||
*
|
||||
* // Track custom URL
|
||||
* trackPageview('/products/123?category=cables');
|
||||
* ```
|
||||
*/
|
||||
trackPageview(url?: string): void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
import type { AnalyticsService } from "./analytics-service";
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type {
|
||||
AnalyticsEventProperties,
|
||||
AnalyticsService,
|
||||
} from "./analytics-service";
|
||||
|
||||
/**
|
||||
* No-op Analytics Service Implementation.
|
||||
*
|
||||
* This service implements the AnalyticsService interface but does nothing.
|
||||
* It's used as a fallback when analytics are disabled or not configured.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Service creation (usually done by create-services.ts)
|
||||
* const service = new NoopAnalyticsService();
|
||||
*
|
||||
* // These calls do nothing but are safe to execute
|
||||
* service.track('button_click', { button_id: 'cta' });
|
||||
* service.trackPageview('/products/123');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic fallback in create-services.ts
|
||||
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||
* const analytics = umamiEnabled
|
||||
* ? new UmamiAnalyticsService({ enabled: true })
|
||||
* : new NoopAnalyticsService(); // Fallback when no website ID
|
||||
* ```
|
||||
*/
|
||||
export class NoopAnalyticsService implements AnalyticsService {
|
||||
trackEvent() {}
|
||||
/**
|
||||
* No-op implementation of track.
|
||||
*
|
||||
* This method does nothing but maintains the same signature as other
|
||||
* analytics services for consistency.
|
||||
*
|
||||
* @param _eventName - Event name (ignored)
|
||||
* @param _props - Event properties (ignored)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Safe to call even when analytics are disabled
|
||||
* service.track('button_click', { button_id: 'cta' });
|
||||
* // No error, no action taken
|
||||
* ```
|
||||
*/
|
||||
track(_eventName: string, _props?: AnalyticsEventProperties) {
|
||||
// intentionally noop - analytics are disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op implementation of trackPageview.
|
||||
*
|
||||
* This method does nothing but maintains the same signature as other
|
||||
* analytics services for consistency.
|
||||
*
|
||||
* @param _url - URL to track (ignored)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Safe to call even when analytics are disabled
|
||||
* service.trackPageview('/products/123');
|
||||
* // No error, no action taken
|
||||
* ```
|
||||
*/
|
||||
trackPageview(_url?: string) {
|
||||
// intentionally noop - analytics are disabled
|
||||
}
|
||||
}
|
||||
|
||||
112
lib/services/analytics/umami-analytics-service.ts
Normal file
112
lib/services/analytics/umami-analytics-service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
AnalyticsEventProperties,
|
||||
AnalyticsService,
|
||||
} from "./analytics-service";
|
||||
import { config } from "../../config";
|
||||
|
||||
/**
|
||||
* Configuration options for UmamiAnalyticsService.
|
||||
*
|
||||
* @property enabled - Whether analytics are enabled
|
||||
*/
|
||||
export type UmamiAnalyticsServiceOptions = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||
*
|
||||
* This version implements the Umami tracking protocol directly via fetch,
|
||||
* eliminating the need to load an external script.js file.
|
||||
*
|
||||
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||
*/
|
||||
export class UmamiAnalyticsService implements AnalyticsService {
|
||||
private websiteId?: string;
|
||||
private endpoint: string;
|
||||
|
||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||
this.websiteId = config.analytics.umami.websiteId;
|
||||
|
||||
// On server, use the full internal URL; on client, use the proxied path
|
||||
this.endpoint =
|
||||
typeof window === "undefined"
|
||||
? config.analytics.umami.apiEndpoint
|
||||
: "/stats";
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to send the payload to Umami API.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async sendPayload(type: "event", data: Record<string, unknown>) {
|
||||
if (!this.options.enabled || !this.websiteId) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
website: this.websiteId,
|
||||
hostname:
|
||||
typeof window !== "undefined" ? window.location.hostname : "server",
|
||||
screen:
|
||||
typeof window !== "undefined"
|
||||
? `${window.screen.width}x${window.screen.height}`
|
||||
: undefined,
|
||||
language:
|
||||
typeof window !== "undefined" ? navigator.language : undefined,
|
||||
referrer: typeof window !== "undefined" ? document.referrer : undefined,
|
||||
...data,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent":
|
||||
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
|
||||
},
|
||||
body: JSON.stringify({ type, payload }),
|
||||
keepalive: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
if (!response.ok && process.env.NODE_ENV === "development") {
|
||||
const errorText = await response.text();
|
||||
console.warn(
|
||||
`[Umami] API responded with ${response.status}: ${errorText}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("[Umami] Failed to send analytics:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event.
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||
this.sendPayload("event", {
|
||||
name: eventName,
|
||||
data: props,
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a pageview.
|
||||
*/
|
||||
trackPageview(url?: string) {
|
||||
this.sendPayload("event", {
|
||||
url:
|
||||
url ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ErrorReportingService } from "./errors/error-reporting-service";
|
||||
import type { LoggerService } from "./logging/logger-service";
|
||||
import type { NotificationService } from "./notifications/notification-service";
|
||||
|
||||
// Simple constructor-based DI container.
|
||||
export class AppServices {
|
||||
constructor(
|
||||
public readonly analytics: AnalyticsService,
|
||||
|
||||
10
lib/services/cache/cache-service.ts
vendored
10
lib/services/cache/cache-service.ts
vendored
@@ -1,5 +1,9 @@
|
||||
export type CacheSetOptions = {
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
export interface CacheService {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
get<T>(key: string): Promise<T | undefined>;
|
||||
set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
|
||||
del(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
36
lib/services/cache/memory-cache-service.ts
vendored
36
lib/services/cache/memory-cache-service.ts
vendored
@@ -1,26 +1,30 @@
|
||||
import type { CacheService } from "./cache-service";
|
||||
import type { CacheService, CacheSetOptions } from "./cache-service";
|
||||
|
||||
type Entry = {
|
||||
value: unknown;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export class MemoryCacheService implements CacheService {
|
||||
private cache = new Map<string, { value: unknown; expiry: number | null }>();
|
||||
private readonly store = new Map<string, Entry>();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (item.expiry && item.expiry < Date.now()) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
async get<T>(key: string) {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return item.value as T;
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
|
||||
this.cache.set(key, { value, expiry });
|
||||
async set<T>(key: string, value: T, options?: CacheSetOptions) {
|
||||
const ttl = options?.ttlSeconds;
|
||||
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||
this.store.set(key, { value, expiresAt });
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
async del(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { AppServices } from "./app-services";
|
||||
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
|
||||
import { UmamiAnalyticsService } from "./analytics/umami-analytics-service";
|
||||
import { MemoryCacheService } from "./cache/memory-cache-service";
|
||||
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
|
||||
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
|
||||
import { GotifyNotificationService } from "./notifications/gotify-notification-service";
|
||||
import { NoopNotificationService } from "./notifications/noop-notification-service";
|
||||
import {
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from "./notifications/gotify-notification-service";
|
||||
import { PinoLoggerService } from "./logging/pino-logger-service";
|
||||
import { config, getMaskedConfig } from "../config";
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
|
||||
export function getServerAppServices(): AppServices {
|
||||
if (singleton) return singleton;
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger = new PinoLoggerService("server");
|
||||
|
||||
logger.info("Initializing server application services", {
|
||||
@@ -20,7 +23,22 @@ export function getServerAppServices(): AppServices {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const analytics = new NoopAnalyticsService();
|
||||
logger.info("Service configuration", {
|
||||
umamiEnabled: config.analytics.umami.enabled,
|
||||
sentryEnabled: config.errors.glitchtip.enabled,
|
||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||
gotifyEnabled: config.notifications.gotify.enabled,
|
||||
});
|
||||
|
||||
const analytics = config.analytics.umami.enabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (config.analytics.umami.enabled) {
|
||||
logger.info("Umami analytics service initialized");
|
||||
} else {
|
||||
logger.info("Noop analytics service initialized (analytics disabled)");
|
||||
}
|
||||
|
||||
const notifications = config.notifications.gotify.enabled
|
||||
? new GotifyNotificationService({
|
||||
@@ -30,11 +48,35 @@ export function getServerAppServices(): AppServices {
|
||||
})
|
||||
: new NoopNotificationService();
|
||||
|
||||
if (config.notifications.gotify.enabled) {
|
||||
logger.info("Gotify notification service initialized");
|
||||
} else {
|
||||
logger.info(
|
||||
"Noop notification service initialized (notifications disabled)",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = config.errors.glitchtip.enabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
logger.info("GlitchTip error reporting service initialized", {
|
||||
dsnPresent: Boolean(config.errors.glitchtip.dsn),
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
"Noop error reporting service initialized (error reporting disabled)",
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new MemoryCacheService();
|
||||
logger.info("Memory cache service initialized");
|
||||
|
||||
logger.info("Pino logger service initialized", {
|
||||
name: "server",
|
||||
level: config.logging.level,
|
||||
});
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
|
||||
154
lib/services/create-services.ts
Normal file
154
lib/services/create-services.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { AppServices } from "./app-services";
|
||||
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
|
||||
import { MemoryCacheService } from "./cache/memory-cache-service";
|
||||
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
|
||||
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
|
||||
import { NoopLoggerService } from "./logging/noop-logger-service";
|
||||
import { PinoLoggerService } from "./logging/pino-logger-service";
|
||||
import { NoopNotificationService } from "./notifications/gotify-notification-service";
|
||||
import { config, getMaskedConfig } from "../config";
|
||||
|
||||
/**
|
||||
* Singleton instance of AppServices.
|
||||
*
|
||||
* In Next.js, module singletons are per-process (server) and per-tab (client).
|
||||
* This is sufficient for a small service layer and provides better performance
|
||||
* than creating new instances on every request.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
let singleton: AppServices | undefined;
|
||||
|
||||
/**
|
||||
* Get the application services singleton.
|
||||
*
|
||||
* This function creates and caches the application services, including:
|
||||
* - Analytics service (Umami or no-op)
|
||||
* - Error reporting service (GlitchTip/Sentry or no-op)
|
||||
* - Cache service (in-memory)
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a client component
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* services.analytics.track('button_click', { button_id: 'cta' });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a server component or API route
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* await services.cache.set('key', 'value');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic service selection based on environment
|
||||
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
|
||||
* // services.analytics = UmamiAnalyticsService
|
||||
* // If not set:
|
||||
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||
* ```
|
||||
*
|
||||
* @see {@link UmamiAnalyticsService} for analytics implementation
|
||||
* @see {@link NoopAnalyticsService} for no-op fallback
|
||||
* @see {@link GlitchtipErrorReportingService} for error reporting
|
||||
* @see {@link MemoryCacheService} for caching
|
||||
*/
|
||||
export function getAppServices(): AppServices {
|
||||
// Return cached instance if available
|
||||
if (singleton) return singleton;
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger =
|
||||
typeof window === "undefined"
|
||||
? new PinoLoggerService("server")
|
||||
: new NoopLoggerService();
|
||||
|
||||
// Log initialization
|
||||
if (typeof window === "undefined") {
|
||||
// Server-side
|
||||
logger.info("Initializing server application services", {
|
||||
environment: getMaskedConfig(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Client-side
|
||||
logger.info("Initializing client application services", {
|
||||
environment: getMaskedConfig(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which services to enable based on environment variables
|
||||
const umamiEnabled = config.analytics.umami.enabled;
|
||||
const sentryEnabled = config.errors.glitchtip.enabled;
|
||||
|
||||
logger.info("Service configuration", {
|
||||
umamiEnabled,
|
||||
sentryEnabled,
|
||||
isServer: typeof window === "undefined",
|
||||
});
|
||||
|
||||
// Create analytics service (Umami or no-op)
|
||||
// Use dynamic import to avoid importing server-only code in client components
|
||||
const analytics = umamiEnabled
|
||||
? (() => {
|
||||
const { UmamiAnalyticsService } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./analytics/umami-analytics-service");
|
||||
return new UmamiAnalyticsService({ enabled: true });
|
||||
})()
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (umamiEnabled) {
|
||||
logger.info("Umami analytics service initialized");
|
||||
} else {
|
||||
logger.info("Noop analytics service initialized (analytics disabled)");
|
||||
}
|
||||
|
||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||
const errors = sentryEnabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true })
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (sentryEnabled) {
|
||||
logger.info(
|
||||
`GlitchTip error reporting service initialized (${typeof window === "undefined" ? "server" : "client"})`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
"Noop error reporting service initialized (error reporting disabled)",
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: This module is imported by client components.
|
||||
// Do not import Node-only modules (like the `redis` client) here.
|
||||
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
|
||||
const cache = new MemoryCacheService();
|
||||
logger.info("Memory cache service initialized");
|
||||
|
||||
logger.info("Pino logger service initialized", {
|
||||
name: typeof window === "undefined" ? "server" : "client",
|
||||
level: config.logging.level,
|
||||
});
|
||||
|
||||
// Create and cache the singleton
|
||||
const notifications = new NoopNotificationService();
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info("All application services initialized successfully");
|
||||
|
||||
return singleton;
|
||||
}
|
||||
@@ -1,4 +1,27 @@
|
||||
export type ErrorReportingUser = {
|
||||
id?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type ErrorReportingLevel =
|
||||
| "fatal"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "log";
|
||||
|
||||
export interface ErrorReportingService {
|
||||
captureException(error: unknown, context?: Record<string, unknown>): void;
|
||||
captureMessage(message: string, context?: Record<string, unknown>): void;
|
||||
captureException(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<string | undefined> | string | undefined;
|
||||
captureMessage(
|
||||
message: string,
|
||||
level?: ErrorReportingLevel,
|
||||
): Promise<string | undefined> | string | undefined;
|
||||
setUser(user: ErrorReportingUser | null): void;
|
||||
setTag(key: string, value: string): void;
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { ErrorReportingService } from "./error-reporting-service";
|
||||
import type {
|
||||
ErrorReportingLevel,
|
||||
ErrorReportingService,
|
||||
ErrorReportingUser,
|
||||
} from "./error-reporting-service";
|
||||
import type { NotificationService } from "../notifications/notification-service";
|
||||
|
||||
export interface GlitchtipConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
type SentryLike = typeof Sentry;
|
||||
|
||||
export type GlitchtipErrorReportingServiceOptions = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
constructor(
|
||||
private readonly config: GlitchtipConfig,
|
||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||
private readonly notifications?: NotificationService,
|
||||
private readonly sentry: SentryLike = Sentry,
|
||||
) {}
|
||||
|
||||
captureException(error: unknown, context?: Record<string, unknown>) {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
if (context) {
|
||||
scope.setExtras(context);
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||
if (!this.options.enabled) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = this.sentry.captureException(error, context as any) as any;
|
||||
|
||||
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
||||
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
||||
if (this.notifications) {
|
||||
this.notifications
|
||||
.notify({
|
||||
title: "🚨 Exception Captured",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
priority: 10,
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("Failed to send notification for exception", err),
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const contextStr = context
|
||||
? `\nContext: ${JSON.stringify(context, null, 2)}`
|
||||
: "";
|
||||
|
||||
await this.notifications.notify({
|
||||
title: "🔥 Critical Error Captured",
|
||||
message: `Error: ${errorMessage}${contextStr}`,
|
||||
priority: 7,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
captureMessage(message: string, context?: Record<string, unknown>) {
|
||||
if (!this.config.enabled) return;
|
||||
captureMessage(message: string, level: ErrorReportingLevel = "error") {
|
||||
if (!this.options.enabled) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return this.sentry.captureMessage(message, level as any) as any;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
setUser(user: ErrorReportingUser | null) {
|
||||
if (!this.options.enabled) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.sentry.setUser(user as any);
|
||||
}
|
||||
|
||||
setTag(key: string, value: string) {
|
||||
if (!this.options.enabled) return;
|
||||
this.sentry.setTag(key, value);
|
||||
}
|
||||
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
|
||||
if (!this.options.enabled) return fn();
|
||||
|
||||
return this.sentry.withScope((scope) => {
|
||||
if (context) {
|
||||
scope.setExtras(context);
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
scope.setExtra(key, value);
|
||||
}
|
||||
}
|
||||
Sentry.captureMessage(message);
|
||||
return fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import type { ErrorReportingService } from "./error-reporting-service";
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type {
|
||||
ErrorReportingLevel,
|
||||
ErrorReportingService,
|
||||
ErrorReportingUser,
|
||||
} from "./error-reporting-service";
|
||||
|
||||
export class NoopErrorReportingService implements ErrorReportingService {
|
||||
captureException() {}
|
||||
captureMessage() {}
|
||||
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setUser(_user: ErrorReportingUser | null) {}
|
||||
setTag(_key: string, _value: string) {}
|
||||
|
||||
withScope<T>(fn: () => T, _context?: Record<string, unknown>) {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
export interface LoggerService {
|
||||
debug(message: string, context?: Record<string, unknown>): void;
|
||||
info(message: string, context?: Record<string, unknown>): void;
|
||||
warn(message: string, context?: Record<string, unknown>): void;
|
||||
error(message: string, context?: Record<string, unknown>): void;
|
||||
child(context: Record<string, unknown>): LoggerService;
|
||||
trace(msg: string, ...args: unknown[]): void;
|
||||
debug(msg: string, ...args: unknown[]): void;
|
||||
info(msg: string, ...args: unknown[]): void;
|
||||
warn(msg: string, ...args: unknown[]): void;
|
||||
error(msg: string, ...args: unknown[]): void;
|
||||
fatal(msg: string, ...args: unknown[]): void;
|
||||
child(bindings: Record<string, unknown>): LoggerService;
|
||||
}
|
||||
|
||||
13
lib/services/logging/noop-logger-service.ts
Normal file
13
lib/services/logging/noop-logger-service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { LoggerService } from "./logger-service";
|
||||
|
||||
export class NoopLoggerService implements LoggerService {
|
||||
trace() {}
|
||||
debug() {}
|
||||
info() {}
|
||||
warn() {}
|
||||
error() {}
|
||||
fatal() {}
|
||||
child() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { pino, type Logger as PinoLogger } from "pino";
|
||||
import pino, { Logger as PinoLogger } from "pino";
|
||||
import type { LoggerService } from "./logger-service";
|
||||
import { config } from "../../config";
|
||||
|
||||
export class PinoLoggerService implements LoggerService {
|
||||
private logger: PinoLogger;
|
||||
private readonly logger: PinoLogger;
|
||||
|
||||
constructor(name?: string, parent?: PinoLogger) {
|
||||
if (parent) {
|
||||
this.logger = parent.child({ name });
|
||||
} else {
|
||||
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||
// pino transports (which use worker threads) can cause issues.
|
||||
// We disable transport in production and during instrumentation.
|
||||
const useTransport =
|
||||
config.isDevelopment && typeof window === "undefined";
|
||||
|
||||
@@ -27,30 +30,41 @@ export class PinoLoggerService implements LoggerService {
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>) {
|
||||
if (context) this.logger.debug(context, message);
|
||||
else this.logger.debug(message);
|
||||
trace(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.trace(msg, ...(args as any));
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>) {
|
||||
if (context) this.logger.info(context, message);
|
||||
else this.logger.info(message);
|
||||
debug(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.debug(msg, ...(args as any));
|
||||
}
|
||||
|
||||
warn(message: string, context?: Record<string, unknown>) {
|
||||
if (context) this.logger.warn(context, message);
|
||||
else this.logger.warn(message);
|
||||
info(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.info(msg, ...(args as any));
|
||||
}
|
||||
|
||||
error(message: string, context?: Record<string, unknown>) {
|
||||
if (context) this.logger.error(context, message);
|
||||
else this.logger.error(message);
|
||||
warn(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.warn(msg, ...(args as any));
|
||||
}
|
||||
|
||||
child(context: Record<string, unknown>): LoggerService {
|
||||
const childPino = this.logger.child(context);
|
||||
error(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.error(msg, ...(args as any));
|
||||
}
|
||||
|
||||
fatal(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.fatal(msg, ...(args as any));
|
||||
}
|
||||
|
||||
child(bindings: Record<string, unknown>): LoggerService {
|
||||
const childPino = this.logger.child(bindings);
|
||||
const service = new PinoLoggerService();
|
||||
service.logger = childPino;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(service as any).logger = childPino;
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
NotificationMessage,
|
||||
import {
|
||||
NotificationOptions,
|
||||
NotificationService,
|
||||
} from "./notification-service";
|
||||
|
||||
@@ -10,35 +10,44 @@ export interface GotifyConfig {
|
||||
}
|
||||
|
||||
export class GotifyNotificationService implements NotificationService {
|
||||
constructor(private readonly config: GotifyConfig) {}
|
||||
constructor(private config: GotifyConfig) {}
|
||||
|
||||
async notify(message: NotificationMessage): Promise<void> {
|
||||
async notify(options: NotificationOptions): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.config.url}/message?token=${this.config.token}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: message.title,
|
||||
message: message.message,
|
||||
priority: message.priority ?? 5,
|
||||
}),
|
||||
const { title, message, priority = 4 } = options;
|
||||
const url = new URL("message", this.config.url);
|
||||
url.searchParams.set("token", this.config.token);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Failed to send Gotify notification",
|
||||
await response.text(),
|
||||
);
|
||||
const errorText = await response.text();
|
||||
console.error("Gotify notification failed:", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending Gotify notification", error);
|
||||
console.error("Gotify notification error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async notify(_options: NotificationOptions): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { NotificationService } from "./notification-service";
|
||||
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
async notify() {}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
export interface NotificationMessage {
|
||||
export interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: number; // 0-10, Gotify style
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface NotificationService {
|
||||
notify(message: NotificationMessage): Promise<void>;
|
||||
notify(options: NotificationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import withMintelConfig from "@mintel/next-config";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
const umamiUrl =
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.UMAMI_SCRIPT_URL ||
|
||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
|
||||
"https://analytics.infra.mintel.me";
|
||||
const glitchtipUrl = process.env.SENTRY_DSN
|
||||
? new URL(process.env.SENTRY_DSN).origin
|
||||
: "https://errors.infra.mintel.me";
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/stats/:path*",
|
||||
destination: `${umamiUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/errors/:path*",
|
||||
destination: `${glitchtipUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default withMintelConfig(nextConfig);
|
||||
|
||||
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- '@sentry/cli'
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -25,7 +25,7 @@ REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-production"; ENV_FILE=".env.prod" ;;
|
||||
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -35,8 +35,21 @@ DB_NAME="directus"
|
||||
|
||||
echo "🔍 Detecting local database..."
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
||||
# Check if it exists but is stopped
|
||||
LOCAL_DB_EXISTS=$(docker compose ps -a -q directus-db)
|
||||
if [ -n "$LOCAL_DB_EXISTS" ]; then
|
||||
echo "⏳ Local directus-db is stopped. Starting it..."
|
||||
docker compose up -d directus-db
|
||||
# Wait a few seconds for PG to be ready
|
||||
sleep 2
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Is it defined in docker-compose.yaml?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
0
tests/.gitkeep
Normal file
0
tests/.gitkeep
Normal file
@@ -1,97 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import Contact from '../app/kontakt/page'
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn()
|
||||
global.fetch = fetchMock
|
||||
|
||||
// Mock alert
|
||||
const alertMock = vi.fn()
|
||||
global.alert = alertMock
|
||||
|
||||
describe('Contact Page', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear()
|
||||
alertMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders the contact form correctly', () => {
|
||||
render(<Contact />)
|
||||
|
||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Nachricht senden/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the form successfully', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
|
||||
render(<Contact />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
||||
fireEvent.change(screen.getByLabelText(/Firma/i), { target: { value: 'Acme Corp' } })
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/contact', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'John Doe',
|
||||
company: 'Acme Corp',
|
||||
email: 'john@example.com',
|
||||
message: 'This is a test message that is long enough.',
|
||||
website: ''
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Vielen Dank für Ihre Anfrage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles submission errors', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Server error' }),
|
||||
})
|
||||
|
||||
render(<Contact />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith('Fehler: Server error')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles network errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
render(<Contact />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith('Es gab einen Fehler beim Senden Ihrer Nachricht.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Home from '../app/page'
|
||||
|
||||
describe('Home Page', () => {
|
||||
it('renders the hero section with correct title', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('contains the CTA button', () => {
|
||||
render(<Home />)
|
||||
const ctaButton = screen.getByRole('link', { name: /Projekt anfragen/i })
|
||||
expect(ctaButton).toBeInTheDocument()
|
||||
expect(ctaButton).toHaveAttribute('href', '/kontakt')
|
||||
})
|
||||
|
||||
it('renders the portfolio section', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument()
|
||||
// Use getAllByText because it appears in both hero description and card title
|
||||
const elements = screen.getAllByText(/Technische Beratung/i)
|
||||
expect(elements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
126
tests_bak/contact.test.tsx
Normal file
126
tests_bak/contact.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Contact from "../app/kontakt/page";
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Mock alert
|
||||
const alertMock = vi.fn();
|
||||
global.alert = alertMock;
|
||||
|
||||
describe("Contact Page", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
alertMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders the contact form correctly", () => {
|
||||
render(<Contact />);
|
||||
|
||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Nachricht senden/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Firma/i), {
|
||||
target: { value: "Acme Corp" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/contact",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "John Doe",
|
||||
company: "Acme Corp",
|
||||
email: "john@example.com",
|
||||
message: "This is a test message that is long enough.",
|
||||
website: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Vielen Dank für Ihre Anfrage/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles submission errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Server error" }),
|
||||
});
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith("Fehler: Server error");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles network errors", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
"Es gab einen Fehler beim Senden Ihrer Nachricht.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
tests_bak/home.test.tsx
Normal file
27
tests_bak/home.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Home from "../app/page";
|
||||
|
||||
describe("Home Page", () => {
|
||||
it("renders the hero section with correct title", () => {
|
||||
render(<Home />);
|
||||
expect(
|
||||
screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("contains the CTA button", () => {
|
||||
render(<Home />);
|
||||
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
||||
});
|
||||
|
||||
it("renders the portfolio section", () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
||||
// Use getAllByText because it appears in both hero description and card title
|
||||
const elements = screen.getAllByText(/Technische Beratung/i);
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
12
tests_bak/setup.ts
Normal file
12
tests_bak/setup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
@@ -13,5 +13,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "tests", "tests_bak"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user