Compare commits
67 Commits
develop
...
e1f47ddcd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f47ddcd0 | ||
|
|
934879e5cf | ||
|
|
4a17ea378e | ||
|
|
e829006b77 | ||
|
|
76e46c08a4 | ||
|
|
01c46be9ef | ||
|
|
f09cec4e25 | ||
|
|
b34ed79d2b | ||
|
|
47664c0aae | ||
|
|
5662b9f770 | ||
|
|
a56eb93ffc | ||
|
|
615b0719f1 | ||
|
|
62e3c7804a | ||
|
|
9878cdb2a2 | ||
|
|
f19fda5494 | ||
|
|
7b22889e23 | ||
|
|
bdbf840529 | ||
|
|
b71b84da1b | ||
|
|
1b4e998cf9 | ||
|
|
a99adb88d6 | ||
|
|
dd2c3859e9 | ||
|
|
0729dd21be | ||
|
|
de1d228fb2 | ||
|
|
3228c76675 | ||
|
|
b039da3ff8 | ||
|
|
07eb18c61c | ||
|
|
5ba9869400 | ||
|
|
9542fdfe4a | ||
|
|
88f18246ce | ||
|
|
6b76e82220 | ||
|
|
898a5253e5 | ||
|
|
756ec5c002 | ||
|
|
235efd5a47 | ||
|
|
c0c5240f8d | ||
|
|
ca69db2395 | ||
|
|
e789a38d19 | ||
|
|
2ec5993e63 | ||
|
|
6db2610c8d | ||
|
|
0b2809b13e | ||
|
|
9ffa04e551 | ||
|
|
505629f798 | ||
|
|
08418e22d3 | ||
|
|
0f55e17095 | ||
|
|
fa1edc9c12 | ||
|
|
bab2930b97 | ||
|
|
e97ff51dc8 | ||
|
|
4402d4ec64 | ||
|
|
4b9c289ec4 | ||
|
|
072ceaf5f0 | ||
|
|
e31721432b | ||
|
|
e1a555d4f9 | ||
|
|
8bf59b20f6 | ||
|
|
3bc7c6ad3b | ||
|
|
65e133a391 | ||
|
|
7ab0c28f6f | ||
|
|
c187c1dc60 | ||
|
|
9ddf5faf66 | ||
|
|
f11322e98d | ||
|
|
126de81834 | ||
|
|
7bb6807d37 | ||
|
|
3eb72481ce | ||
|
|
2403408b6c | ||
|
|
f91499861e | ||
|
|
84ced5a170 | ||
|
|
d7a45576b2 | ||
|
|
dedf1945ef | ||
| e6913c03f5 |
@@ -7,6 +7,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: webserver
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: shop
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
@@ -15,4 +20,39 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- 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
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Use a multi‑stage 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
35
scripts/init_supabase.sh
Normal 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 "$@"
|
||||
27
shop/app/_global-error.tsx
Normal file
27
shop/app/_global-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
321
shop/app/admin/einstellungen/page.tsx
Normal file
321
shop/app/admin/einstellungen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import Link from 'next/link'
|
||||
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: 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 (
|
||||
<div className="flex min-h-screen bg-[#020617] text-white">
|
||||
{/* Sidebar */}
|
||||
@@ -46,11 +63,14 @@ export default function AdminLayout({
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<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">
|
||||
<LogOut className="w-5 h-5" />
|
||||
Abmelden
|
||||
</button>
|
||||
<div className="p-4 border-t border-white/5 space-y-4">
|
||||
<AdminLogoutButton />
|
||||
<div className="px-3 pt-2 flex items-center gap-2 border-t border-white/5 opacity-60">
|
||||
<span className="font-bold text-sm 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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { OrdersTable } from '@/components/admin/orders-table'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,75 +28,8 @@ async function OrdersData() {
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
return <OrdersTable initialOrders={orders || []} />
|
||||
}
|
||||
|
||||
151
shop/app/admin/settings/page.tsx
Normal file
151
shop/app/admin/settings/page.tsx
Normal 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">SMTP‑Einstellungen</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 (SMTP‑User)
|
||||
<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"
|
||||
>
|
||||
Test‑Mail senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { UserDialog } from '@/components/admin/user-dialog'
|
||||
import { Users } from 'lucide-react'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
return (
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
@@ -32,10 +34,14 @@ async function UserDataWrapper() {
|
||||
try {
|
||||
const users = await getUsers()
|
||||
return <UserList initialUsers={users} />
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('[UserDataWrapper] Error loading users:', error)
|
||||
return (
|
||||
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center">
|
||||
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.
|
||||
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center space-y-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
52
shop/app/api/admin/send-test-email/route.ts
Normal file
52
shop/app/api/admin/send-test-email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
96
shop/app/api/admin/smtp-settings/route.ts
Normal file
96
shop/app/api/admin/smtp-settings/route.ts
Normal 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' });
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { SignUpForm } from "@/components/sign-up-form";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
export default function SignUpPage() {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import "./globals.css";
|
||||
|
||||
const defaultUrl = process.env.VERCEL_URL
|
||||
@@ -33,7 +36,10 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<InactivityTracker />
|
||||
<HeaderWrapper navbar={<Navbar />}>
|
||||
{children}
|
||||
</HeaderWrapper>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5
shop/app/login/page.tsx
Normal file
5
shop/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item, i) => (
|
||||
<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"
|
||||
>
|
||||
{item.product_name}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||
import { OrderWizard } from '@/components/order-wizard'
|
||||
@@ -31,19 +32,48 @@ async function OrderDataWrapper() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login')
|
||||
redirect('/auth/login?next=/order')
|
||||
}
|
||||
|
||||
const products = await getProducts()
|
||||
const categories = await getCategories()
|
||||
const endCustomers = await getEndCustomers()
|
||||
|
||||
// Fetch profile if exists
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
// Check user role
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.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} />
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export default async function OrderSuccessPage({
|
||||
<CardContent className="space-y-4">
|
||||
{/* Produkte */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
@@ -90,8 +90,8 @@ export default async function OrderSuccessPage({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.selected_modules.map((mod, j) => (
|
||||
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,108 +1,110 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
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() {
|
||||
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">
|
||||
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
||||
</div>
|
||||
<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">
|
||||
<Link className="flex items-center justify-center gap-2" href="#">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<Rocket className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
<>
|
||||
{/* Hero Section: Zielgerichtet auf Lizenzen */}
|
||||
<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-blue-600/10 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-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">
|
||||
CASPOS Lizenz-Management-Server
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
|
||||
<div className="container mx-auto px-4 md:px-6">
|
||||
<div className="grid gap-12 lg:grid-cols-3">
|
||||
<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">
|
||||
<Cpu className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Handel</h3>
|
||||
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. 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">
|
||||
<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>
|
||||
{/* High-Contrast Buttons (Light/Dark Ready) */}
|
||||
<div className="space-x-4 mt-8">
|
||||
<Link href="/order">
|
||||
<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">
|
||||
Lizenz bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer">
|
||||
<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">
|
||||
Fragen? Kontaktieren Sie uns
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<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]">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
<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="#">Datenschutz</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="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</Link>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
35
shop/components/HeaderWrapper.tsx
Normal file
35
shop/components/HeaderWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
shop/components/Navbar.tsx
Normal file
60
shop/components/Navbar.tsx
Normal 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} />;
|
||||
}
|
||||
279
shop/components/NavbarClient.tsx
Normal file
279
shop/components/NavbarClient.tsx
Normal 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.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
@@ -54,6 +55,7 @@ export function CategoryDialog({
|
||||
category?: Category
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const form = useForm<CategoryFormValues>({
|
||||
resolver: zodResolver(categorySchema) as any,
|
||||
@@ -75,6 +77,7 @@ export function CategoryDialog({
|
||||
}
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { deleteCategory } from '@/lib/actions/products'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CategoryDialog } from './category-dialog'
|
||||
|
||||
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
|
||||
const [categories, setCategories] = useState(initialCategories)
|
||||
|
||||
useEffect(() => {
|
||||
setCategories(initialCategories)
|
||||
}, [initialCategories])
|
||||
|
||||
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.')) {
|
||||
await deleteCategory(id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, useFieldArray } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
@@ -39,12 +40,13 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const moduleSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
description: z.string().optional(),
|
||||
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||
is_required: z.boolean().default(false),
|
||||
requirements: z.string().optional().default(''), // Will be converted to array
|
||||
exclusions: z.string().optional().default(''), // Will be converted to array
|
||||
requirements: z.array(z.string()).default([]),
|
||||
exclusions: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
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 }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
resolver: zodResolver(productSchema) as any,
|
||||
@@ -70,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
category_id: product?.category_id || '',
|
||||
billing_interval: product?.billing_interval || 'monthly',
|
||||
modules: product?.modules?.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description || '',
|
||||
price: m.price,
|
||||
is_required: false,
|
||||
requirements: m.requirements?.join(', ') || '',
|
||||
exclusions: m.exclusions?.join(', ') || '',
|
||||
requirements: m.requirements || [],
|
||||
exclusions: m.exclusions || [],
|
||||
})) || [],
|
||||
},
|
||||
})
|
||||
@@ -90,8 +94,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
const { modules, ...productData } = values
|
||||
const formattedModules = modules.map(m => ({
|
||||
...m,
|
||||
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
|
||||
}))
|
||||
|
||||
if (product) {
|
||||
@@ -102,6 +105,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
|
||||
setOpen(false)
|
||||
if (!product) form.reset()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error saving product:', error)
|
||||
}
|
||||
@@ -240,103 +244,184 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
{fields.map((field, index) => {
|
||||
const allModules = form.watch('modules') || []
|
||||
const otherModules = allModules
|
||||
.map((m, idx) => ({
|
||||
id: m.id || field.id,
|
||||
name: m.name || `Modul ${idx + 1}`,
|
||||
}))
|
||||
.filter((_, idx) => idx !== index)
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-white">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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>
|
||||
return (
|
||||
<div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-white">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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="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 className="grid grid-cols-2 gap-4 border border-white/10 rounded-lg p-3 bg-black/20">
|
||||
{/* Requirements Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: reqField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-white text-xs font-semibold">Benötigt Module</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 = (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>
|
||||
</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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
|
||||
|
||||
47
shop/components/admin/logout-menu-item.tsx
Normal file
47
shop/components/admin/logout-menu-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
shop/components/admin/orders-table.tsx
Normal file
197
shop/components/admin/orders-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,16 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Edit2, Trash2, Layers } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteProduct } from '@/lib/actions/products'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CreateProductDialog } from './create-product-dialog'
|
||||
|
||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
|
||||
useEffect(() => {
|
||||
setProducts(initialProducts)
|
||||
}, [initialProducts])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||||
await deleteProduct(id)
|
||||
|
||||
@@ -40,7 +40,8 @@ export function AdminRecentOrders() {
|
||||
|
||||
const fetchOrders = async () => {
|
||||
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
|
||||
const { orders: fresh } = await res.json()
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ import { Plus } from 'lucide-react'
|
||||
|
||||
const userSchema = z.object({
|
||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen lang sein').optional(),
|
||||
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen lang sein').optional().or(z.literal('')),
|
||||
role: z.enum(['admin', 'partner']),
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof userSchema>
|
||||
@@ -43,12 +44,19 @@ export function UserDialog() {
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'partner',
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: UserFormValues) {
|
||||
try {
|
||||
await createUser(values)
|
||||
// If password is empty string, omit it
|
||||
const payload = {
|
||||
email: values.email,
|
||||
role: values.role,
|
||||
...(values.password ? { password: values.password } : {})
|
||||
}
|
||||
await createUser(payload)
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
@@ -68,7 +76,7 @@ export function UserDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||
<DialogDescription className="text-slate-300">
|
||||
Geben Sie die E-Mail und optional ein Passwort für den neuen Benutzer ein.
|
||||
Geben Sie die E-Mail, die Rolle und optional ein Passwort für den neuen Benutzer ein.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -87,6 +95,25 @@ export function UserDialog() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-white">Rolle</FormLabel>
|
||||
<FormControl>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-slate-950/80 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
{...field}
|
||||
>
|
||||
<option value="partner">Partner</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
||||
@@ -53,6 +53,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
<TableHeader>
|
||||
<TableRow className="border-white/10 hover:bg-white/5">
|
||||
<TableHead className="text-slate-200">E-Mail</TableHead>
|
||||
<TableHead className="text-slate-200">Rolle</TableHead>
|
||||
<TableHead className="text-slate-200">Status</TableHead>
|
||||
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
|
||||
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||
@@ -69,6 +70,11 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={user.role === 'admin' ? "bg-purple-500/20 text-purple-400 border border-purple-500/30" : "bg-blue-500/20 text-blue-400 border border-blue-500/30"}>
|
||||
{user.role === 'admin' ? 'Admin' : 'Partner'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
|
||||
{isBanned ? "Gesperrt" : "Aktiv"}
|
||||
|
||||
@@ -21,9 +21,6 @@ export async function AuthButton() {
|
||||
<Button asChild size="sm" variant={"outline"}>
|
||||
<Link href="/auth/login">Sign in</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant={"default"}>
|
||||
<Link href="/auth/sign-up">Sign up</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { resetPassword } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -26,16 +26,11 @@ export function ForgotPasswordForm({
|
||||
|
||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const supabase = createClient();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
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
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/update-password`,
|
||||
});
|
||||
if (error) throw error;
|
||||
await resetPassword(email);
|
||||
setSuccess(true);
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
|
||||
68
shop/components/inactivity-tracker.tsx
Normal file
68
shop/components/inactivity-tracker.tsx
Normal 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;
|
||||
}
|
||||
@@ -94,75 +94,103 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||
<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>
|
||||
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
const items = orderSnapshot?.items ?? [];
|
||||
const subtotal = orderSnapshot?.subtotal ?? 0;
|
||||
const taxAmount = orderSnapshot?.tax_amount ?? 0;
|
||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||
const total = orderSnapshot?.total ?? 0;
|
||||
|
||||
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Rechnungsempfänger</Text>
|
||||
<Text>{customer.company_name}</Text>
|
||||
<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.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>
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||
<Text>Software Solutions</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.total}>
|
||||
<View style={{ width: '40%' }}>
|
||||
<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 style={styles.companyInfo}>
|
||||
<Text>CASPOS GmbH</Text>
|
||||
<Text>Musterstraße 1</Text>
|
||||
<Text>12345 Musterstadt</Text>
|
||||
<Text>Deutschland</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>
|
||||
);
|
||||
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Rechnungsempfänger</Text>
|
||||
<Text>{customer.company_name}</Text>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { signIn } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -28,18 +28,20 @@ export function LoginForm({
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const supabase = createClient();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) throw error;
|
||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||
router.push("/protected");
|
||||
const res = await signIn(email, password);
|
||||
if (nextParam) {
|
||||
router.push(nextParam);
|
||||
} else if (res.role === "admin") {
|
||||
router.push("/admin/einstellungen");
|
||||
} else {
|
||||
router.push("/my-customers");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
} finally {
|
||||
@@ -88,21 +90,23 @@ export function LoginForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Logging in..." : "Login"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/sign-up"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
{(() => {
|
||||
const messageParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('message') : null;
|
||||
if (messageParam === "concurrent") {
|
||||
return (
|
||||
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Logging in..." : "Login"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -8,9 +8,9 @@ export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/auth/login");
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return <Button onClick={logout}>Logout</Button>;
|
||||
|
||||
@@ -48,12 +48,18 @@ function toggleModuleInList(
|
||||
): string[] {
|
||||
const isSelected = currentIds.includes(toggleId)
|
||||
if (isSelected) {
|
||||
const next = currentIds.filter(id => id !== toggleId)
|
||||
// cascade: remove modules that required this one
|
||||
return next.filter(mId => {
|
||||
const m = modules.find(mod => mod.id === mId)
|
||||
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
|
||||
}).filter(id => id !== toggleId)
|
||||
// Recursive removal: repeatedly filter until no more dependent modules are removed
|
||||
let next = currentIds.filter(id => id !== toggleId)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
const beforeLength = next.length
|
||||
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 {
|
||||
const module = modules.find(m => m.id === toggleId)
|
||||
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
|
||||
@@ -118,20 +124,29 @@ export function OrderWizard({
|
||||
)
|
||||
|
||||
// Total price across all selections
|
||||
const totalPrice = useMemo(() => {
|
||||
let total = 0
|
||||
// Total price calculations (split by billing interval)
|
||||
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||
let monthly = 0
|
||||
let oneTime = 0
|
||||
for (const cat of categories) {
|
||||
const sel = selections[cat.id]
|
||||
if (!sel?.productId) continue
|
||||
const prod = products.find(p => p.id === sel.productId)
|
||||
if (!prod) continue
|
||||
total += Number(prod.base_price)
|
||||
const base = Number(prod.base_price)
|
||||
let modulesSum = 0
|
||||
sel.moduleIds.forEach(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])
|
||||
|
||||
// Helper: update product selection for a category (resets modules)
|
||||
@@ -425,12 +440,38 @@ export function OrderWizard({
|
||||
)
|
||||
})}
|
||||
<Separator className="bg-white/10" />
|
||||
<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(totalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-base font-semibold text-white">
|
||||
<span className="text-slate-400">Einmalig gesamt:</span>
|
||||
<span>
|
||||
{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 && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
@@ -669,15 +710,43 @@ export function OrderWizard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||
<span>Gesamtbetrag:</span>
|
||||
<span>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
|
||||
</span>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-2 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between text-lg font-bold text-white">
|
||||
<span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
|
||||
<span>
|
||||
{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">
|
||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
|
||||
Datenschutzerklärung.
|
||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||
<a href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||
Datenschutzerklärung
|
||||
</a>.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { signUp } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -29,7 +29,6 @@ export function SignUpForm({
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const supabase = createClient();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -40,14 +39,7 @@ export function SignUpForm({
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/protected`,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
await signUp(email, password);
|
||||
router.push("/auth/sign-up-success");
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
|
||||
41
shop/components/ui/switch.tsx
Normal file
41
shop/components/ui/switch.tsx
Normal 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 };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { updatePassword } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -26,15 +26,12 @@ export function UpdatePasswordForm({
|
||||
|
||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const supabase = createClient();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({ password });
|
||||
if (error) throw error;
|
||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||
router.push("/protected");
|
||||
await updatePassword(password);
|
||||
router.push("/");
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
} finally {
|
||||
|
||||
70
shop/lib/actions/auth.ts
Normal file
70
shop/lib/actions/auth.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { EndCustomer } from '@/lib/types'
|
||||
|
||||
@@ -143,3 +144,15 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
||||
revalidatePath('/my-customers')
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import React from 'react'
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
|
||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
|
||||
*/
|
||||
export async function submitOrder(params: {
|
||||
selections: WizardSelections
|
||||
products: Product[]
|
||||
categories: Category[]
|
||||
products?: Product[]
|
||||
categories?: Category[]
|
||||
customerProfile: Partial<Profile>
|
||||
endCustomerId?: string | null
|
||||
endCustomer?: EndCustomer | null
|
||||
}): Promise<Order> {
|
||||
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
|
||||
const { selections, customerProfile, endCustomerId, endCustomer } = params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
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
|
||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||
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
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
@@ -144,7 +185,7 @@ export async function submitOrder(params: {
|
||||
console.error('PDF Generation Error:', pdfError)
|
||||
}
|
||||
|
||||
revalidatePath('/protected')
|
||||
revalidatePath('/my-customers')
|
||||
revalidatePath('/my-orders')
|
||||
return order as Order
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { Category, Product, ProductModule } from '../types'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export async function getProducts() {
|
||||
const supabase = await createClient()
|
||||
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
|
||||
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()
|
||||
|
||||
// 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 (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
|
||||
.from('product_modules')
|
||||
.insert(modulesWithId)
|
||||
@@ -106,12 +118,13 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
||||
|
||||
if (modules.length > 0) {
|
||||
const modulesWithId = modules.map(m => ({
|
||||
id: m.id || randomUUID(),
|
||||
product_id: id,
|
||||
name: m.name,
|
||||
description: m.description || null,
|
||||
price: m.price,
|
||||
requirements: m.requirements || [],
|
||||
exclusions: m.exclusions || [],
|
||||
product_id: id
|
||||
}))
|
||||
const { error: modulesError } = await supabase
|
||||
.from('product_modules')
|
||||
@@ -120,6 +133,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
||||
if (modulesError) throw modulesError
|
||||
}
|
||||
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
@@ -7,10 +7,25 @@ export async function getUsers() {
|
||||
const admin = createAdminClient()
|
||||
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
||||
if (error) throw error
|
||||
return users
|
||||
|
||||
const { data: dbUsers, error: dbError } = await admin
|
||||
.from('users')
|
||||
.select('id, role')
|
||||
|
||||
const roleMap: Record<string, string> = {}
|
||||
if (!dbError && dbUsers) {
|
||||
dbUsers.forEach(u => {
|
||||
roleMap[u.id] = u.role
|
||||
})
|
||||
}
|
||||
|
||||
return users.map(u => ({
|
||||
...u,
|
||||
role: roleMap[u.id] || 'partner'
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createUser(data: { email: string; password?: string; email_confirm?: boolean }) {
|
||||
export async function createUser(data: { email: string; password?: string; role?: 'admin' | 'partner'; email_confirm?: boolean }) {
|
||||
const admin = createAdminClient()
|
||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
||||
email: data.email,
|
||||
@@ -18,6 +33,16 @@ export async function createUser(data: { email: string; password?: string; email
|
||||
email_confirm: data.email_confirm ?? true,
|
||||
})
|
||||
if (error) throw error
|
||||
if (!user) throw new Error('Benutzer konnte nicht erstellt werden.')
|
||||
|
||||
// Upsert user role in public.users table
|
||||
const role = data.role || 'partner'
|
||||
const { error: dbError } = await admin
|
||||
.from('users')
|
||||
.upsert({ id: user.id, role }, { onConflict: 'id' })
|
||||
|
||||
if (dbError) throw dbError
|
||||
|
||||
revalidatePath('/admin/users')
|
||||
return user
|
||||
}
|
||||
@@ -48,3 +73,18 @@ export async function resetPassword(email: string) {
|
||||
if (error) throw error
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
export function createAdminClient() {
|
||||
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(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
supabaseUrl!,
|
||||
serviceRoleKey,
|
||||
{
|
||||
auth: {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { resolveSupabaseUrl } from "../utils";
|
||||
|
||||
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(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
resolveSupabaseUrl(supabaseUrl)!,
|
||||
supabaseAnonKey!,
|
||||
{
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { hasEnvVars } from "../utils";
|
||||
import { hasEnvVars, resolveSupabaseUrl } from "../utils";
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
@@ -13,11 +13,12 @@ export async function updateSession(request: NextRequest) {
|
||||
return supabaseResponse;
|
||||
}
|
||||
|
||||
// With Fluid compute, don't put this client in a global environment
|
||||
// variable. Always create a new one on each request.
|
||||
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;
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
resolveSupabaseUrl(supabaseUrl)!,
|
||||
supabaseAnonKey!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
@@ -35,6 +36,9 @@ export async function updateSession(request: NextRequest) {
|
||||
);
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { resolveSupabaseUrl } from "../utils";
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
resolveSupabaseUrl(supabaseUrl)!,
|
||||
supabaseAnonKey!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
@@ -29,6 +33,9 @@ export async function createClient() {
|
||||
}
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,36 @@ export function cn(...inputs: ClassValue[]) {
|
||||
|
||||
// This check can be removed, it is just for tutorial purposes
|
||||
export const hasEnvVars =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||
(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL) &&
|
||||
(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
93
shop/migrate.js
Normal 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);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
cacheComponents: false,
|
||||
};
|
||||
|
||||
|
||||
167
shop/package-lock.json
generated
167
shop/package-lock.json
generated
@@ -19,12 +19,15 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@supabase/ssr": "latest",
|
||||
"@supabase/supabase-js": "latest",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "latest",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -3174,6 +3177,15 @@
|
||||
"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": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -6895,6 +6907,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -7166,6 +7187,95 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -7407,6 +7517,45 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -8178,6 +8327,15 @@
|
||||
"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": {
|
||||
"version": "0.0.5",
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@supabase/ssr": "latest",
|
||||
"@supabase/supabase-js": "latest",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "latest",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -16,13 +16,4 @@ ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
|
||||
-- Policies
|
||||
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%';
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
65
shop/utils/mail.ts
Normal 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 Plain‑text 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user