Compare commits

65 Commits

Author SHA1 Message Date
DanielS
4a17ea378e feat: make orders list clearer and put navbar globally except for admin area
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:37:01 +02:00
DanielS
e829006b77 feat: update contact link on homepage to point to caspos.de/kontakt/
Some checks failed
Staging Build / build (push) Failing after 1m56s
2026-06-24 01:26:44 +02:00
DanielS
76e46c08a4 feat: update privacy policy links to point to caspos.de/datenschutz/
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:25:41 +02:00
DanielS
01c46be9ef add zulip dummy for quick access
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:23:30 +02:00
DanielS
f09cec4e25 fix: ignore network errors during concurrent session check to prevent false-positive logouts
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 01:14:33 +02:00
DanielS
b34ed79d2b feat: implement next redirect flow, remove inactivity auto-logout, and add concurrent session termination notification
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:09:17 +02:00
DanielS
47664c0aae fix: resolve mixed content errors by synchronizing client supabase URL protocol with window location protocol
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 00:57:22 +02:00
DanielS
5662b9f770 feat: allow admin to order licenses and query all partner end customers
All checks were successful
Staging Build / build (push) Successful in 2m15s
2026-06-24 00:32:20 +02:00
DanielS
a56eb93ffc feat: handle redirects for admin in /order page and implement next query param on login
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:29:46 +02:00
DanielS
615b0719f1 feat: redirect user dynamically based on role instead of /protected after login
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 00:23:51 +02:00
DanielS
62e3c7804a feat: fix logout button, inactivity tracker, and concurrent session termination
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:15:59 +02:00
DanielS
9878cdb2a2 feat: add CASPOS Store brand and Demo badge to admin sidebar footer
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:12:45 +02:00
DanielS
f19fda5494 feat: enforce admin role checks in layouts and navbar navigation
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:05:16 +02:00
DanielS
7b22889e23 feat: add partner list and customer list explorer to admin settings
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:02:27 +02:00
DanielS
bdbf840529 feat: lock down registration and enforce partner-only logins
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 23:59:22 +02:00
DanielS
b71b84da1b migration: create public.customers table with RLS
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 23:52:57 +02:00
DanielS
1b4e998cf9 feat: add admin settings page with toggle switch for demo banner
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-23 23:43:22 +02:00
DanielS
a99adb88d6 feat: update navbar links, settings labels, dark mode text color, and banner toggle hook 2026-06-23 23:43:16 +02:00
DanielS
dd2c3859e9 feat: add custom navbar with inline supabase clients and custom dropdown
All checks were successful
Staging Build / build (push) Successful in 2m22s
2026-06-23 23:27:23 +02:00
DanielS
0729dd21be Implement dynamic SMTP setting loader, admin product dependency matrix UI, recursive deselect cascade, and backend catalog validation
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:42:50 +02:00
DanielS
de1d228fb2 feat: port all auth forms to Next.js Server Actions to avoid exposing Supabase API to the browser
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:39:00 +02:00
DanielS
3228c76675 fix: use maybeSingle for settings query to avoid failing when the table is empty
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 04:36:16 +02:00
DanielS
b039da3ff8 fix: force dynamic rendering on admin users page to load data at request-time
All checks were successful
Staging Build / build (push) Successful in 2m20s
2026-06-23 04:29:58 +02:00
DanielS
07eb18c61c debug: print detailed error message when loading admin users fails
All checks were successful
Staging Build / build (push) Successful in 2m25s
2026-06-23 04:11:14 +02:00
DanielS
5ba9869400 feat: create users and settings tables, replace getSession with getUser
All checks were successful
Staging Build / build (push) Successful in 2m29s
2026-06-23 04:05:39 +02:00
DanielS
9542fdfe4a migration: add order_number and order_hash columns to orders
All checks were successful
Staging Build / build (push) Successful in 2m27s
2026-06-23 04:02:36 +02:00
DanielS
88f18246ce feat: split pricing into one-time and monthly totals, fix invoice pdf rendering
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 04:01:03 +02:00
DanielS
6b76e82220 fix: resolve customer creation fk violation and implement instant updates in admin dialogs
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:27:46 +02:00
DanielS
898a5253e5 fix: remove default seeded categories from migrations
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:22:30 +02:00
DanielS
756ec5c002 feat: add automatic database migration and seeding on docker startup
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-06-23 03:14:40 +02:00
DanielS
235efd5a47 fix: use static auth cookie name to sync session across browser and server contexts
All checks were successful
Staging Build / build (push) Successful in 1m56s
2026-06-23 02:58:55 +02:00
DanielS
c0c5240f8d fix: resolve internal docker hostnames to client window location hostname in browser context
All checks were successful
Staging Build / build (push) Successful in 1m49s
2026-06-23 02:42:50 +02:00
DanielS
ca69db2395 fix: prioritize SUPABASE_URL and SUPABASE_ANON_KEY server-side environment variables
All checks were successful
Staging Build / build (push) Successful in 1m47s
2026-06-23 02:37:48 +02:00
DanielS
e789a38d19 fix: add environment variable fallback for Supabase client configuration
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 02:36:31 +02:00
DanielS
2ec5993e63 Add Supabase ARG/ENV before build and in runtime stage
All checks were successful
Staging Build / build (push) Successful in 1m59s
2026-06-23 02:16:45 +02:00
DanielS
6db2610c8d Update Docker build to use internal supabase‑kong URL"
All checks were successful
Staging Build / build (push) Successful in 1m53s
2026-06-23 02:10:11 +02:00
DanielS
0b2809b13e Add Supabase ARG/ENV before build in Dockerfile (final)
All checks were successful
Staging Build / build (push) Successful in 1m41s
2026-06-23 01:32:46 +02:00
DanielS
9ffa04e551 Insert Supabase ARG/ENV before Next.js build in Dockerfile
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 01:16:37 +02:00
DanielS
505629f798 Add build args to Docker build step in staging workflow
All checks were successful
Staging Build / build (push) Successful in 1m46s
2026-06-23 01:09:58 +02:00
DanielS
08418e22d3 Add Supabase keys to staging build step
All checks were successful
Staging Build / build (push) Successful in 1m44s
2026-06-23 01:03:55 +02:00
DanielS
0f55e17095 Enable Next.js standalone mode and simplify Dockerfile copy block
All checks were successful
Staging Build / build (push) Successful in 1m43s
2026-06-23 00:32:08 +02:00
DanielS
fa1edc9c12 Add cookies handling to smtp-settings routes
Some checks failed
Staging Build / build (push) Failing after 1m55s
2026-06-23 00:27:55 +02:00
DanielS
bab2930b97 Add cookies handling to send-test-email route
Some checks failed
Staging Build / build (push) Failing after 27s
2026-06-23 00:26:08 +02:00
DanielS
e97ff51dc8 Fix client directive on admin settings page and install nodemailer
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-23 00:23:47 +02:00
DanielS
4402d4ec64 Add SMTP mail utility, admin settings page, and API routes for SMTP configuration and test email
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-23 00:17:41 +02:00
DanielS
4b9c289ec4 test build server
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-06-22 23:54:12 +02:00
DanielS
072ceaf5f0 fix build yaml staging adress of the host for deployment
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-06-22 23:40:47 +02:00
DanielS
e31721432b Update optional TS copy lines with wildcard in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 2m34s
2026-06-22 23:34:29 +02:00
DanielS
e1a555d4f9 Fix duplicate next.config copy in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 1m30s
2026-06-22 23:29:29 +02:00
DanielS
8bf59b20f6 Make public copy flexible with wildcard in Dockerfile 2026-06-22 23:29:06 +02:00
DanielS
3bc7c6ad3b Make Dockerfile tolerant to missing config files
Some checks failed
Staging Build / build (push) Failing after 1m32s
2026-06-22 23:24:58 +02:00
DanielS
65e133a391 fix staging build yaml
Some checks failed
Staging Build / build (push) Failing after 1m45s
2026-06-22 23:18:41 +02:00
DanielS
7ab0c28f6f fix staging build yaml docker build image
Some checks failed
Staging Build / build (push) Failing after 29s
2026-06-22 23:14:22 +02:00
DanielS
c187c1dc60 fix dockerfile position
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 23:10:58 +02:00
DanielS
9ddf5faf66 change from GITEA to REGISTRY for build server secrets
Some checks failed
Staging Build / build (push) Failing after 41s
2026-06-22 23:05:13 +02:00
DanielS
f11322e98d ci: fix docker login command and update deployment paths
Some checks failed
Staging Build / build (push) Failing after 31s
2026-06-22 22:45:20 +02:00
DanielS
126de81834 add container for build server
Some checks failed
Staging Build / build (push) Failing after 1m22s
2026-06-22 22:41:04 +02:00
DanielS
7bb6807d37 Fix global error page, update CI workflow, add Dockerfile
Some checks failed
Staging Build / build (push) Failing after 34s
2026-06-22 22:30:55 +02:00
DanielS
3eb72481ce Add key props to recent orders list rendering
Some checks failed
Staging Build / build (push) Failing after 26s
2026-06-22 22:25:21 +02:00
DanielS
2403408b6c Fix React key props in order and my-orders pages 2026-06-22 22:24:21 +02:00
DanielS
f91499861e fix: korrigiere workflow syntax
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 21:41:42 +02:00
DanielS
84ced5a170 remove unused build config
Some checks failed
Staging Build / build (push) Failing after 2s
2026-06-22 21:38:57 +02:00
DanielS
d7a45576b2 build server test
Some checks failed
Staging Build / build (push) Failing after 1m1s
2026-06-22 21:32:53 +02:00
DanielS
dedf1945ef correct build server config
Some checks failed
Staging Build / build (push) Failing after 24s
2026-06-22 21:27:06 +02:00
e6913c03f5 Merge pull request 'develop build server' (#1) from develop into staging
Some checks failed
Staging Build / build (push) Failing after 48s
Reviewed-on: #1
2026-06-22 21:24:09 +02:00
58 changed files with 2794 additions and 475 deletions

View File

@@ -7,6 +7,11 @@ on:
jobs: jobs:
build: build:
runs-on: webserver runs-on: webserver
container:
image: catthehacker/ubuntu:act-latest
defaults:
run:
working-directory: shop
steps: steps:
- name: Code auschecken - name: Code auschecken
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -15,4 +20,39 @@ jobs:
run: npm install run: npm install
- name: Staging-Build generieren - name: Staging-Build generieren
run: npm run build -- --configuration=staging env:
NODE_ENV: production
# KORREKTUR: Wir brennen die interne Docker-Netzwerk-URL fest ein!
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }}
run: npm run build
- name: Docker login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# KORREKTUR: Wir übergeben die INTERNE Docker-URL auch an das Docker-Image!
- name: Build Docker image
run: |
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL="http://supabase-kong:8000" \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${{ secrets.STAGING_SUPABASE_ANON_KEY }}" \
-t gitea.hephex.de/daniel/webshop:staging -f Dockerfile .
working-directory: .
# KORREKTUR: Auch der Push wird vom Hauptordner aus gestartet
- name: Push Docker image
run: docker push gitea.hephex.de/daniel/webshop:staging
working-directory: .
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
- name: Deploy to Proxmox LXC via SSH
uses: appleboy/ssh-action@v0.1.5
with:
host: 192.168.178.132
username: ${{ secrets.PROXMOX_USER }}
key: ${{ secrets.PROXMOX_SSH_KEY }}
script: |
cd /opt/webshop/supabase/supabase-project
docker compose pull webshop
docker compose up -d webshop

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Use a multistage build for a Next.js app
# ---------- Builder stage ----------
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Install dependencies (use package lock when present)
COPY shop/package*.json ./
RUN npm ci --omit=dev
# Install nodemailer for email sending
RUN npm install nodemailer
# Copy the source code
COPY shop/ .
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
# Build the production output
RUN npm run build
# ---------- Runtime stage ----------
FROM node:20-alpine AS runner
WORKDIR /app
# Statische Assets kopieren (wichtig für die Chunks/404-Fehler von vorhin)
COPY --from=builder /app/public* ./public
COPY --from=builder /app/.next/static ./.next/static
# Den fertigen Standalone-Server kopieren
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/supabase ./supabase
COPY --from=builder /app/migrate.js ./migrate.js
# Pass Supabase variables to runtime image
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["sh", "-c", "node migrate.js && node server.js"]

