Compare commits
47 Commits
develop
...
dd2c3859e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,39 @@ jobs:
|
|||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Staging-Build generieren
|
- name: Staging-Build generieren
|
||||||
run: npm run build -- --configuration=staging
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
# KORREKTUR: Wir brennen die interne Docker-Netzwerk-URL fest ein!
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }}
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Docker login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# KORREKTUR: Wir übergeben die INTERNE Docker-URL auch an das Docker-Image!
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_URL="http://supabase-kong:8000" \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${{ secrets.STAGING_SUPABASE_ANON_KEY }}" \
|
||||||
|
-t gitea.hephex.de/daniel/webshop:staging -f Dockerfile .
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# KORREKTUR: Auch der Push wird vom Hauptordner aus gestartet
|
||||||
|
- name: Push Docker image
|
||||||
|
run: docker push gitea.hephex.de/daniel/webshop:staging
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
|
||||||
|
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
|
||||||
|
- name: Deploy to Proxmox LXC via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: 192.168.178.132
|
||||||
|
username: ${{ secrets.PROXMOX_USER }}
|
||||||
|
key: ${{ secrets.PROXMOX_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/webshop/supabase/supabase-project
|
||||||
|
docker compose pull webshop
|
||||||
|
docker compose up -d webshop
|
||||||
|
|||||||
51
Dockerfile
Normal file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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' });
|
||||||
|
}
|
||||||
5
shop/app/login/page.tsx
Normal file
5
shop/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<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}
|
||||||
|
|||||||
@@ -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,46 @@
|
|||||||
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="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark: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">
|
{/* Demo Banner */}
|
||||||
|
<div 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.
|
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
||||||
</div>
|
</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="#">
|
<Navbar />
|
||||||
<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">
|
<main className="flex-1">
|
||||||
|
{/* Hero Section: Zielgerichtet auf Lizenzen */}
|
||||||
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
|
<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 & Add-ons</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="/contact">
|
||||||
<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,38 +48,69 @@ 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>
|
</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="#">Datenschutz</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
49
shop/components/Navbar.tsx
Normal file
49
shop/components/Navbar.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.getUser();
|
||||||
|
user = data.user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Abrufen des Users in Navbar:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NavbarClient user={user} />;
|
||||||
|
}
|
||||||
217
shop/components/NavbarClient.tsx
Normal file
217
shop/components/NavbarClient.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
interface NavbarClientProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarClient({ user }: NavbarClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Supabase URL & Anon Key auslesen (für Browser-Client)
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Supabase URL Auflösung inline
|
||||||
|
let resolvedUrl = supabaseUrl;
|
||||||
|
if (resolvedUrl && typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(resolvedUrl);
|
||||||
|
if (parsed.hostname === "supabase-kong" || parsed.hostname === "kong") {
|
||||||
|
parsed.hostname = window.location.hostname;
|
||||||
|
resolvedUrl = parsed.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resolvedUrl = resolvedUrl
|
||||||
|
.replace("//supabase-kong", `//${window.location.hostname}`)
|
||||||
|
.replace("//kong", `//${window.location.hostname}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser Client inline erstellen
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
resolvedUrl!,
|
||||||
|
supabaseAnonKey!,
|
||||||
|
{
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auf Auth-Statusänderungen reagieren
|
||||||
|
useEffect(() => {
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
setCurrentUser(session?.user ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||||
|
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||||
|
<span className="font-bold text-xl tracking-tighter text-white">CASPOS Store</span>
|
||||||
|
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||||
|
Demo
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex gap-6 items-center">
|
||||||
|
<Link className="text-sm font-medium text-slate-300 hover:text-primary transition-colors" href="/order">
|
||||||
|
Bestellen
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Account Kunden
|
||||||
|
</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="/login">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>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/my-customers"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Account Kunden
|
||||||
|
</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="/login">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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -28,16 +28,11 @@ export function LoginForm({
|
|||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
await signIn(email, password);
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||||
router.push("/protected");
|
router.push("/protected");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signOut } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -8,8 +8,7 @@ export function LogoutButton() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
const supabase = createClient();
|
await signOut();
|
||||||
await supabase.auth.signOut();
|
|
||||||
router.push("/auth/login");
|
router.push("/auth/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +710,38 @@ 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.
|
Datenschutzerklärung.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,13 +26,11 @@ 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;
|
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||||
router.push("/protected");
|
router.push("/protected");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
62
shop/lib/actions/auth.ts
Normal file
62
shop/lib/actions/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
|
export async function signIn(email: string, password: string) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUp(email: string, password: string) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const origin = (await headers()).get('origin') || ''
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
emailRedirectTo: `${origin}/protected`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 @@ 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;
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
.replace('//supabase-kong', `//${window.location.hostname}`)
|
||||||
|
.replace('//kong', `//${window.location.hostname}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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';
|
||||||
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