Compare commits
71 Commits
develop
...
f2db40fa36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2db40fa36 | ||
|
|
0968f1a753 | ||
|
|
ac5bd766a4 | ||
|
|
5c8c198fc5 | ||
|
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: webserver
|
runs-on: webserver
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: shop
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Code auschecken
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -15,4 +20,46 @@ jobs:
|
|||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Staging-Build generieren
|
- name: Staging-Build generieren
|
||||||
run: npm run build -- --configuration=staging
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
# KORREKTUR: Wir brennen die interne Docker-Netzwerk-URL fest ein!
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }}
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Docker login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# KORREKTUR: Wir übergeben die INTERNE Docker-URL auch an das Docker-Image!
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_URL="http://supabase-kong:8000" \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${{ secrets.STAGING_SUPABASE_ANON_KEY }}" \
|
||||||
|
-t gitea.hephex.de/daniel/webshop:staging -f Dockerfile .
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# KORREKTUR: Auch der Push wird vom Hauptordner aus gestartet
|
||||||
|
- name: Push Docker image
|
||||||
|
run: docker push gitea.hephex.de/daniel/webshop:staging
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Clean up runner Docker cache
|
||||||
|
run: |
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f
|
||||||
|
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
|
||||||
|
docker image prune -f
|
||||||
|
|||||||
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 Link from 'next/link'
|
||||||
import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } from 'lucide-react'
|
import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } from 'lucide-react'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) redirect('/auth/login')
|
||||||
|
|
||||||
|
const { data: userData } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('role')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!userData || userData.role !== 'admin') {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[#020617] text-white">
|
<div className="flex min-h-screen bg-[#020617] text-white">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -46,11 +63,14 @@ export default function AdminLayout({
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/5">
|
<div className="p-4 border-t border-white/5 space-y-4">
|
||||||
<button className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
|
<AdminLogoutButton />
|
||||||
<LogOut className="w-5 h-5" />
|
<div className="px-3 pt-2 flex items-center gap-2 border-t border-white/5 opacity-60">
|
||||||
Abmelden
|
<span className="font-bold text-sm tracking-tighter text-white">CASPOS Store</span>
|
||||||
</button>
|
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||||
|
Demo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { OrdersTable } from '@/components/admin/orders-table'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { FileText, Download, ExternalLink } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
export default async function AdminOrdersPage() {
|
export default async function AdminOrdersPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white">Bestellungen & Rechnungen</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen & Rechnungen</h1>
|
||||||
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
|
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,75 +28,8 @@ async function OrdersData() {
|
|||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>Fehler beim Laden der Bestellungen: {error.message}</div>
|
return <div className="text-red-500">Fehler beim Laden der Bestellungen: {error.message}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <OrdersTable initialOrders={orders || []} />
|
||||||
<Card className="glass-dark border-white/10">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="border-white/10 hover:bg-transparent">
|
|
||||||
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
|
||||||
<TableHead className="text-slate-200">Datum</TableHead>
|
|
||||||
<TableHead className="text-slate-200">Kunde</TableHead>
|
|
||||||
<TableHead className="text-slate-200">Betrag</TableHead>
|
|
||||||
<TableHead className="text-slate-200">Status</TableHead>
|
|
||||||
<TableHead className="text-right text-slate-200">Rechnung</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{orders?.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
|
||||||
Noch keine Bestellungen vorhanden.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
orders?.map((order) => (
|
|
||||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
|
|
||||||
<TableCell className="font-mono text-xs text-primary font-semibold">
|
|
||||||
{order.order_number ?? order.id.slice(0, 8) + '...'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-slate-300">
|
|
||||||
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-white">{order.customer_data?.company_name || 'Privatkunde'}</div>
|
|
||||||
<div className="text-xs text-slate-400">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-semibold text-white">
|
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={order.status === 'completed' ? 'default' : 'secondary'} className="capitalize">
|
|
||||||
{order.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{order.pdf_url ? (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20">
|
|
||||||
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" /> Ansehen
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" asChild className="h-8">
|
|
||||||
<a href={order.pdf_url} download>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground italic">Kein PDF</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Users } from 'lucide-react'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function AdminUsersPage() {
|
export default async function AdminUsersPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||||
@@ -32,10 +34,14 @@ async function UserDataWrapper() {
|
|||||||
try {
|
try {
|
||||||
const users = await getUsers()
|
const users = await getUsers()
|
||||||
return <UserList initialUsers={users} />
|
return <UserList initialUsers={users} />
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error('[UserDataWrapper] Error loading users:', error)
|
||||||
return (
|
return (
|
||||||
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center">
|
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center space-y-2">
|
||||||
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.
|
<p>Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.</p>
|
||||||
|
<p className="text-xs font-mono text-destructive/80">
|
||||||
|
Details: {error instanceof Error ? error.message : JSON.stringify(error)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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() {
|
export default function SignUpPage() {
|
||||||
return (
|
redirect("/auth/login");
|
||||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<SignUpForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||||
|
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const defaultUrl = process.env.VERCEL_URL
|
const defaultUrl = process.env.VERCEL_URL
|
||||||
@@ -33,7 +36,10 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<InactivityTracker />
|
||||||
|
<HeaderWrapper navbar={<Navbar />}>
|
||||||
{children}
|
{children}
|
||||||
|
</HeaderWrapper>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||||
|
import type { EndCustomer } from '@/lib/types'
|
||||||
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -13,10 +14,20 @@ export default async function MyCustomersPage() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) redirect('/auth/login')
|
if (!user) redirect('/auth/login')
|
||||||
|
|
||||||
const customers = await getEndCustomers()
|
let customers: EndCustomer[] = []
|
||||||
|
let fetchError: string | null = null
|
||||||
|
try {
|
||||||
|
customers = await getEndCustomers()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error loading end customers:", err)
|
||||||
|
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden."
|
||||||
|
}
|
||||||
|
|
||||||
// Anzahl Bestellungen pro Endkunde
|
// Anzahl Bestellungen pro Endkunde
|
||||||
const { data: orderCounts } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
|
const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
|
||||||
|
if (orderError) {
|
||||||
|
console.error("Error loading order counts:", orderError)
|
||||||
|
}
|
||||||
|
|
||||||
const countMap: Record<string, number> = {}
|
const countMap: Record<string, number> = {}
|
||||||
;(orderCounts ?? []).forEach(o => {
|
;(orderCounts ?? []).forEach(o => {
|
||||||
@@ -53,6 +64,12 @@ export default async function MyCustomersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fetchError && (
|
||||||
|
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
|
||||||
|
{fetchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keine Kunden */}
|
{/* Keine Kunden */}
|
||||||
{customers.length === 0 && (
|
{customers.length === 0 && (
|
||||||
<Card className="glass-dark border-white/10">
|
<Card className="glass-dark border-white/10">
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={item.product_id}
|
||||||
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
||||||
>
|
>
|
||||||
{item.product_name}
|
{item.product_name}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||||
import { OrderWizard } from '@/components/order-wizard'
|
import { OrderWizard } from '@/components/order-wizard'
|
||||||
@@ -31,19 +32,48 @@ async function OrderDataWrapper() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect('/auth/login')
|
redirect('/auth/login?next=/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await getProducts()
|
// Check user role
|
||||||
const categories = await getCategories()
|
const { data: userData } = await supabase
|
||||||
const endCustomers = await getEndCustomers()
|
.from('users')
|
||||||
|
.select('role')
|
||||||
// Fetch profile if exists
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
const products = await getProducts().catch(() => [])
|
||||||
|
const categories = await getCategories().catch(() => [])
|
||||||
|
|
||||||
|
// Admin sees all end customers across partners
|
||||||
|
let endCustomers = []
|
||||||
|
if (userData?.role === 'admin') {
|
||||||
|
try {
|
||||||
|
const adminClient = createAdminClient()
|
||||||
|
const { data } = await adminClient
|
||||||
|
.from('end_customers')
|
||||||
|
.select('*')
|
||||||
|
.order('company_name', { ascending: true })
|
||||||
|
endCustomers = data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden aller Endkunden für Admin:', e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
endCustomers = await getEndCustomers().catch(() => [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile safely using maybeSingle
|
||||||
|
let profile = null
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
profile = data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden des Profils:', e)
|
||||||
|
}
|
||||||
|
|
||||||
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
|
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export default async function OrderSuccessPage({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Produkte */}
|
{/* Produkte */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, i) => (
|
{items.map((item) => (
|
||||||
<div key={i} className="space-y-1">
|
<div key={item.product_id} className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-semibold text-white">{item.product_name}</span>
|
<span className="font-semibold text-white">{item.product_name}</span>
|
||||||
<span className="text-white">
|
<span className="text-white">
|
||||||
@@ -90,8 +90,8 @@ export default async function OrderSuccessPage({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.selected_modules.map((mod, j) => (
|
{item.selected_modules.map((mod) => (
|
||||||
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
|
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||||
<span>+ {mod.module_name}</span>
|
<span>+ {mod.module_name}</span>
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +1,38 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
|
import { Shield, Cpu, ArrowRight, Key, Zap, CheckCircle2 } from 'lucide-react'
|
||||||
|
import { Navbar } from '@/components/Navbar'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
|
<>
|
||||||
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50">
|
{/* Hero Section: Zielgerichtet auf Lizenzen */}
|
||||||
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
|
||||||
</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">
|
<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="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="container mx-auto">
|
||||||
<div className="flex flex-col items-center space-y-4 text-center">
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
||||||
⚠️ Demo-Umgebung für Testbestellungen
|
CASPOS Lizenz-Management-Server
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
|
<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>
|
POS Software <span className="text-blue-600 dark:text-blue-500">Lizenzen & Module</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
|
<p className="mx-auto max-w-[700px] text-slate-600 dark:text-slate-400 md:text-xl mt-6">
|
||||||
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
|
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme. Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* High-Contrast Buttons (Light/Dark Ready) */}
|
||||||
<div className="space-x-4 mt-8">
|
<div className="space-x-4 mt-8">
|
||||||
<Link href="/order">
|
<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">
|
<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">
|
||||||
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
Lizenz bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/contact">
|
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer">
|
||||||
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
|
<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">
|
||||||
Contact Us
|
Fragen? Kontaktieren Sie uns
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,41 +40,71 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
|
{/* 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="container mx-auto px-4 md:px-6">
|
||||||
<div className="grid gap-12 lg:grid-cols-3">
|
<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">
|
{/* Modul 1 */}
|
||||||
<Cpu className="h-8 w-8 text-primary" />
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Handel</h3>
|
<h3 className="text-xl font-bold">Handel & ERP</h3>
|
||||||
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
|
<p className="text-slate-600 dark:text-slate-400">Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).</p>
|
||||||
</div>
|
</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">
|
{/* Modul 2 */}
|
||||||
<Shield className="h-8 w-8 text-primary" />
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Gastronomie</h3>
|
<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>
|
<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>
|
</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">
|
{/* Modul 3 */}
|
||||||
<Rocket className="h-8 w-8 text-primary" />
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
|
<h3 className="text-xl font-bold">Hybride Systeme</h3>
|
||||||
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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]">
|
{/* Footer */}
|
||||||
|
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#020617]">
|
||||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||||
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
|
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
|
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/impressum/" target="_blank" rel="noopener noreferrer">Impressum</Link>
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
|
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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} />;
|
||||||
|
}
|
||||||
284
shop/components/NavbarClient.tsx
Normal file
284
shop/components/NavbarClient.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"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);
|
||||||
|
const [pathname, setPathname] = useState("/");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPathname(window.location.pathname);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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=${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=${pathname}`}>Anmelden</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
@@ -54,6 +55,7 @@ export function CategoryDialog({
|
|||||||
category?: Category
|
category?: Category
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const form = useForm<CategoryFormValues>({
|
const form = useForm<CategoryFormValues>({
|
||||||
resolver: zodResolver(categorySchema) as any,
|
resolver: zodResolver(categorySchema) as any,
|
||||||
@@ -75,6 +77,7 @@ export function CategoryDialog({
|
|||||||
}
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving category:', error)
|
console.error('Error saving category:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { deleteCategory } from '@/lib/actions/products'
|
import { deleteCategory } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { CategoryDialog } from './category-dialog'
|
import { CategoryDialog } from './category-dialog'
|
||||||
|
|
||||||
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
|
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
|
||||||
const [categories, setCategories] = useState(initialCategories)
|
const [categories, setCategories] = useState(initialCategories)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCategories(initialCategories)
|
||||||
|
}, [initialCategories])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
|
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
|
||||||
await deleteCategory(id)
|
await deleteCategory(id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm, useFieldArray } from 'react-hook-form'
|
import { useForm, useFieldArray } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
@@ -39,12 +40,13 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
const moduleSchema = z.object({
|
const moduleSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||||
is_required: z.boolean().default(false),
|
is_required: z.boolean().default(false),
|
||||||
requirements: z.string().optional().default(''), // Will be converted to array
|
requirements: z.array(z.string()).default([]),
|
||||||
exclusions: z.string().optional().default(''), // Will be converted to array
|
exclusions: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
@@ -60,6 +62,7 @@ type ProductFormValues = z.infer<typeof productSchema>
|
|||||||
|
|
||||||
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
|
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as any,
|
resolver: zodResolver(productSchema) as any,
|
||||||
@@ -70,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
category_id: product?.category_id || '',
|
category_id: product?.category_id || '',
|
||||||
billing_interval: product?.billing_interval || 'monthly',
|
billing_interval: product?.billing_interval || 'monthly',
|
||||||
modules: product?.modules?.map(m => ({
|
modules: product?.modules?.map(m => ({
|
||||||
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || '',
|
description: m.description || '',
|
||||||
price: m.price,
|
price: m.price,
|
||||||
is_required: false,
|
is_required: false,
|
||||||
requirements: m.requirements?.join(', ') || '',
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions?.join(', ') || '',
|
exclusions: m.exclusions || [],
|
||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -90,8 +94,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
const { modules, ...productData } = values
|
const { modules, ...productData } = values
|
||||||
const formattedModules = modules.map(m => ({
|
const formattedModules = modules.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
|
id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
|
||||||
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (product) {
|
if (product) {
|
||||||
@@ -102,6 +105,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
if (!product) form.reset()
|
if (!product) form.reset()
|
||||||
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving product:', error)
|
console.error('Error saving product:', error)
|
||||||
}
|
}
|
||||||
@@ -240,13 +244,30 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||||
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })}
|
onClick={() => append({
|
||||||
|
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||||
|
name: '',
|
||||||
|
price: 0,
|
||||||
|
is_required: false,
|
||||||
|
description: '',
|
||||||
|
requirements: [],
|
||||||
|
exclusions: []
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group">
|
<div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -287,31 +308,94 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4 border border-white/10 rounded-lg p-3 bg-black/20">
|
||||||
|
{/* Requirements Selection Matrix */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.requirements`}
|
name={`modules.${index}.requirements`}
|
||||||
render={({ field }) => (
|
render={({ field: reqField }) => (
|
||||||
<FormItem>
|
<div className="space-y-2">
|
||||||
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||||
<FormControl>
|
<ScrollArea className="h-28 border border-white/5 rounded p-2 bg-white/5">
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
{otherModules.length === 0 ? (
|
||||||
</FormControl>
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Exclusions Selection Matrix */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.exclusions`}
|
name={`modules.${index}.exclusions`}
|
||||||
render={({ field }) => (
|
render={({ field: exclField }) => (
|
||||||
<FormItem>
|
<div className="space-y-2">
|
||||||
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||||
<FormControl>
|
<ScrollArea className="h-28 border border-white/5 rounded p-2 bg-white/5">
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
{otherModules.length === 0 ? (
|
||||||
</FormControl>
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<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>
|
||||||
@@ -336,7 +420,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
|
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
|
||||||
|
|||||||
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 { Edit2, Trash2, Layers } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { deleteProduct } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { CreateProductDialog } from './create-product-dialog'
|
import { CreateProductDialog } from './create-product-dialog'
|
||||||
|
|
||||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||||
const [products, setProducts] = useState(initialProducts)
|
const [products, setProducts] = useState(initialProducts)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProducts(initialProducts)
|
||||||
|
}, [initialProducts])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||||||
await deleteProduct(id)
|
await deleteProduct(id)
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export function AdminRecentOrders() {
|
|||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { orders: fresh } = await res.json()
|
const { orders: fresh } = await res.json()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import { Plus } from 'lucide-react'
|
|||||||
|
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
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>
|
type UserFormValues = z.infer<typeof userSchema>
|
||||||
@@ -43,12 +44,19 @@ export function UserDialog() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
role: 'partner',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: UserFormValues) {
|
async function onSubmit(values: UserFormValues) {
|
||||||
try {
|
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)
|
setOpen(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
router.refresh()
|
router.refresh()
|
||||||
@@ -68,7 +76,7 @@ export function UserDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||||
<DialogDescription className="text-slate-300">
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -87,6 +95,25 @@ export function UserDialog() {
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
<TableRow className="border-white/10 hover:bg-white/5">
|
||||||
<TableHead className="text-slate-200">E-Mail</TableHead>
|
<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">Status</TableHead>
|
||||||
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
|
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
|
||||||
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||||
@@ -69,6 +70,11 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
{user.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
|
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
|
||||||
{isBanned ? "Gesperrt" : "Aktiv"}
|
{isBanned ? "Gesperrt" : "Aktiv"}
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export async function AuthButton() {
|
|||||||
<Button asChild size="sm" variant={"outline"}>
|
<Button asChild size="sm" variant={"outline"}>
|
||||||
<Link href="/auth/login">Sign in</Link>
|
<Link href="/auth/login">Sign in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant={"default"}>
|
|
||||||
<Link href="/auth/sign-up">Sign up</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { resetPassword } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -26,16 +26,11 @@ export function ForgotPasswordForm({
|
|||||||
|
|
||||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration
|
await resetPassword(email);
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: `${window.location.origin}/auth/update-password`,
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
|||||||
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,7 +94,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -123,38 +130,58 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.label}>Bestelldetails</Text>
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
<Text>Bestellnummer: {order.id}</Text>
|
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
||||||
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.table}>
|
<View style={styles.table}>
|
||||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
|
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{items.map((item: any, idx: number) => (
|
||||||
|
<View key={idx}>
|
||||||
<View style={styles.tableRow}>
|
<View style={styles.tableRow}>
|
||||||
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
|
<View style={styles.tableCol}>
|
||||||
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
|
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
{modules.map((mod: any) => (
|
<Text>
|
||||||
<View key={mod.id} style={styles.tableRow}>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||||
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
|
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||||
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
|
</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>
|
</View>
|
||||||
|
|
||||||
<View style={styles.total}>
|
<View style={styles.total}>
|
||||||
<View style={{ width: '40%' }}>
|
<View style={{ width: '50%' }}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text>Zwischensumme:</Text>
|
<Text>Netto-Zwischensumme:</Text>
|
||||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
|
<Text>zzgl. {taxRate}% USt:</Text>
|
||||||
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</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>
|
||||||
</View>
|
</View>
|
||||||
@@ -166,3 +193,4 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signIn } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
@@ -24,22 +24,33 @@ export function LoginForm({
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [messageParam, setMessageParam] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageParam(new URLSearchParams(window.location.search).get('message'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const res = await signIn(email, password);
|
||||||
email,
|
if (!res.success) {
|
||||||
password,
|
setError(res.error || "An error occurred");
|
||||||
});
|
return;
|
||||||
if (error) throw error;
|
}
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
if (nextParam) {
|
||||||
router.push("/protected");
|
router.push(nextParam);
|
||||||
|
} else if (res.role === "admin") {
|
||||||
|
router.push("/admin/einstellungen");
|
||||||
|
} else {
|
||||||
|
router.push("/my-customers");
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,20 +99,16 @@ export function LoginForm({
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{messageParam === "concurrent" && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? "Logging in..." : "Login"}
|
{isLoading ? "Logging in..." : "Login"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signOut } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -8,9 +8,9 @@ export function LogoutButton() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
const supabase = createClient();
|
await signOut();
|
||||||
await supabase.auth.signOut();
|
router.push("/");
|
||||||
router.push("/auth/login");
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Button onClick={logout}>Logout</Button>;
|
return <Button onClick={logout}>Logout</Button>;
|
||||||
|
|||||||
@@ -48,12 +48,18 @@ function toggleModuleInList(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const isSelected = currentIds.includes(toggleId)
|
const isSelected = currentIds.includes(toggleId)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const next = currentIds.filter(id => id !== toggleId)
|
// Recursive removal: repeatedly filter until no more dependent modules are removed
|
||||||
// cascade: remove modules that required this one
|
let next = currentIds.filter(id => id !== toggleId)
|
||||||
return next.filter(mId => {
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
const beforeLength = next.length
|
||||||
|
next = next.filter(mId => {
|
||||||
const m = modules.find(mod => mod.id === mId)
|
const m = modules.find(mod => mod.id === mId)
|
||||||
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
|
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId))
|
||||||
}).filter(id => id !== toggleId)
|
})
|
||||||
|
changed = next.length !== beforeLength
|
||||||
|
}
|
||||||
|
return next
|
||||||
} else {
|
} else {
|
||||||
const module = modules.find(m => m.id === toggleId)
|
const module = modules.find(m => m.id === toggleId)
|
||||||
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
|
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
|
||||||
@@ -118,20 +124,29 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Total price across all selections
|
// Total price across all selections
|
||||||
const totalPrice = useMemo(() => {
|
// Total price calculations (split by billing interval)
|
||||||
let total = 0
|
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||||
|
let monthly = 0
|
||||||
|
let oneTime = 0
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
if (!sel?.productId) continue
|
if (!sel?.productId) continue
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
const prod = products.find(p => p.id === sel.productId)
|
||||||
if (!prod) continue
|
if (!prod) continue
|
||||||
total += Number(prod.base_price)
|
const base = Number(prod.base_price)
|
||||||
|
let modulesSum = 0
|
||||||
sel.moduleIds.forEach(mId => {
|
sel.moduleIds.forEach(mId => {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
if (mod) total += Number(mod.price)
|
if (mod) modulesSum += Number(mod.price)
|
||||||
})
|
})
|
||||||
|
const totalItem = base + modulesSum
|
||||||
|
if (prod.billing_interval === 'one_time') {
|
||||||
|
oneTime += totalItem
|
||||||
|
} else {
|
||||||
|
monthly += totalItem
|
||||||
}
|
}
|
||||||
return total
|
}
|
||||||
|
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
|
||||||
}, [selections, products, categories])
|
}, [selections, products, categories])
|
||||||
|
|
||||||
// Helper: update product selection for a category (resets modules)
|
// Helper: update product selection for a category (resets modules)
|
||||||
@@ -425,12 +440,38 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
|
{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">
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<span>Gesamt:</span>
|
<span>Gesamt:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{!allCategoriesFilled && (
|
{!allCategoriesFilled && (
|
||||||
<p className="text-xs text-destructive flex items-center gap-1">
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
@@ -669,15 +710,43 @@ export function OrderWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
<span>Gesamtbetrag:</span>
|
<div className="space-y-2 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : oneTimeTotal > 0 ? (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 italic">
|
<p className="text-xs text-slate-500 italic">
|
||||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
|
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||||
Datenschutzerklärung.
|
<a href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</a>.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signUp } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -29,7 +29,6 @@ export function SignUpForm({
|
|||||||
|
|
||||||
const handleSignUp = async (e: React.FormEvent) => {
|
const handleSignUp = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -40,14 +39,7 @@ export function SignUpForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.signUp({
|
await signUp(email, password);
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${window.location.origin}/protected`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
router.push("/auth/sign-up-success");
|
router.push("/auth/sign-up-success");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { updatePassword } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -26,15 +26,12 @@ export function UpdatePasswordForm({
|
|||||||
|
|
||||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.updateUser({ password });
|
await updatePassword(password);
|
||||||
if (error) throw error;
|
router.push("/");
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
|
||||||
router.push("/protected");
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
100
shop/lib/actions/auth.ts
Normal file
100
shop/lib/actions/auth.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
|
export async function signIn(email: string, password: string) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = data.user?.id
|
||||||
|
if (userId) {
|
||||||
|
let { data: userData, error: userError } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('role')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (userError || !userData) {
|
||||||
|
// Fallback: create database records if missing for this authenticated user
|
||||||
|
try {
|
||||||
|
const adminSupabase = createAdminClient()
|
||||||
|
|
||||||
|
// Ensure profile exists
|
||||||
|
await adminSupabase.from('profiles').insert([{ id: userId }]).select()
|
||||||
|
|
||||||
|
// Ensure user exists
|
||||||
|
const { data: insertedUser, error: insertError } = await adminSupabase
|
||||||
|
.from('users')
|
||||||
|
.insert([{ id: userId, role: 'partner' }])
|
||||||
|
.select('role')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!insertError && insertedUser) {
|
||||||
|
userData = insertedUser
|
||||||
|
userError = null
|
||||||
|
}
|
||||||
|
} catch (adminErr) {
|
||||||
|
console.error("Failed to backfill user records:", adminErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
return { success: false, 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' }
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("SignIn error:", err)
|
||||||
|
return { success: false, error: err?.message || "Ein unerwarteter Fehler ist aufgetreten." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
'use server'
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import type { EndCustomer } from '@/lib/types'
|
import type { EndCustomer } from '@/lib/types'
|
||||||
|
|
||||||
@@ -143,3 +144,15 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
|||||||
revalidatePath('/my-customers')
|
revalidatePath('/my-customers')
|
||||||
revalidatePath(`/my-customers/${id}`)
|
revalidatePath(`/my-customers/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPartnerEndCustomers(partnerId: string): Promise<EndCustomer[]> {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.select('*')
|
||||||
|
.eq('partner_id', partnerId)
|
||||||
|
.order('company_name', { ascending: true })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data as EndCustomer[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||||
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||||
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
|
|
||||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
|
|||||||
*/
|
*/
|
||||||
export async function submitOrder(params: {
|
export async function submitOrder(params: {
|
||||||
selections: WizardSelections
|
selections: WizardSelections
|
||||||
products: Product[]
|
products?: Product[]
|
||||||
categories: Category[]
|
categories?: Category[]
|
||||||
customerProfile: Partial<Profile>
|
customerProfile: Partial<Profile>
|
||||||
endCustomerId?: string | null
|
endCustomerId?: string | null
|
||||||
endCustomer?: EndCustomer | null
|
endCustomer?: EndCustomer | null
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
|
const { selections, customerProfile, endCustomerId, endCustomer } = params
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) throw new Error('Not authenticated')
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
// Fetch catalog directly from DB to prevent client tampering
|
||||||
|
const dbProducts = await getProducts()
|
||||||
|
const dbCategories = await getCategories()
|
||||||
|
|
||||||
|
// ─── Constraint Validation ──────────────────────────────────────────────────
|
||||||
|
for (const cat of dbCategories) {
|
||||||
|
const sel = selections[cat.id]
|
||||||
|
if (cat.is_required && (!sel || !sel.productId)) {
|
||||||
|
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
||||||
|
}
|
||||||
|
if (sel?.productId) {
|
||||||
|
const prod = dbProducts.find(p => p.id === sel.productId)
|
||||||
|
if (!prod) {
|
||||||
|
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate selected modules
|
||||||
|
for (const mId of sel.moduleIds) {
|
||||||
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
|
if (!mod) {
|
||||||
|
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
||||||
|
}
|
||||||
|
// Requirements check
|
||||||
|
if (mod.requirements && mod.requirements.length > 0) {
|
||||||
|
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exclusions check
|
||||||
|
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||||
|
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||||
|
if (conflicting.length > 0) {
|
||||||
|
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Snapshots aufbauen
|
// 1. Snapshots aufbauen
|
||||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
||||||
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
|
||||||
|
|
||||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||||
@@ -144,7 +185,7 @@ export async function submitOrder(params: {
|
|||||||
console.error('PDF Generation Error:', pdfError)
|
console.error('PDF Generation Error:', pdfError)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/protected')
|
revalidatePath('/my-customers')
|
||||||
revalidatePath('/my-orders')
|
revalidatePath('/my-orders')
|
||||||
return order as Order
|
return order as Order
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { Category, Product, ProductModule } from '../types'
|
import { Category, Product, ProductModule } from '../types'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
export async function getProducts() {
|
export async function getProducts() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
|
|||||||
revalidatePath('/order')
|
revalidatePath('/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) {
|
export async function createProduct(
|
||||||
|
product: Omit<Product, 'id' | 'created_at' | 'updated_at'>,
|
||||||
|
modules: Omit<ProductModule, 'product_id' | 'created_at'>[]
|
||||||
|
) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
|
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
|
||||||
@@ -77,7 +81,15 @@ export async function createProduct(product: Omit<Product, 'id' | 'created_at' |
|
|||||||
if (productError) throw productError
|
if (productError) throw productError
|
||||||
|
|
||||||
if (modules.length > 0) {
|
if (modules.length > 0) {
|
||||||
const modulesWithId = modules.map(m => ({ ...m, product_id: newProduct.id }))
|
const modulesWithId = modules.map(m => ({
|
||||||
|
id: m.id || randomUUID(),
|
||||||
|
product_id: newProduct.id,
|
||||||
|
name: m.name,
|
||||||
|
description: m.description || null,
|
||||||
|
price: m.price,
|
||||||
|
requirements: m.requirements || [],
|
||||||
|
exclusions: m.exclusions || [],
|
||||||
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
.insert(modulesWithId)
|
.insert(modulesWithId)
|
||||||
@@ -106,12 +118,13 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
|
|
||||||
if (modules.length > 0) {
|
if (modules.length > 0) {
|
||||||
const modulesWithId = modules.map(m => ({
|
const modulesWithId = modules.map(m => ({
|
||||||
|
id: m.id || randomUUID(),
|
||||||
|
product_id: id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || null,
|
description: m.description || null,
|
||||||
price: m.price,
|
price: m.price,
|
||||||
requirements: m.requirements || [],
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions || [],
|
exclusions: m.exclusions || [],
|
||||||
product_id: id
|
|
||||||
}))
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
@@ -120,6 +133,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
if (modulesError) throw modulesError
|
if (modulesError) throw modulesError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
revalidatePath('/admin/products')
|
revalidatePath('/admin/products')
|
||||||
revalidatePath('/order')
|
revalidatePath('/order')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,25 @@ export async function getUsers() {
|
|||||||
const admin = createAdminClient()
|
const admin = createAdminClient()
|
||||||
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
||||||
if (error) throw error
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(data: { email: string; password?: string; email_confirm?: boolean }) {
|
return users.map(u => ({
|
||||||
|
...u,
|
||||||
|
role: roleMap[u.id] || 'partner'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: { email: string; password?: string; role?: 'admin' | 'partner'; email_confirm?: boolean }) {
|
||||||
const admin = createAdminClient()
|
const admin = createAdminClient()
|
||||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
const { data: { user }, error } = await admin.auth.admin.createUser({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -18,6 +33,16 @@ export async function createUser(data: { email: string; password?: string; email
|
|||||||
email_confirm: data.email_confirm ?? true,
|
email_confirm: data.email_confirm ?? true,
|
||||||
})
|
})
|
||||||
if (error) throw error
|
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')
|
revalidatePath('/admin/users')
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
@@ -48,3 +73,18 @@ export async function resetPassword(email: string) {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
|
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPartners() {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
const { data: dbUsers, error: dbError } = await admin
|
||||||
|
.from('users')
|
||||||
|
.select('id, role')
|
||||||
|
.eq('role', 'partner')
|
||||||
|
if (dbError) throw dbError
|
||||||
|
|
||||||
|
const partnerIds = new Set(dbUsers.map(u => u.id))
|
||||||
|
return users.filter(u => partnerIds.has(u.id))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { createClient } from '@supabase/supabase-js'
|
|||||||
|
|
||||||
export function createAdminClient() {
|
export function createAdminClient() {
|
||||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
|
||||||
return createClient(
|
return createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
supabaseUrl!,
|
||||||
serviceRoleKey,
|
serviceRoleKey,
|
||||||
{
|
{
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { createBrowserClient } from "@supabase/ssr";
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import { resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
return createBrowserClient(
|
return createBrowserClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
|
{
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { hasEnvVars } from "../utils";
|
import { hasEnvVars, resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
export async function updateSession(request: NextRequest) {
|
export async function updateSession(request: NextRequest) {
|
||||||
let supabaseResponse = NextResponse.next({
|
let supabaseResponse = NextResponse.next({
|
||||||
@@ -13,11 +13,12 @@ export async function updateSession(request: NextRequest) {
|
|||||||
return supabaseResponse;
|
return supabaseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// With Fluid compute, don't put this client in a global environment
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
// variable. Always create a new one on each request.
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const supabase = createServerClient(
|
const supabase = createServerClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
@@ -35,6 +36,9 @@ export async function updateSession(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Especially important if using Fluid compute: Don't put this client in a
|
* Especially important if using Fluid compute: Don't put this client in a
|
||||||
@@ -9,9 +10,12 @@ import { cookies } from "next/headers";
|
|||||||
export async function createClient() {
|
export async function createClient() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
return createServerClient(
|
return createServerClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
@@ -29,6 +33,9 @@ export async function createClient() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,39 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
|
|
||||||
// This check can be removed, it is just for tutorial purposes
|
// This check can be removed, it is just for tutorial purposes
|
||||||
export const hasEnvVars =
|
export const hasEnvVars =
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL) &&
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
(process.env.SUPABASE_ANON_KEY ||
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
|
||||||
|
|
||||||
|
export function resolveSupabaseUrl(url: string | undefined): string | undefined {
|
||||||
|
if (!url) return url;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.hostname === 'supabase-kong' || parsed.hostname === 'kong') {
|
||||||
|
parsed.hostname = window.location.hostname;
|
||||||
|
parsed.protocol = window.location.protocol;
|
||||||
|
parsed.port = ''; // Port entfernen, da der öffentliche Zugriff über Port 80/443 läuft
|
||||||
|
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:8000', `//${window.location.hostname}`)
|
||||||
|
.replace('//kong:8000', `//${window.location.hostname}`)
|
||||||
|
.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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
cacheComponents: false,
|
cacheComponents: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
167
shop/package-lock.json
generated
167
shop/package-lock.json
generated
@@ -19,12 +19,15 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@supabase/ssr": "latest",
|
"@supabase/ssr": "latest",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/supabase-js": "latest",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -3174,6 +3177,15 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -6895,6 +6907,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -7166,6 +7187,95 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -7407,6 +7517,45 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -8178,6 +8327,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -9101,6 +9259,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -21,12 +21,15 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@supabase/ssr": "latest",
|
"@supabase/ssr": "latest",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/supabase-js": "latest",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -16,13 +16,4 @@ ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
|
|||||||
-- Policies
|
-- Policies
|
||||||
CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true);
|
CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true);
|
||||||
|
|
||||||
-- Seed Categories
|
|
||||||
INSERT INTO public.categories (id, name, description, icon)
|
|
||||||
VALUES
|
|
||||||
('c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Cloud Software', 'Modulare Cloud-Lösungen für den modernen Handel.', 'Cloud'),
|
|
||||||
('c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'Gastronomie', 'Kassensysteme und Management für Restaurants und Cafés.', 'Utensils'),
|
|
||||||
('c3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'Hardware', 'Kassenterminals, Drucker und Zubehör.', 'HardDrive');
|
|
||||||
|
|
||||||
-- Update existing products with categories
|
|
||||||
UPDATE public.products SET category_id = 'c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1' WHERE name LIKE '%Cloud%';
|
|
||||||
UPDATE public.products SET category_id = 'c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2' WHERE name LIKE '%Gastro%';
|
|
||||||
|
|||||||
@@ -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