35
scripts/init_supabase.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
set -e
# Initialize Supabase if needed and ensure default admin user exists
# Requires environment variables:
# SUPABASE_URL base URL of Supabase instance (e.g., http://192.168.1.132:8000)
# SUPABASE_SERVICE_ROLE_KEY service role secret key with admin privileges
# DEFAULT_USER_EMAIL email for the default user (default: info@hephex.de)
# DEFAULT_USER_PASSWORD password for the default user (default: Kathi2022!)
DEFAULT_EMAIL="${DEFAULT_USER_EMAIL:-info@hephex.de}"
DEFAULT_PASSWORD="${DEFAULT_USER_PASSWORD:-Kathi2022!}"
if [[ -z "$SUPABASE_URL" || -z "$SUPABASE_SERVICE_ROLE_KEY" ]]; then
echo "Supabase URL or Service Role Key not set skipping user creation."
exit 0
fi
# Check if the user already exists
EXISTING=$(curl -s "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Content-Type: application/json" | grep -i "${DEFAULT_EMAIL}")
if [[ -n "$EXISTING" ]]; then
echo "Default user ${DEFAULT_EMAIL} already exists."
else
echo "Creating default user ${DEFAULT_EMAIL}..."
curl -s -X POST "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Content-Type: application/json" \
-d "{\"email\": \"${DEFAULT_EMAIL}\", \"password\": \"${DEFAULT_PASSWORD}\", \"email_confirmed\": true}"
echo "User created."
fi
exec "$@"

View File

@@ -0,0 +1,27 @@
'use client'
import { useEffect } from 'react'
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
// Ensure the page does not depend on any context or Supabase client.
useEffect(() => {
console.error('Global error caught:', error)
}, [error])
return (
<div className="min-h-screen bg-[#020617] text-white flex items-center justify-center p-8">
<div className="max-w-lg space-y-4 text-center">
<h1 className="text-4xl font-extrabold">Ein unerwarteter Fehler ist aufgetreten</h1>
<p className="text-slate-300">
Bitte versuchen Sie die Seite zu aktualisieren oder kontaktieren Sie den Support.
</p>
<button
onClick={reset}
className="mt-4 px-6 py-2 bg-primary text-white rounded hover:bg-primary/90"
>
Seite neu laden
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,321 @@
'use client';
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getPartners, createUser } from '@/lib/actions/users';
import { getPartnerEndCustomers } from '@/lib/actions/end-customers';
import { Plus, Users, Mail, Eye, Key, CheckCircle, ShieldAlert, Loader2 } from 'lucide-react';
export default function AdminSettings() {
const [demoActive, setDemoActive] = useState(true);
const [partners, setPartners] = useState<any[]>([]);
const [loadingPartners, setLoadingPartners] = useState(true);
// Modal States
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCustomersOpen, setIsCustomersOpen] = useState(false);
// Selected Partner / Customers State
const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
const [partnerCustomers, setPartnerCustomers] = useState<any[]>([]);
const [loadingCustomers, setLoadingCustomers] = useState(false);
// Create Partner Form States
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
useEffect(() => {
// 1. Demo Banner Status auslesen
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
// 2. Partner laden
loadPartners();
}, []);
const loadPartners = async () => {
setLoadingPartners(true);
try {
const data = await getPartners();
setPartners(data);
} catch (err) {
console.error('Fehler beim Laden der Partner:', err);
} finally {
setLoadingPartners(false);
}
};
const toggleDemo = (checked: boolean) => {
setDemoActive(checked);
localStorage.setItem('demo_banner_disabled', (!checked).toString());
window.dispatchEvent(new Event('storage_demo_changed'));
};
const handleCreatePartner = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
setCreateError(null);
try {
await createUser({
email,
password: password || undefined,
email_confirm: true,
});
setIsCreateOpen(false);
setEmail('');
setPassword('');
loadPartners();
} catch (err: any) {
console.error('Fehler beim Erstellen des Partners:', err);
setCreateError(err.message || 'Ein Fehler ist aufgetreten.');
} finally {
setCreating(false);
}
};
const handleShowCustomers = async (partner: any) => {
setSelectedPartner(partner);
setPartnerCustomers([]);
setLoadingCustomers(true);
setIsCustomersOpen(true);
try {
const customers = await getPartnerEndCustomers(partner.id);
setPartnerCustomers(customers);
} catch (err) {
console.error('Fehler beim Laden der Partner-Kunden:', err);
} finally {
setLoadingCustomers(false);
}
};
return (
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Verwalten Sie globale Shopeinstellungen, Partnerkonten und Kundenzuweisungen.
</p>
</div>
{/* Allgemeine Einstellungen */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
<h2 className="text-lg font-bold">Allgemeine Einstellungen</h2>
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
<div>
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
</div>
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
</div>
</div>
{/* Partnerverwaltung */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600 dark:text-blue-500" />
<h2 className="text-lg font-bold">Partner-Management</h2>
</div>
<Button onClick={() => setIsCreateOpen(true)} className="bg-blue-600 hover:bg-blue-700 text-white font-semibold">
<Plus className="w-4 h-4 mr-2" /> Partner hinzufügen
</Button>
</div>
{/* Partner Liste */}
{loadingPartners ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 text-blue-600 dark:text-blue-500 animate-spin" />
<span className="ml-3 text-slate-500 text-sm">Lade Partner...</span>
</div>
) : partners.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
Keine Partner registriert.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
<th className="pb-3">Partner E-Mail</th>
<th className="pb-3">Status</th>
<th className="pb-3">Erstellt am</th>
<th className="pb-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
{partners.map((partner) => (
<tr key={partner.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
<td className="py-4 font-medium text-slate-900 dark:text-white">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-400" />
{partner.email}
</div>
</td>
<td className="py-4">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400 border border-green-200 dark:border-green-500/20">
<CheckCircle className="w-3.5 h-3.5" /> Aktiv
</span>
</td>
<td className="py-4 text-sm text-slate-500 dark:text-slate-400">
{partner.created_at ? new Date(partner.created_at).toLocaleDateString('de-DE') : ''}
</td>
<td className="py-4 text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleShowCustomers(partner)}
className="border-slate-200 hover:bg-slate-100 dark:border-white/10 dark:hover:bg-white/5 gap-1.5"
>
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-500" /> Kundenliste
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* MODAL 1: Neuen Partner erstellen */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogHeader>
<DialogTitle>Neuen Partner anlegen</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Erstellt einen Partner-Zugang. Dieser kann sich mit den Daten im Shop einloggen.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreatePartner} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="email">E-Mail Adresse</Label>
<Input
id="email"
type="email"
required
placeholder="partner@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort (optional)</Label>
<Input
id="password"
type="password"
placeholder="Leer lassen für Zufallspasswort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
{createError && (
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4" /> {createError}
</p>
)}
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)} disabled={creating}>
Abbrechen
</Button>
<Button type="submit" disabled={creating} className="bg-blue-600 hover:bg-blue-700 text-white font-semibold">
{creating ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Partner erstellen
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* MODAL 2: Kundenliste eines Partners anzeigen */}
<Dialog open={isCustomersOpen} onOpenChange={setIsCustomersOpen}>
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Kunden von {selectedPartner?.email}</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Liste aller angelegten Endkunden dieses Partners.
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{loadingCustomers ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 text-blue-600 dark:text-blue-500 animate-spin" />
<span className="ml-3 text-slate-500 text-sm">Lade Kunden...</span>
</div>
) : partnerCustomers.length === 0 ? (
<div className="text-center py-12 border border-dashed border-slate-200 dark:border-white/10 rounded-xl text-slate-500">
Dieser Partner hat noch keine Kunden angelegt.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
<th className="pb-3">Unternehmen</th>
<th className="pb-3">Ansprechpartner</th>
<th className="pb-3">Ort</th>
<th className="pb-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
{partnerCustomers.map((customer) => (
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
<td className="py-4 font-semibold text-slate-900 dark:text-white">
{customer.company_name}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ') || ''}
</td>
<td className="py-4 text-sm text-slate-500 dark:text-slate-400">
{[customer.zip, customer.city].filter(Boolean).join(' ') || ''}
</td>
<td className="py-4">
{customer.is_anonymized ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-400 border border-red-200 dark:border-red-500/20">
Anonymisiert
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400 border border-green-200 dark:border-green-500/20">
Aktiv
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<DialogFooter className="pt-4 border-t border-slate-100 dark:border-white/5">
<Button type="button" onClick={() => setIsCustomersOpen(false)}>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,11 +1,28 @@
import Link from 'next/link' import Link from 'next/link'
import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } from 'lucide-react' import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } from 'lucide-react'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
export default function AdminLayout({ export default async function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || userData.role !== 'admin') {
redirect('/')
}
return ( return (
<div className="flex min-h-screen bg-[#020617] text-white"> <div className="flex min-h-screen bg-[#020617] text-white">
{/* Sidebar */} {/* Sidebar */}
@@ -46,11 +63,14 @@ export default function AdminLayout({
</Link> </Link>
</nav> </nav>
<div className="p-4 border-t border-white/5"> <div className="p-4 border-t border-white/5 space-y-4">
<button className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all"> <AdminLogoutButton />
<LogOut className="w-5 h-5" /> <div className="px-3 pt-2 flex items-center gap-2 border-t border-white/5 opacity-60">
Abmelden <span className="font-bold text-sm tracking-tighter text-white">CASPOS Store</span>
</button> <span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</div>
</div> </div>
</aside> </aside>

View File

@@ -1,19 +1,13 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { OrdersTable } from '@/components/admin/orders-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { FileText, Download, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Suspense } from 'react' import { Suspense } from 'react'
export default async function AdminOrdersPage() { export default async function AdminOrdersPage() {
return ( return (
<div className="space-y-6"> <div className="p-8 space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight text-white">Bestellungen & Rechnungen</h1> <h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen & Rechnungen</h1>
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p> <p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
</div> </div>
</div> </div>
@@ -34,75 +28,8 @@ async function OrdersData() {
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
if (error) { if (error) {
return <div>Fehler beim Laden der Bestellungen: {error.message}</div> return <div className="text-red-500">Fehler beim Laden der Bestellungen: {error.message}</div>
} }
return ( return <OrdersTable initialOrders={orders || []} />
<Card className="glass-dark border-white/10">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Rechnung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
Noch keine Bestellungen vorhanden.
</TableCell>
</TableRow>
) : (
orders?.map((order) => (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
<TableCell className="font-mono text-xs text-primary font-semibold">
{order.order_number ?? order.id.slice(0, 8) + '...'}
</TableCell>
<TableCell className="text-slate-300">
{new Date(order.created_at).toLocaleDateString('de-DE')}
</TableCell>
<TableCell>
<div className="font-medium text-white">{order.customer_data?.company_name || 'Privatkunde'}</div>
<div className="text-xs text-slate-400">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
</TableCell>
<TableCell className="font-semibold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>
<Badge variant={order.status === 'completed' ? 'default' : 'secondary'} className="capitalize">
{order.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{order.pdf_url ? (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20">
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" /> Ansehen
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8">
<a href={order.pdf_url} download>
<Download className="w-4 h-4" />
</a>
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground italic">Kein PDF</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
} }

View File

@@ -0,0 +1,151 @@
"use client";
// Admin Settings page view & edit SMTP configuration and send test email
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface Settings {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const router = useRouter();
// Load current settings
useEffect(() => {
fetch('/api/admin/smtp-settings')
.then((res) => res.json())
.then((data) => {
if (data.settings) setSettings(data.settings as Settings);
else setMessage('Failed to load settings');
})
.catch(() => setMessage('Failed to load settings'))
.finally(() => setLoading(false));
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setSettings((prev) =>
prev && {
...prev,
[name]: type === 'checkbox' ? checked : name === 'port' ? Number(value) : value,
}
);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setMessage('');
const res = await fetch('/api/admin/smtp-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
const data = await res.json();
if (res.ok) setMessage('Settings saved');
else setMessage(data.error || 'Error saving settings');
};
const handleTestEmail = async () => {
setMessage('');
const testPayload = {
to: settings?.user || '', // send to the configured user address
subject: 'Test Email from Webshop Admin',
text: 'This is a test email to verify SMTP configuration.',
};
const res = await fetch('/api/admin/send-test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload),
});
const data = await res.json();
if (res.ok) setMessage('Test email sent successfully');
else setMessage(data.error || 'Failed to send test email');
};
if (loading) return <div className="p-8">Loading</div>;
return (
<div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white">
<h1 className="text-2xl font-bold mb-6">SMTPEinstellungen</h1>
{message && <p className="mb-4 text-yellow-300">{message}</p>}
<form onSubmit={handleSave} className="space-y-4">
<label className="flex flex-col">
Host
<input
name="host"
type="text"
required
value={settings?.host ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="flex flex-col">
Port
<input
name="port"
type="number"
required
value={settings?.port ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="inline-flex items-center space-x-2">
<input
name="secure"
type="checkbox"
checked={settings?.secure ?? false}
onChange={handleChange}
className="rounded"
/>
<span>SSL / TLS (secure)</span>
</label>
<label className="flex flex-col">
Benutzer (SMTPUser)
<input
name="user"
type="email"
required
value={settings?.user ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="flex flex-col">
Passwort
<input
name="pass"
type="password"
required
value={settings?.pass ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<div className="flex space-x-4 mt-4">
<button
type="submit"
className="px-4 py-2 bg-primary rounded hover:bg-primary/80 transition"
>
Speichern
</button>
<button
type="button"
onClick={handleTestEmail}
className="px-4 py-2 bg-green-600 rounded hover:bg-green-500 transition"
>
TestMail senden
</button>
</div>
</form>
</div>
);
}

View File

@@ -4,6 +4,8 @@ import { UserDialog } from '@/components/admin/user-dialog'
import { Users } from 'lucide-react' import { Users } from 'lucide-react'
import { Suspense } from 'react' import { Suspense } from 'react'
export const dynamic = 'force-dynamic'
export default async function AdminUsersPage() { export default async function AdminUsersPage() {
return ( return (
<div className="flex-1 space-y-8 p-8 pt-6"> <div className="flex-1 space-y-8 p-8 pt-6">
@@ -32,10 +34,14 @@ async function UserDataWrapper() {
try { try {
const users = await getUsers() const users = await getUsers()
return <UserList initialUsers={users} /> return <UserList initialUsers={users} />
} catch (error) { } catch (error: any) {
console.error('[UserDataWrapper] Error loading users:', error)
return ( return (
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center"> <div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center space-y-2">
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist. <p>Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.</p>
<p className="text-xs font-mono text-destructive/80">
Details: {error instanceof Error ? error.message : JSON.stringify(error)}
</p>
</div> </div>
) )
} }

View File

@@ -0,0 +1,52 @@
// API route to send a test email protected by Supabase admin check
import { NextResponse } from 'next/server';
import { sendMail } from '@/utils/mail';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
// Verify admin rights expecting a user with role "admin" in the "users" table
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { to, subject, text, html } = await request.json();
try {
const info = await sendMail({ to, subject, text, html });
return NextResponse.json({ message: 'Email sent', info });
} catch (err) {
console.error('Mail error', err);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}

View File

@@ -0,0 +1,96 @@
// API route for getting and updating SMTP settings (protected by admin check)
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function GET() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { data, error } = await supabase.from('settings').select('*').maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
});
}
export async function POST(request: Request) {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const payload = await request.json(); // expect {host, port, secure, user, pass}
const { error } = await supabase
.from('settings')
.upsert({ id: 'smtp', ...payload });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ message: 'SMTP settings saved' });
}

View File

@@ -1,11 +1,5 @@
import { SignUpForm } from "@/components/sign-up-form"; import { redirect } from "next/navigation";
export default function Page() { export default function SignUpPage() {
return ( redirect("/auth/login");
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<SignUpForm />
</div>
</div>
);
} }

View File

@@ -1,6 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { InactivityTracker } from "@/components/inactivity-tracker";
import { HeaderWrapper } from "@/components/HeaderWrapper";
import { Navbar } from "@/components/Navbar";
import "./globals.css"; import "./globals.css";
const defaultUrl = process.env.VERCEL_URL const defaultUrl = process.env.VERCEL_URL
@@ -33,7 +36,10 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <InactivityTracker />
<HeaderWrapper navbar={<Navbar />}>
{children}
</HeaderWrapper>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

5
shop/app/login/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LoginPage() {
redirect("/auth/login");
}

View File

@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{items.map((item, i) => ( {items.map((item, i) => (
<span <span
key={i} key={item.product_id}
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300" className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
> >
{item.product_name} {item.product_name}

View File

@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { getProducts, getCategories } from '@/lib/actions/products' import { getProducts, getCategories } from '@/lib/actions/products'
import { getEndCustomers } from '@/lib/actions/end-customers' import { getEndCustomers } from '@/lib/actions/end-customers'
import { OrderWizard } from '@/components/order-wizard' import { OrderWizard } from '@/components/order-wizard'
@@ -31,19 +32,48 @@ async function OrderDataWrapper() {
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) { if (!user) {
redirect('/auth/login') redirect('/auth/login?next=/order')
} }
const products = await getProducts() // Check user role
const categories = await getCategories() const { data: userData } = await supabase
const endCustomers = await getEndCustomers() .from('users')
.select('role')
// Fetch profile if exists
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id) .eq('id', user.id)
.single() .single()
const products = await getProducts().catch(() => [])
const categories = await getCategories().catch(() => [])
// Admin sees all end customers across partners
let endCustomers = []
if (userData?.role === 'admin') {
try {
const adminClient = createAdminClient()
const { data } = await adminClient
.from('end_customers')
.select('*')
.order('company_name', { ascending: true })
endCustomers = data || []
} catch (e) {
console.error('Fehler beim Laden aller Endkunden für Admin:', e)
}
} else {
endCustomers = await getEndCustomers().catch(() => [])
}
// Fetch profile safely using maybeSingle
let profile = null
try {
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.maybeSingle()
profile = data
} catch (e) {
console.error('Fehler beim Laden des Profils:', e)
}
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} /> return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
} }

View File

@@ -79,8 +79,8 @@ export default async function OrderSuccessPage({
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Produkte */} {/* Produkte */}
<div className="space-y-3"> <div className="space-y-3">
{items.map((item, i) => ( {items.map((item) => (
<div key={i} className="space-y-1"> <div key={item.product_id} className="space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span> <span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white"> <span className="text-white">
@@ -90,8 +90,8 @@ export default async function OrderSuccessPage({
</span> </span>
</span> </span>
</div> </div>
{item.selected_modules.map((mod, j) => ( {item.selected_modules.map((mod) => (
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400"> <div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span> <span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span> <span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
</div> </div>

View File

@@ -1,108 +1,110 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import Link from 'next/link' import Link from 'next/link'
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react' import { Shield, Cpu, ArrowRight, Key, Zap, CheckCircle2 } from 'lucide-react'
import { Navbar } from '@/components/Navbar'
export default function Home() { export default function Home() {
return ( return (
<div className="flex flex-col min-h-screen bg-[#020617] text-white"> <>
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50"> {/* Hero Section: Zielgerichtet auf Lizenzen */}
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt. <section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
</div> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50"> <div className="container mx-auto">
<Link className="flex items-center justify-center gap-2" href="#"> <div className="flex flex-col items-center space-y-4 text-center">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> <div className="space-y-2">
<Rocket className="h-5 w-5 text-white" /> <div className="inline-block rounded-full bg-blue-500/10 px-3 py-1 text-sm text-blue-600 dark:text-blue-400 font-medium border border-blue-500/20 mb-4">
</div> CASPOS Lizenz-Management-Server
<span className="font-bold text-xl tracking-tighter">CASPOS Store</span>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
Bestellen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-customers">
Meine Kunden
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-orders">
Meine Bestellungen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
Admin
</Link>
<Button variant="outline" className="border-white/10 hover:bg-white/5">
Login
</Button>
</nav>
</header>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full -z-10 opacity-30" />
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-amber-500/10 px-3 py-1 text-sm text-amber-400 font-medium border border-amber-500/20 mb-4">
Demo-Umgebung für Testbestellungen
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
CASPOS <span className="text-gradient">Software-Bestellung</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
</p>
</div>
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/contact">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
Contact Us
</Button>
</Link>
</div> </div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
POS Software <span className="text-blue-600 dark:text-blue-500">Lizenzen & Module</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-600 dark:text-slate-400 md:text-xl mt-6">
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme. Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
</p>
</div> </div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]"> {/* High-Contrast Buttons (Light/Dark Ready) */}
<div className="container mx-auto px-4 md:px-6"> <div className="space-x-4 mt-8">
<div className="grid gap-12 lg:grid-cols-3"> <Link href="/order">
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark"> <Button size="lg" className="h-12 px-8 text-lg font-bold transition-all hover:scale-105 bg-slate-900 text-white hover:bg-slate-800 border border-slate-200 dark:bg-white dark:text-black dark:hover:bg-slate-200 dark:border-none">
<div className="p-3 bg-primary/10 rounded-xl"> Lizenz bestellen <ArrowRight className="ml-2 h-5 w-5" />
<Cpu className="h-8 w-8 text-primary" /> </Button>
</div> </Link>
<h3 className="text-xl font-bold">Handel</h3> <Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer">
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p> <Button size="lg" variant="outline" className="h-12 px-8 text-lg font-semibold bg-transparent border-slate-400 text-slate-900 hover:bg-slate-100 dark:border-white/40 dark:text-white dark:hover:bg-white/10">
</div> Fragen? Kontaktieren Sie uns
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark"> </Button>
<div className="p-3 bg-primary/10 rounded-xl"> </Link>
<Shield className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-400">Egal ob mobiles Ordern, einer Küchen-Anzeige oder einen Pick-Up-Bord. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
</div>
</div> </div>
</div> </div>
</section> </div>
</main> </section>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
{/* Feature Grid: Aufgeteilt nach echten POS-Modulen */}
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/[0.02]">
<div className="container mx-auto px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-3">
{/* Modul 1 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Cpu className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Handel & ERP</h3>
<p className="text-slate-600 dark:text-slate-400">Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).</p>
</div>
{/* Modul 2 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Zap className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-600 dark:text-slate-400">Lizenzen für mobiles Bestellen (Orderman), digitale Küchen-Monitore und interaktive Abhol-Boards.</p>
</div>
{/* Modul 3 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Shield className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Hybride Systeme</h3>
<p className="text-slate-600 dark:text-slate-400">Das beste aus beiden Welten. Handel mit Gastronomie und umgekehrt. </p>
</div>
</div>
</div>
</section>
{/* Kurze Info-Sektion für Partner / Händler */}
<section className="w-full py-12 md:py-24 border-t border-slate-200 dark:border-white/5">
<div className="container mx-auto px-4 max-w-4xl text-center">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl mb-6">Wie funktioniert die Bereitstellung?</h2>
<div className="grid gap-6 sm:grid-cols-3 text-left">
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>1. Lizenz wählen:</strong> Gewünschtes POS-Modul im Shop selektieren.</p>
</div>
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>2. Kunde zuweisen:</strong> Lizenz direkt einem Kunden-Account zuordnen.</p>
</div>
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>3. Live gehen:</strong> Lizenz-Key (`.key`) wird sofort serverseitig aktiviert.</p>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#020617]">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p> <p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6"> <nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link> <Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/impressum/" target="_blank" rel="noopener noreferrer">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link> <Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</Link>
</nav> </nav>
</footer> </footer>
</div> </>
) )
} }

View File

@@ -0,0 +1,35 @@
"use client";
import { usePathname } from "next/navigation";
interface HeaderWrapperProps {
navbar: React.ReactNode;
children: React.ReactNode;
}
export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
const pathname = usePathname();
const isAdmin = pathname?.startsWith("/admin");
if (isAdmin) {
return <>{children}</>;
}
return (
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
{/* Demo Banner */}
<div
id="demo-banner"
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50"
>
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
</div>
{navbar}
<main className="flex-1">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NavbarClient } from "@/components/NavbarClient";
export async function Navbar() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
// Falls keine Env-Vars da sind, leeren User an Client übergeben
return <NavbarClient user={null} />;
}
const supabase = createServerClient(
supabaseUrl,
supabaseAnonKey,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Kann in Server Components ignoriert werden, da dort keine Cookies gesetzt werden können
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
},
);
let user = null;
let role = "partner";
try {
const { data } = await supabase.auth.getUser();
user = data.user;
if (user) {
const { data: dbUser } = await supabase
.from("users")
.select("role")
.eq("id", user.id)
.single();
if (dbUser) {
role = dbUser.role;
}
}
} catch (error) {
console.error("Fehler beim Abrufen des Users in Navbar:", error);
}
return <NavbarClient user={user} role={role} />;
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr";
import { type User } from "@supabase/supabase-js";
import { Rocket, Menu, X, User as UserIcon, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
interface NavbarClientProps {
user: User | null;
role?: string;
}
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(user);
const [userRole, setUserRole] = useState<string>(role);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [open, setOpen] = useState(false);
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
// Auf Auth-Statusänderungen reagieren
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
setCurrentUser(session?.user ?? null);
if (session?.user) {
try {
const { data } = await supabase
.from("users")
.select("role")
.eq("id", session.user.id)
.single();
if (data) {
setUserRole(data.role);
}
} catch {
setUserRole("partner");
}
} else {
setUserRole("partner");
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
useEffect(() => {
const checkBanner = () => {
const disabled = localStorage.getItem('demo_banner_disabled') === 'true';
const banner = document.getElementById('demo-banner');
if (banner) banner.style.display = disabled ? 'none' : 'block';
};
checkBanner();
window.addEventListener('storage_demo_changed', checkBanner);
return () => window.removeEventListener('storage_demo_changed', checkBanner);
}, []);
const handleSignOut = async () => {
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout:", e);
}
await supabase.auth.signOut();
setCurrentUser(null);
setIsMobileMenuOpen(false);
router.refresh();
router.push("/");
};
return (
<>
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
<Link className="flex items-center justify-center gap-2 group" href="/">
<span className="font-bold text-xl tracking-tighter text-white">CASPOS Store</span>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex gap-6 items-center">
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
target="_blank"
rel="noopener noreferrer"
>
Zulip
</Link>
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
target="_blank"
rel="noopener noreferrer"
>
Wiki
</Link>
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://support.caspos.de"
target="_blank"
rel="noopener noreferrer"
>
Support
</Link>
<div className="h-4 w-px bg-white/10 mx-2" />
{currentUser ? (
<div className="relative">
<Button
variant="outline"
onClick={() => setOpen(!open)}
className="border-white/10 hover:bg-white/5 gap-2 text-white"
>
<UserIcon className="w-4 h-4 text-primary" />
<span className="max-w-[120px] truncate">{currentUser.email}</span>
</Button>
{open && (
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
{currentUser.email}
</div>
{userRole === "admin" ? (
<>
<Link
href="/admin/einstellungen"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Admin
</Link>
</>
) : (
<>
<Link
href="/account-kunden"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Account Einstellungen
</Link>
<Link
href="/my-customers"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Kundenliste
</Link>
</>
)}
<hr className="my-1 border-slate-100 dark:border-white/5" />
<button
onClick={handleSignOut}
className="w-full text-left px-3 py-2 text-sm font-bold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg"
>
Abmelden
</button>
</div>
)}
</div>
) : (
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
<Link href={`/auth/login?next=${typeof window !== 'undefined' ? window.location.pathname : '/'}`}>Anmelden</Link>
</Button>
)}
</nav>
{/* Mobile Hamburger Trigger */}
<button
className="md:hidden p-2 text-slate-300 hover:text-white focus:outline-none"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</header>
{/* Mobile Menu Panel */}
{isMobileMenuOpen && (
<div className="md:hidden fixed inset-x-0 top-16 bg-[#020617] border-b border-white/5 z-40 py-4 px-6 flex flex-col gap-4 animate-in slide-in-from-top duration-200">
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/order"
onClick={() => setIsMobileMenuOpen(false)}
>
Bestellen
</Link>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="https://caspos.de"
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsMobileMenuOpen(false)}
>
Wiki
</Link>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="https://caspos.de"
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsMobileMenuOpen(false)}
>
Support
</Link>
{currentUser && (
<>
<div className="h-px bg-white/5 my-1" />
<div className="text-xs text-slate-500 px-1">Mein Account ({currentUser.email})</div>
{userRole === "admin" ? (
<>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/admin/einstellungen"
onClick={() => setIsMobileMenuOpen(false)}
>
Admin
</Link>
</>
) : (
<>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/my-customers"
onClick={() => setIsMobileMenuOpen(false)}
>
Kundenliste
</Link>
</>
)}
</>
)}
<div className="h-px bg-white/5 my-1" />
{currentUser ? (
<Button
variant="destructive"
className="w-full flex items-center justify-center gap-2"
onClick={handleSignOut}
>
<LogOut className="w-4 h-4" /> Abmelden
</Button>
) : (
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
<Link href={`/auth/login?next=${typeof window !== 'undefined' ? window.location.pathname : '/'}`}>Anmelden</Link>
</Button>
)}
</div>
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod' import * as z from 'zod'
@@ -54,6 +55,7 @@ export function CategoryDialog({
category?: Category category?: Category
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const router = useRouter()
const form = useForm<CategoryFormValues>({ const form = useForm<CategoryFormValues>({
resolver: zodResolver(categorySchema) as any, resolver: zodResolver(categorySchema) as any,
@@ -75,6 +77,7 @@ export function CategoryDialog({
} }
setOpen(false) setOpen(false)
form.reset() form.reset()
router.refresh()
} catch (error) { } catch (error) {
console.error('Error saving category:', error) console.error('Error saving category:', error)
} }

View File

@@ -14,12 +14,16 @@ import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { deleteCategory } from '@/lib/actions/products' import { deleteCategory } from '@/lib/actions/products'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { CategoryDialog } from './category-dialog' import { CategoryDialog } from './category-dialog'
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) { export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
const [categories, setCategories] = useState(initialCategories) const [categories, setCategories] = useState(initialCategories)
useEffect(() => {
setCategories(initialCategories)
}, [initialCategories])
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) { if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
await deleteCategory(id) await deleteCategory(id)

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, useFieldArray } from 'react-hook-form' import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod' import * as z from 'zod'
@@ -39,12 +40,13 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
const moduleSchema = z.object({ const moduleSchema = z.object({
id: z.string().optional(),
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(), description: z.string().optional(),
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
is_required: z.boolean().default(false), is_required: z.boolean().default(false),
requirements: z.string().optional().default(''), // Will be converted to array requirements: z.array(z.string()).default([]),
exclusions: z.string().optional().default(''), // Will be converted to array exclusions: z.array(z.string()).default([]),
}) })
const productSchema = z.object({ const productSchema = z.object({
@@ -60,6 +62,7 @@ type ProductFormValues = z.infer<typeof productSchema>
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) { export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const router = useRouter()
const form = useForm<ProductFormValues>({ const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any, resolver: zodResolver(productSchema) as any,
@@ -70,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre
category_id: product?.category_id || '', category_id: product?.category_id || '',
billing_interval: product?.billing_interval || 'monthly', billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({ modules: product?.modules?.map(m => ({
id: m.id,
name: m.name, name: m.name,
description: m.description || '', description: m.description || '',
price: m.price, price: m.price,
is_required: false, is_required: false,
requirements: m.requirements?.join(', ') || '', requirements: m.requirements || [],
exclusions: m.exclusions?.join(', ') || '', exclusions: m.exclusions || [],
})) || [], })) || [],
}, },
}) })
@@ -90,8 +94,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
const { modules, ...productData } = values const { modules, ...productData } = values
const formattedModules = modules.map(m => ({ const formattedModules = modules.map(m => ({
...m, ...m,
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [], id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
})) }))
if (product) { if (product) {
@@ -102,6 +105,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
setOpen(false) setOpen(false)
if (!product) form.reset() if (!product) form.reset()
router.refresh()
} catch (error) { } catch (error) {
console.error('Error saving product:', error) console.error('Error saving product:', error)
} }
@@ -240,103 +244,184 @@ export function CreateProductDialog({ children, categories, product }: { childre
variant="outline" variant="outline"
size="sm" size="sm"
className="border-primary/50 text-primary hover:bg-primary/10" className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })} onClick={() => append({
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
name: '',
price: 0,
is_required: false,
description: '',
requirements: [],
exclusions: []
})}
> >
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen <Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
</Button> </Button>
</div> </div>
{fields.map((field, index) => ( {fields.map((field, index) => {
<div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group"> const allModules = form.watch('modules') || []
<Button const otherModules = allModules
type="button" .map((m, idx) => ({
variant="ghost" id: m.id || field.id,
size="icon" name: m.name || `Modul ${idx + 1}`,
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity" }))
onClick={() => remove(index)} .filter((_, idx) => idx !== index)
>
<X className="h-4 w-4" />
</Button>
<div className="grid grid-cols-2 gap-4"> return (
<FormField <div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group">
control={form.control} <Button
name={`modules.${index}.name`} type="button"
render={({ field }) => ( variant="ghost"
<FormItem> size="icon"
<FormLabel className="text-white">Modulname</FormLabel> className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
<FormControl> onClick={() => remove(index)}
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} /> >
</FormControl> <X className="h-4 w-4" />
<FormMessage /> </Button>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name={`modules.${index}.requirements`} name={`modules.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel> <FormLabel className="text-white">Modulname</FormLabel>
<FormControl> <FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} /> <Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name={`modules.${index}.exclusions`} name={`modules.${index}.price`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel> <FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl> <FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} /> <Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="grid grid-cols-2 gap-4 border border-white/10 rounded-lg p-3 bg-black/20">
<FormField {/* Requirements Selection Matrix */}
control={form.control} <FormField
name={`modules.${index}.is_required`} control={form.control}
render={({ field }) => ( name={`modules.${index}.requirements`}
<FormItem className="flex flex-row items-start space-x-3 space-y-0"> render={({ field: reqField }) => (
<FormControl> <div className="space-y-2">
<Checkbox <FormLabel className="text-white text-xs font-semibold">Benötigt Module</FormLabel>
checked={field.value} <ScrollArea className="h-28 border border-white/5 rounded p-2 bg-white/5">
onCheckedChange={field.onChange} {otherModules.length === 0 ? (
/> <p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
</FormControl> ) : (
<div className="space-y-1 leading-none"> <div className="space-y-1.5">
<FormLabel className="text-white">Erforderlich</FormLabel> {otherModules.map(other => {
const checked = (reqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`req-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
} else {
reqField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`req-${index}-${other.id}`}
className="text-xs text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div> </div>
</FormItem> )}
)} />
/>
{/* Exclusions Selection Matrix */}
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field: exclField }) => (
<div className="space-y-2">
<FormLabel className="text-white text-xs font-semibold">Schließt aus</FormLabel>
<ScrollArea className="h-28 border border-white/5 rounded p-2 bg-white/5">
{otherModules.length === 0 ? (
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
) : (
<div className="space-y-1.5">
{otherModules.map(other => {
const checked = (exclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`excl-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
} else {
exclField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`excl-${index}-${other.id}`}
className="text-xs text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
)}
/>
</div>
<div className="flex items-center space-x-2">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-white">Erforderlich</FormLabel>
</div>
</FormItem>
)}
/>
</div>
</div> </div>
</div> )
))} })}
{fields.length === 0 && ( {fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm"> <div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">

View File

@@ -0,0 +1,47 @@
"use client";
import { signOut } from "@/lib/actions/auth";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { createBrowserClient } from "@supabase/ssr";
import { resolveSupabaseUrl } from "@/lib/utils";
export function AdminLogoutButton() {
const router = useRouter();
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const handleLogout = async () => {
try {
await signOut();
} catch (error) {
console.error("Fehler beim Abmelden:", error);
}
await supabase.auth.signOut();
router.push("/");
router.refresh();
};
return (
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all text-left"
>
<LogOut className="w-5 h-5" />
Abmelden
</button>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, ExternalLink, Search } from "lucide-react";
interface OrdersTableProps {
initialOrders: any[];
}
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
};
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
};
export function OrdersTable({ initialOrders }: OrdersTableProps) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredOrders = initialOrders.filter((order) => {
const orderNum = (order.order_number || "").toLowerCase();
const company = (order.customer_data?.company_name || "").toLowerCase();
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
const matchesSearch =
orderNum.includes(search.toLowerCase()) ||
company.includes(search.toLowerCase()) ||
contact.includes(search.toLowerCase());
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
return (
<div className="space-y-4">
{/* Filterleiste */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
<div className="relative w-full md:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Bestellung, Firma oder Name suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
className="text-xs"
>
Alle
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("pending")}
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
>
Eingegangen
</Button>
<Button
variant={statusFilter === "active" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("active")}
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
>
In Bearbeitung
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
>
Abgeschlossen
</Button>
<Button
variant={statusFilter === "cancelled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("cancelled")}
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
>
Storniert
</Button>
</div>
</div>
{/* Tabelle */}
<div className="rounded-xl border border-white/5 bg-black/20 overflow-hidden backdrop-blur-xl">
<Table>
<TableHeader>
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Auswahl</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
Keine Bestellungen gefunden.
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
const items = order.order_data?.items || [];
return (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-mono text-xs text-primary font-semibold">
#{order.order_number || order.id.slice(0, 8)}
</TableCell>
<TableCell className="text-slate-300 text-sm">
{new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})}
</TableCell>
<TableCell>
<div className="font-semibold text-white text-sm">
{order.customer_data?.company_name || 'Privatkunde'}
</div>
<div className="text-xs text-slate-400">
{order.customer_data?.first_name} {order.customer_data?.last_name}
</div>
</TableCell>
<TableCell className="max-w-[200px]">
<div className="flex flex-wrap gap-1">
{items.map((item: any) => (
<span
key={item.product_id}
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
>
{item.product_name}
{item.selected_modules?.length > 0 && (
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
)}
</span>
))}
</div>
</TableCell>
<TableCell className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>
<Badge className={`text-xs px-2 py-0.5 rounded ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
{statusLabel[order.status] ?? order.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{order.pdf_url ? (
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<a href={order.pdf_url} download>
<Download className="w-3.5 h-3.5" />
</a>
</Button>
</div>
) : (
<span className="text-xs text-slate-500 italic">Keine Rechnung</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -14,12 +14,16 @@ import { Badge } from '@/components/ui/badge'
import { Edit2, Trash2, Layers } from 'lucide-react' import { Edit2, Trash2, Layers } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { deleteProduct } from '@/lib/actions/products' import { deleteProduct } from '@/lib/actions/products'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { CreateProductDialog } from './create-product-dialog' import { CreateProductDialog } from './create-product-dialog'
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) { export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts) const [products, setProducts] = useState(initialProducts)
useEffect(() => {
setProducts(initialProducts)
}, [initialProducts])
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) { if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
await deleteProduct(id) await deleteProduct(id)

View File

@@ -40,7 +40,8 @@ export function AdminRecentOrders() {
const fetchOrders = async () => { const fetchOrders = async () => {
try { try {
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' }) const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
if (!res.ok) return if (!res.ok) return
const { orders: fresh } = await res.json() const { orders: fresh } = await res.json()

View File

@@ -21,9 +21,6 @@ export async function AuthButton() {
<Button asChild size="sm" variant={"outline"}> <Button asChild size="sm" variant={"outline"}>
<Link href="/auth/login">Sign in</Link> <Link href="/auth/login">Sign in</Link>
</Button> </Button>
<Button asChild size="sm" variant={"default"}>
<Link href="/auth/sign-up">Sign up</Link>
</Button>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client"; import { resetPassword } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -26,16 +26,11 @@ export function ForgotPasswordForm({
const handleForgotPassword = async (e: React.FormEvent) => { const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const supabase = createClient();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
// The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration await resetPassword(email);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/update-password`,
});
if (error) throw error;
setSuccess(true); setSuccess(true);
} catch (error: unknown) { } catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred"); setError(error instanceof Error ? error.message : "An error occurred");

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
export function InactivityTracker() {
const router = useRouter();
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
useEffect(() => {
// Regelmäßige Überprüfung der Session-Gültigkeit (z.B. wegen Login auf anderem Browser/Gerät)
const interval = setInterval(async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return; // Nicht eingeloggt, keine Prüfung nötig
// Prüfen, ob die Session auf dem Server noch gültig ist (z.B. wegen Login auf anderem Browser)
const { data: { user }, error } = await supabase.auth.getUser();
if (error) {
// Nur bei echten Auth-Fehlern (z.B. ungültiges Token / Session gelöscht) abmelden.
// Netzwerkfehler wie "Failed to fetch" haben keinen HTTP-Status (error.status ist undefined).
const isAuthError = error.status === 400 || error.status === 401 || error.status === 403;
if (isAuthError) {
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
}
} else if (!user) {
// Keine Fehlermeldung, aber auch kein User-Objekt (Sitzung abgelaufen/gelöscht)
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
}
}, 5000); // Prüfung alle 5 Sekunden
return () => {
clearInterval(interval);
};
}, [supabase, router]);
return null;
}

View File

@@ -94,75 +94,103 @@ const styles = StyleSheet.create({
}, },
}); });
export const InvoicePDF = ({ order, product, modules, customer }: any) => ( export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
<Document> const items = orderSnapshot?.items ?? [];
<Page size="A4" style={styles.page}> const subtotal = orderSnapshot?.subtotal ?? 0;
<View style={styles.header}> const taxAmount = orderSnapshot?.tax_amount ?? 0;
<View> const taxRate = orderSnapshot?.tax_rate ?? 19;
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text> const total = orderSnapshot?.total ?? 0;
<Text>Software Solutions</Text>
</View>
<View style={styles.companyInfo}>
<Text>CASPOS GmbH</Text>
<Text>Musterstraße 1</Text>
<Text>12345 Musterstadt</Text>
<Text>Deutschland</Text>
</View>
</View>
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text> return (
<Document>
<View style={styles.section}> <Page size="A4" style={styles.page}>
<Text style={styles.label}>Rechnungsempfänger</Text> <View style={styles.header}>
<Text>{customer.company_name}</Text> <View>
<Text>{customer.first_name} {customer.last_name}</Text> <Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>{customer.address}</Text> <Text>Software Solutions</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.id}</Text>
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
</View>
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
</View>
<View style={styles.tableRow}>
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
</View>
{modules.map((mod: any) => (
<View key={mod.id} style={styles.tableRow}>
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
</View> </View>
))} <View style={styles.companyInfo}>
</View> <Text>CASPOS GmbH</Text>
<Text>Musterstraße 1</Text>
<View style={styles.total}> <Text>12345 Musterstadt</Text>
<View style={{ width: '40%' }}> <Text>Deutschland</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text>Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
</View> </View>
</View> </View>
</View>
<View style={styles.footer}> <Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text> <View style={styles.section}>
</View> <Text style={styles.label}>Rechnungsempfänger</Text>
</Page> <Text>{customer.company_name}</Text>
</Document> <Text>{customer.first_name} {customer.last_name}</Text>
); <Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
</View>
{items.map((item: any, idx: number) => (
<View key={idx}>
<View style={styles.tableRow}>
<View style={styles.tableCol}>
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
</View>
<View style={styles.tableColPrice}>
<Text>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>+ {mod.module_name}</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
</Text>
</View>
</View>
))}
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '50%' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(taxAmount)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>Gesamtbetrag (brutto):</Text>
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(total)}</Text>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
</View>
</Page>
</Document>
);
};

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client"; import { signIn } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -28,18 +28,20 @@ export function LoginForm({
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const supabase = createClient();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
try { try {
const { error } = await supabase.auth.signInWithPassword({ const res = await signIn(email, password);
email, if (nextParam) {
password, router.push(nextParam);
}); } else if (res.role === "admin") {
if (error) throw error; router.push("/admin/einstellungen");
// Update this route to redirect to an authenticated route. The user already has an active session. } else {
router.push("/protected"); router.push("/my-customers");
}
} catch (error: unknown) { } catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred"); setError(error instanceof Error ? error.message : "An error occurred");
} finally { } finally {
@@ -88,21 +90,23 @@ export function LoginForm({
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {(() => {
<Button type="submit" className="w-full" disabled={isLoading}> const messageParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('message') : null;
{isLoading ? "Logging in..." : "Login"} if (messageParam === "concurrent") {
</Button> return (
</div> <p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
<div className="mt-4 text-center text-sm"> Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
Don&apos;t have an account?{" "} </p>
<Link );
href="/auth/sign-up" }
className="underline underline-offset-4" return null;
> })()}
Sign up {error && <p className="text-sm text-red-500">{error}</p>}
</Link> <Button type="submit" className="w-full" disabled={isLoading}>
</div> {isLoading ? "Logging in..." : "Login"}
</form> </Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { createClient } from "@/lib/supabase/client"; import { signOut } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -8,8 +8,7 @@ export function LogoutButton() {
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
const supabase = createClient(); await signOut();
await supabase.auth.signOut();
router.push("/auth/login"); router.push("/auth/login");
}; };

View File

@@ -48,12 +48,18 @@ function toggleModuleInList(
): string[] { ): string[] {
const isSelected = currentIds.includes(toggleId) const isSelected = currentIds.includes(toggleId)
if (isSelected) { if (isSelected) {
const next = currentIds.filter(id => id !== toggleId) // Recursive removal: repeatedly filter until no more dependent modules are removed
// cascade: remove modules that required this one let next = currentIds.filter(id => id !== toggleId)
return next.filter(mId => { let changed = true
const m = modules.find(mod => mod.id === mId) while (changed) {
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId) const beforeLength = next.length
}).filter(id => id !== toggleId) next = next.filter(mId => {
const m = modules.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId))
})
changed = next.length !== beforeLength
}
return next
} else { } else {
const module = modules.find(m => m.id === toggleId) const module = modules.find(m => m.id === toggleId)
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
@@ -118,20 +124,29 @@ export function OrderWizard({
) )
// Total price across all selections // Total price across all selections
const totalPrice = useMemo(() => { // Total price calculations (split by billing interval)
let total = 0 const { monthlyTotal, oneTimeTotal } = useMemo(() => {
let monthly = 0
let oneTime = 0
for (const cat of categories) { for (const cat of categories) {
const sel = selections[cat.id] const sel = selections[cat.id]
if (!sel?.productId) continue if (!sel?.productId) continue
const prod = products.find(p => p.id === sel.productId) const prod = products.find(p => p.id === sel.productId)
if (!prod) continue if (!prod) continue
total += Number(prod.base_price) const base = Number(prod.base_price)
let modulesSum = 0
sel.moduleIds.forEach(mId => { sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId) const mod = prod.modules?.find(m => m.id === mId)
if (mod) total += Number(mod.price) if (mod) modulesSum += Number(mod.price)
}) })
const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') {
oneTime += totalItem
} else {
monthly += totalItem
}
} }
return total return { monthlyTotal: monthly, oneTimeTotal: oneTime }
}, [selections, products, categories]) }, [selections, products, categories])
// Helper: update product selection for a category (resets modules) // Helper: update product selection for a category (resets modules)
@@ -425,12 +440,38 @@ export function OrderWizard({
) )
})} })}
<Separator className="bg-white/10" /> <Separator className="bg-white/10" />
<div className="flex justify-between text-xl font-bold text-gradient"> {oneTimeTotal > 0 && monthlyTotal > 0 ? (
<span>Gesamt:</span> <div className="space-y-2">
<span> <div className="flex justify-between text-base font-semibold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} <span className="text-slate-400">Einmalig gesamt:</span>
</span> <span>
</div> {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
<div className="flex justify-between text-base font-semibold text-white">
<span className="text-slate-400">Monatlich gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-xs font-normal text-slate-400">/ Monat</span>
</span>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt (einmalig):</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
) : (
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
)}
{!allCategoriesFilled && ( {!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1"> <p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />
@@ -669,15 +710,43 @@ export function OrderWizard({
)} )}
</div> </div>
</div> </div>
<div className="flex justify-between text-xl font-bold text-gradient"> {oneTimeTotal > 0 && monthlyTotal > 0 ? (
<span>Gesamtbetrag:</span> <div className="space-y-2 border-t border-white/10 pt-4">
<span> <div className="flex justify-between text-lg font-bold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat <span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
</span> <span>
</div> {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
<div className="flex justify-between text-lg font-bold text-white">
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
) : (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
)}
<p className="text-xs text-slate-500 italic"> <p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
Datenschutzerklärung. <a href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
Datenschutzerklärung
</a>.
</p> </p>
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-4"> <CardFooter className="flex flex-col gap-4">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client"; import { signUp } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -29,7 +29,6 @@ export function SignUpForm({
const handleSignUp = async (e: React.FormEvent) => { const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const supabase = createClient();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -40,14 +39,7 @@ export function SignUpForm({
} }
try { try {
const { error } = await supabase.auth.signUp({ await signUp(email, password);
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/protected`,
},
});
if (error) throw error;
router.push("/auth/sign-up-success"); router.push("/auth/sign-up-success");
} catch (error: unknown) { } catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred"); setError(error instanceof Error ? error.message : "An error occurred");

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<label className="relative inline-flex items-center cursor-pointer select-none">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={handleChange}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 bg-slate-200 rounded-full peer dark:bg-slate-700",
"peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500/50",
"peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white",
"after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600",
"peer-checked:bg-blue-600"
)} />
</label>
);
}
);
Switch.displayName = "Switch";
export { Switch };

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client"; import { updatePassword } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -26,15 +26,12 @@ export function UpdatePasswordForm({
const handleForgotPassword = async (e: React.FormEvent) => { const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const supabase = createClient();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const { error } = await supabase.auth.updateUser({ password }); await updatePassword(password);
if (error) throw error; router.push("/");
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push("/protected");
} catch (error: unknown) { } catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred"); setError(error instanceof Error ? error.message : "An error occurred");
} finally { } finally {

70
shop/lib/actions/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { headers } from 'next/headers'
export async function signIn(email: string, password: string) {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
throw new Error(error.message)
}
const userId = data.user?.id
if (userId) {
const { data: userData, error: userError } = await supabase
.from('users')
.select('role')
.eq('id', userId)
.single()
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
await supabase.auth.signOut()
throw new Error("Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden.")
}
// Andere aktive Sitzungen beenden
await supabase.auth.signOut({ scope: 'others' })
return { success: true, role: userData.role }
}
return { success: true, role: 'partner' }
}
export async function signUp(email: string, password: string) {
throw new Error("Registrierung ist deaktiviert.")
}
export async function signOut() {
const supabase = await createClient()
const { error } = await supabase.auth.signOut()
if (error) {
throw new Error(error.message)
}
return { success: true }
}
export async function resetPassword(email: string) {
const supabase = await createClient()
const origin = (await headers()).get('origin') || ''
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/update-password`,
})
if (error) {
throw new Error(error.message)
}
return { success: true }
}
export async function updatePassword(password: string) {
const supabase = await createClient()
const { error } = await supabase.auth.updateUser({ password })
if (error) {
throw new Error(error.message)
}
return { success: true }
}

View File

@@ -1,6 +1,7 @@
'use server' 'use server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import type { EndCustomer } from '@/lib/types' import type { EndCustomer } from '@/lib/types'
@@ -143,3 +144,15 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
revalidatePath('/my-customers') revalidatePath('/my-customers')
revalidatePath(`/my-customers/${id}`) revalidatePath(`/my-customers/${id}`)
} }
export async function getPartnerEndCustomers(partnerId: string): Promise<EndCustomer[]> {
const admin = createAdminClient()
const { data, error } = await admin
.from('end_customers')
.select('*')
.eq('partner_id', partnerId)
.order('company_name', { ascending: true })
if (error) throw error
return data as EndCustomer[]
}

View File

@@ -7,6 +7,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react' import React from 'react'
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform' import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types' import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products'
// ─── Hilfsfunktionen ───────────────────────────────────────────────────────── // ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
*/ */
export async function submitOrder(params: { export async function submitOrder(params: {
selections: WizardSelections selections: WizardSelections
products: Product[] products?: Product[]
categories: Category[] categories?: Category[]
customerProfile: Partial<Profile> customerProfile: Partial<Profile>
endCustomerId?: string | null endCustomerId?: string | null
endCustomer?: EndCustomer | null endCustomer?: EndCustomer | null
}): Promise<Order> { }): Promise<Order> {
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params const { selections, customerProfile, endCustomerId, endCustomer } = params
const supabase = await createClient() const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated') if (!user) throw new Error('Not authenticated')
// Fetch catalog directly from DB to prevent client tampering
const dbProducts = await getProducts()
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
if (missing.length > 0) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// 1. Snapshots aufbauen // 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, products, categories) const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
const orderHash = hashOrderSnapshot(orderSnapshot) const orderHash = hashOrderSnapshot(orderSnapshot)
@@ -144,7 +185,7 @@ export async function submitOrder(params: {
console.error('PDF Generation Error:', pdfError) console.error('PDF Generation Error:', pdfError)
} }
revalidatePath('/protected') revalidatePath('/my-customers')
revalidatePath('/my-orders') revalidatePath('/my-orders')
return order as Order return order as Order
} }

View File

@@ -3,6 +3,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { Category, Product, ProductModule } from '../types' import { Category, Product, ProductModule } from '../types'
import { randomUUID } from 'crypto'
export async function getProducts() { export async function getProducts() {
const supabase = await createClient() const supabase = await createClient()
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
revalidatePath('/order') revalidatePath('/order')
} }
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) { export async function createProduct(
product: Omit<Product, 'id' | 'created_at' | 'updated_at'>,
modules: Omit<ProductModule, 'product_id' | 'created_at'>[]
) {
const supabase = await createClient() const supabase = await createClient()
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC) // Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
@@ -77,7 +81,15 @@ export async function createProduct(product: Omit<Product, 'id' | 'created_at' |
if (productError) throw productError if (productError) throw productError
if (modules.length > 0) { if (modules.length > 0) {
const modulesWithId = modules.map(m => ({ ...m, product_id: newProduct.id })) const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: newProduct.id,
name: m.name,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
}))
const { error: modulesError } = await supabase const { error: modulesError } = await supabase
.from('product_modules') .from('product_modules')
.insert(modulesWithId) .insert(modulesWithId)
@@ -106,12 +118,13 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modules.length > 0) { if (modules.length > 0) {
const modulesWithId = modules.map(m => ({ const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: id,
name: m.name, name: m.name,
description: m.description || null, description: m.description || null,
price: m.price, price: m.price,
requirements: m.requirements || [], requirements: m.requirements || [],
exclusions: m.exclusions || [], exclusions: m.exclusions || [],
product_id: id
})) }))
const { error: modulesError } = await supabase const { error: modulesError } = await supabase
.from('product_modules') .from('product_modules')
@@ -120,6 +133,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modulesError) throw modulesError if (modulesError) throw modulesError
} }
revalidatePath('/admin/products') revalidatePath('/admin/products')
revalidatePath('/order') revalidatePath('/order')
} }

View File

@@ -48,3 +48,18 @@ export async function resetPassword(email: string) {
if (error) throw error if (error) throw error
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail // Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
} }
export async function getPartners() {
const admin = createAdminClient()
const { data: { users }, error } = await admin.auth.admin.listUsers()
if (error) throw error
const { data: dbUsers, error: dbError } = await admin
.from('users')
.select('id, role')
.eq('role', 'partner')
if (dbError) throw dbError
const partnerIds = new Set(dbUsers.map(u => u.id))
return users.filter(u => partnerIds.has(u.id))
}

View File

@@ -2,9 +2,10 @@ import { createClient } from '@supabase/supabase-js'
export function createAdminClient() { export function createAdminClient() {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
return createClient( return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, supabaseUrl!,
serviceRoleKey, serviceRoleKey,
{ {
auth: { auth: {

View File

@@ -1,8 +1,17 @@
import { createBrowserClient } from "@supabase/ssr"; import { createBrowserClient } from "@supabase/ssr";
import { resolveSupabaseUrl } from "../utils";
export function createClient() { export function createClient() {
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
return createBrowserClient( return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, resolveSupabaseUrl(supabaseUrl)!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
); );
} }

View File

@@ -1,6 +1,6 @@
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { hasEnvVars } from "../utils"; import { hasEnvVars, resolveSupabaseUrl } from "../utils";
export async function updateSession(request: NextRequest) { export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ let supabaseResponse = NextResponse.next({
@@ -13,11 +13,12 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse; return supabaseResponse;
} }
// With Fluid compute, don't put this client in a global environment const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
// variable. Always create a new one on each request. const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const supabase = createServerClient( const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, resolveSupabaseUrl(supabaseUrl)!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, supabaseAnonKey!,
{ {
cookies: { cookies: {
getAll() { getAll() {
@@ -35,6 +36,9 @@ export async function updateSession(request: NextRequest) {
); );
}, },
}, },
cookieOptions: {
name: "webshop-auth-token",
},
}, },
); );

View File

@@ -1,5 +1,6 @@
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { resolveSupabaseUrl } from "../utils";
/** /**
* Especially important if using Fluid compute: Don't put this client in a * Especially important if using Fluid compute: Don't put this client in a
@@ -9,9 +10,12 @@ import { cookies } from "next/headers";
export async function createClient() { export async function createClient() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
return createServerClient( return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, resolveSupabaseUrl(supabaseUrl)!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, supabaseAnonKey!,
{ {
cookies: { cookies: {
getAll() { getAll() {
@@ -29,6 +33,9 @@ export async function createClient() {
} }
}, },
}, },
cookieOptions: {
name: "webshop-auth-token",
},
}, },
); );
} }

View File

@@ -7,5 +7,36 @@ export function cn(...inputs: ClassValue[]) {
// This check can be removed, it is just for tutorial purposes // This check can be removed, it is just for tutorial purposes
export const hasEnvVars = export const hasEnvVars =
process.env.NEXT_PUBLIC_SUPABASE_URL && (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL) &&
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; (process.env.SUPABASE_ANON_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
export function resolveSupabaseUrl(url: string | undefined): string | undefined {
if (!url) return url;
if (typeof window !== 'undefined') {
try {
const parsed = new URL(url);
if (parsed.hostname === 'supabase-kong' || parsed.hostname === 'kong') {
parsed.hostname = window.location.hostname;
parsed.protocol = window.location.protocol;
return parsed.toString().replace(/\/$/, '');
}
// Mitigate Mixed Content: if the site is HTTPS, force Supabase to HTTPS
if (window.location.protocol === 'https:' && parsed.protocol === 'http:') {
parsed.protocol = 'https:';
return parsed.toString().replace(/\/$/, '');
}
} catch {
let resolved = url
.replace('//supabase-kong', `//${window.location.hostname}`)
.replace('//kong', `//${window.location.hostname}`);
if (window.location.protocol === 'https:') {
resolved = resolved.replace('http://', 'https://');
}
return resolved;
}
}
return url;
}

93
shop/migrate.js Normal file
View File

@@ -0,0 +1,93 @@
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
async function run() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.log('DATABASE_URL not set skipping migrations.');
return;
}
console.log('Starting database migrations...');
const client = new Client({
connectionString,
connectionTimeoutMillis: 10000,
});
await client.connect();
// Create schema_migrations table if not exists
await client.query(`
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version VARCHAR(255) PRIMARY KEY,
inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
`);
const migrationsDir = path.join(__dirname, 'supabase', 'migrations');
if (!fs.existsSync(migrationsDir)) {
console.warn(`Migrations directory not found at: ${migrationsDir}`);
await client.end();
return;
}
const files = fs.readdirSync(migrationsDir).sort();
for (const file of files) {
if (!file.endsWith('.sql')) continue;
const { rows } = await client.query('SELECT 1 FROM public.schema_migrations WHERE version = $1', [file]);
if (rows.length === 0) {
console.log(`Running migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
// We run the migration in a single transaction
await client.query('BEGIN');
try {
await client.query(sql);
await client.query('INSERT INTO public.schema_migrations (version) VALUES ($1)', [file]);
await client.query('COMMIT');
console.log(`Migration ${file} completed successfully.`);
} catch (err) {
await client.query('ROLLBACK');
console.error(`Migration ${file} failed, rolled back.`);
throw err;
}
} else {
console.log(`Skipping migration: ${file} (already run)`);
}
}
// Also check if we should run seed.sql (only if we run it for the first time or if products are empty)
const seedFile = path.join(__dirname, 'supabase', 'seed.sql');
if (fs.existsSync(seedFile)) {
try {
const { rows: productRows } = await client.query('SELECT 1 FROM public.products LIMIT 1');
if (productRows.length === 0) {
console.log('No products found. Seeding database with default products...');
const seedSql = fs.readFileSync(seedFile, 'utf8');
await client.query('BEGIN');
try {
await client.query(seedSql);
await client.query('COMMIT');
console.log('Database seeded successfully.');
} catch (err) {
await client.query('ROLLBACK');
console.error('Seeding failed, rolled back.', err);
}
}
} catch (e) {
// In case products table check fails (e.g. initial_schema wasn't run or failed)
console.error('Failed to run seed check/seed SQL', e);
}
}
await client.end();
console.log('Database migrations completed.');
}
run().catch(err => {
console.error('Migration execution failed:', err);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: false, cacheComponents: false,
}; };

167
shop/package-lock.json generated
View File

@@ -19,12 +19,15 @@
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"@supabase/ssr": "latest", "@supabase/ssr": "latest",
"@supabase/supabase-js": "latest", "@supabase/supabase-js": "latest",
"@types/nodemailer": "^8.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "latest", "next": "latest",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"pg": "^8.11.3",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -3174,6 +3177,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -6895,6 +6907,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7166,6 +7187,95 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7407,6 +7517,45 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8178,6 +8327,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -9101,6 +9259,15 @@
} }
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -21,12 +21,15 @@
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"@supabase/ssr": "latest", "@supabase/ssr": "latest",
"@supabase/supabase-js": "latest", "@supabase/supabase-js": "latest",
"@types/nodemailer": "^8.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "latest", "next": "latest",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"pg": "^8.11.3",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -16,13 +16,4 @@ ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
-- Policies -- Policies
CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true); CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true);
-- Seed Categories
INSERT INTO public.categories (id, name, description, icon)
VALUES
('c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Cloud Software', 'Modulare Cloud-Lösungen für den modernen Handel.', 'Cloud'),
('c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'Gastronomie', 'Kassensysteme und Management für Restaurants und Cafés.', 'Utensils'),
('c3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'Hardware', 'Kassenterminals, Drucker und Zubehör.', 'HardDrive');
-- Update existing products with categories
UPDATE public.products SET category_id = 'c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1' WHERE name LIKE '%Cloud%';
UPDATE public.products SET category_id = 'c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2' WHERE name LIKE '%Gastro%';

View File

@@ -0,0 +1,19 @@
-- Trigger, um Profile automatisch bei User-Erstellung in auth.users anzulegen
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id)
VALUES (new.id)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Profile für bereits existierende Benutzer anlegen (Backfill)
INSERT INTO public.profiles (id)
SELECT id FROM auth.users
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,17 @@
-- Migration: Add order_number and order_hash to orders table
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS order_number TEXT,
ADD COLUMN IF NOT EXISTS order_hash TEXT;
-- Update existing orders to have a unique order_number
UPDATE public.orders
SET order_number = 'RE-' || EXTRACT(YEAR FROM created_at)::text || '-' || floor(10000 + random() * 90000)::text
WHERE order_number IS NULL;
-- Now add unique constraint and make it not null
ALTER TABLE public.orders
ALTER COLUMN order_number SET NOT NULL,
ADD CONSTRAINT orders_order_number_key UNIQUE (order_number);
-- Notify PostgREST to reload schema cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,61 @@
-- Migration: Create public.users and public.settings tables
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'partner',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Policies for public.users
DROP POLICY IF EXISTS "Users can view their own role" ON public.users;
DROP POLICY IF EXISTS "Admins can view all roles" ON public.users;
CREATE POLICY "Users can view their own role" ON public.users FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Admins can view all roles" ON public.users FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Enable all for service_role" ON public.users FOR ALL USING (true) WITH CHECK (true);
-- Create public.settings table
CREATE TABLE IF NOT EXISTS public.settings (
id TEXT PRIMARY KEY,
host TEXT,
port INTEGER,
secure BOOLEAN,
"user" TEXT,
pass TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
-- Policies for public.settings
DROP POLICY IF EXISTS "Allow authenticated read on settings" ON public.settings;
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
CREATE POLICY "Allow authenticated read on settings" ON public.settings FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Allow authenticated write on settings" ON public.settings FOR ALL USING (auth.role() = 'authenticated') WITH CHECK (auth.role() = 'authenticated');
-- Update user creation trigger function to also insert into public.users
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id)
VALUES (new.id)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.users (id, role)
VALUES (new.id, CASE WHEN new.email = 'info@hephex.de' THEN 'admin' ELSE 'partner' END)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Backfill public.users for existing auth.users
INSERT INTO public.users (id, role)
SELECT id, CASE WHEN email = 'info@hephex.de' THEN 'admin' ELSE 'partner' END
FROM auth.users
ON CONFLICT (id) DO NOTHING;
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,29 @@
-- Erstellt die Tabelle direkt mit allen Feldern neu
CREATE TABLE IF NOT EXISTS public.customers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
user_id UUID REFERENCES auth.users(id), -- Verknüpfung zu Supabase Auth
-- Neue Pflichtfelder für CASPOS Lizenzserver
company_name TEXT NOT NULL,
street TEXT NOT NULL,
tax_id TEXT NOT NULL,
contact_person TEXT NOT NULL,
email TEXT NOT NULL,
phone_no TEXT NOT NULL,
-- Optionales Feld
dongle_no TEXT
);
-- Zeilen-Sicherheit (RLS) direkt aktivieren
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
-- Policy: Nutzer darf nur eigene Kundendaten sehen
DROP POLICY IF EXISTS "Nutzer sehen nur eigene Daten" ON public.customers;
CREATE POLICY "Nutzer sehen nur eigene Daten"
ON public.customers
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

65
shop/utils/mail.ts Normal file
View File

@@ -0,0 +1,65 @@
// utils/mail.ts simple wrapper around nodemailer
import nodemailer from 'nodemailer';
import { createAdminClient } from '@/lib/supabase/admin';
/**
* Send an email using database-configured SMTP settings (or environment variable fallback).
* @param to Recipient address
* @param subject Subject line
* @param text Plaintext body
* @param html Optional HTML body
*/
export async function sendMail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html?: string;
}) {
// Query custom SMTP settings from database
const supabase = createAdminClient();
const { data: dbSettings } = await supabase
.from('settings')
.select('*')
.eq('id', 'smtp')
.maybeSingle();
// Resolve config from DB or environment variables
const host = dbSettings?.host || process.env.SMTP_HOST;
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
const secure = dbSettings
? !!dbSettings.secure
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
const user = dbSettings?.user || process.env.SMTP_USER;
const pass = dbSettings?.pass || process.env.SMTP_PASS;
if (!host || !user) {
throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).');
}
// Create transporter dynamically on send request
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: {
user,
pass,
},
});
const info = await transporter.sendMail({
from: user,
to,
subject,
text,
html,
});
return info;
}