test preview
This commit is contained in:
15
.env
Normal file
15
.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Stripe Keys - Get from https://dashboard.stripe.com/apikeys
|
||||||
|
# Use TEST keys for development (pk_test_... and sk_test_...)
|
||||||
|
STRIPE_SECRET_KEY=sk_test_51SmzgcBONHRzgId9uiPW4houDfhY0nNQ6ECjSRFLNXEOi8YtMwxNezFA6e4MUQypZ3Sjo5AwvvTmkqLsNDcDX4E400XPoW9XCu
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SmzgcBONHRzgId9Da7vSODAkMfJvLJ2okPpLnumV6WY3IMizmSSeHjQtYwmyHrexzZBU76el1MJIj2gw8vVBW2o00GiqfDeEA
|
||||||
|
|
||||||
|
# Webhook Secret - Get from Stripe Dashboard > Webhooks
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE
|
||||||
|
|
||||||
|
# URLs
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Stripe Configuration
|
||||||
|
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
|
||||||
|
|
||||||
|
# Backend URL (for production)
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
225
KONZEPT.md
Normal file
225
KONZEPT.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# webklar - Konzept & Spezifikation
|
||||||
|
|
||||||
|
> Full-Stack Digitalagentur mit produktisiertem Website-Service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geschäftsmodell
|
||||||
|
|
||||||
|
### Hauptprodukt: Website in 48h
|
||||||
|
|
||||||
|
| Detail | Wert |
|
||||||
|
|--------|------|
|
||||||
|
| **Preis** | 199€ (Festpreis) |
|
||||||
|
| **Lieferzeit** | 48 Stunden |
|
||||||
|
| **Zahlung** | 100% Vorkasse (Online) |
|
||||||
|
| **Technologie** | AI-gestützt |
|
||||||
|
| **Änderungen** | X Runden inklusive |
|
||||||
|
|
||||||
|
### Optionales Hosting (Hybrid-Modell)
|
||||||
|
|
||||||
|
| Detail | Wert |
|
||||||
|
|--------|------|
|
||||||
|
| **Preis** | 30-100€/Monat |
|
||||||
|
| **Anbieter** | Hetzner (Managed) |
|
||||||
|
| **Status** | Optional - Kunde kann auch selbst hosten |
|
||||||
|
| **Enthält** | Server, Updates, Backups |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Website-Konfigurator
|
||||||
|
|
||||||
|
### Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┬──────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ FORMULAR │ LIVE-PREVIEW │
|
||||||
|
│ │ │
|
||||||
|
│ 1. Typ wählen │ [Website ändert │
|
||||||
|
│ 2. Stil wählen │ sich in │
|
||||||
|
│ 3. Farben/Logo │ Echtzeit] │
|
||||||
|
│ 4. Hell/Dunkel │ │
|
||||||
|
│ │ │
|
||||||
|
│ [💳 199€ kaufen] │ │
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 1: Website-Typ (6 Optionen)
|
||||||
|
|
||||||
|
| Typ | Icon | Beschreibung |
|
||||||
|
|-----|------|--------------|
|
||||||
|
| **eCommerce** | 🛒 | Online-Shop |
|
||||||
|
| **Scheduling** | 📅 | Terminbuchung (Ärzte, Friseure, etc.) |
|
||||||
|
| **Portfolio** | 🎨 | Kreative, Freelancer |
|
||||||
|
| **Blog** | 📝 | Content-Creator, Blogger |
|
||||||
|
| **Online courses** | 🎓 | Kurse verkaufen |
|
||||||
|
| **Events** | 🎉 | Veranstaltungen, Tickets |
|
||||||
|
|
||||||
|
### Schritt 2: Design-Stil (5 Optionen)
|
||||||
|
|
||||||
|
| Stil | Beschreibung |
|
||||||
|
|------|--------------|
|
||||||
|
| **Modern / Clean** | Zeitlos, professionell |
|
||||||
|
| **Bold / Auffällig** | Hebt sich ab, Marketing |
|
||||||
|
| **Elegant / Luxus** | Premium, hochwertig |
|
||||||
|
| **Minimalistisch** | Reduziert, viel Weißraum |
|
||||||
|
| **Custom** | Eigene Vorstellung |
|
||||||
|
|
||||||
|
#### Custom-Optionen (wenn gewählt):
|
||||||
|
|
||||||
|
- 🔗 **Referenz-Link** - URL einer Website als Inspiration
|
||||||
|
- 📝 **Beschreibung** - Freitext
|
||||||
|
- 🖼️ **Bild hochladen** - Screenshot, Moodboard, etc.
|
||||||
|
|
||||||
|
### Schritt 3: Farben
|
||||||
|
|
||||||
|
#### Option A: Logo hochladen
|
||||||
|
```
|
||||||
|
[Logo hochladen]
|
||||||
|
↓
|
||||||
|
Farben werden automatisch extrahiert
|
||||||
|
↓
|
||||||
|
[Hell ○ / Dunkel ○] ← Kunde kann überschreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Farben selbst wählen
|
||||||
|
```
|
||||||
|
[Farbe 1] ← RGB Picker (Primärfarbe) - Pflicht
|
||||||
|
[Farbe 2] ← RGB Picker (Sekundärfarbe) - Optional
|
||||||
|
[Farbe 3] ← RGB Picker (Akzentfarbe) - Optional
|
||||||
|
|
||||||
|
Max. 3 Farben
|
||||||
|
↓
|
||||||
|
[Hell ○ / Dunkel ○]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 4: Bezahlen
|
||||||
|
|
||||||
|
- Online-Zahlung (Stripe/PayPal)
|
||||||
|
- 100% Vorkasse
|
||||||
|
- Sofortige Bestätigung
|
||||||
|
- Lieferung in 48h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positionierung
|
||||||
|
|
||||||
|
### USP (Unique Selling Points)
|
||||||
|
|
||||||
|
1. **Schnell** - 48h Lieferzeit
|
||||||
|
2. **100% individuell** - Keine Templates
|
||||||
|
3. **AI-gestützt** - Effizient & modern
|
||||||
|
4. **Festpreis** - Keine versteckten Kosten
|
||||||
|
|
||||||
|
### Konkurrenz
|
||||||
|
|
||||||
|
Kunden vergleichen mit:
|
||||||
|
- Wix
|
||||||
|
- Squarespace
|
||||||
|
- Jimdo
|
||||||
|
- Shopify
|
||||||
|
|
||||||
|
### Hauptargument gegen Baukästen
|
||||||
|
|
||||||
|
> "100% individuelles Design – keine Template-Grenzen"
|
||||||
|
|
||||||
|
### Zielgruppe
|
||||||
|
|
||||||
|
- Alle (flexibel)
|
||||||
|
- Hauptsächlich: Kleine Unternehmen, Selbstständige, Startups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
### Tonfall
|
||||||
|
|
||||||
|
**Locker & unkompliziert** - aber professionell
|
||||||
|
|
||||||
|
### Slogan
|
||||||
|
|
||||||
|
> "webklar – das Web maßgeschneidert auf Ihr Unternehmen"
|
||||||
|
|
||||||
|
### Untertitel
|
||||||
|
|
||||||
|
> "Wir übernehmen die Technik, Sie konzentrieren sich aufs Geschäft"
|
||||||
|
|
||||||
|
### Farbschema
|
||||||
|
|
||||||
|
| Farbe | Hex | Verwendung |
|
||||||
|
|-------|-----|------------|
|
||||||
|
| Primary | `#0A400C` | Buttons, Akzente (Dunkelgrün) |
|
||||||
|
| Secondary | `#819067` | Hover-States, Sekundärtexte (Mittelgrün) |
|
||||||
|
| Tertiary | `#B1AB86` | Dekorative Elemente (Hellgrün-Beige) |
|
||||||
|
| Background | `#FEFAE0` | Seitenhintergrund (Creme) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Framework**: Vite + React
|
||||||
|
- **Sprache**: TypeScript
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Ziel**: Ressourcensparend
|
||||||
|
|
||||||
|
### Hosting (für Kunden)
|
||||||
|
|
||||||
|
- **Anbieter**: Hetzner
|
||||||
|
- **Verwaltung**: Komplett durch webklar (Kunde sieht nur "webklar")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
### Phase 1: Landing Page ✅
|
||||||
|
- [x] Projekt-Setup (Vite + TypeScript + Tailwind)
|
||||||
|
- [x] Farbschema konfigurieren
|
||||||
|
- [x] Logo-Komponente erstellen
|
||||||
|
- [x] Header/Navigation
|
||||||
|
- [x] Hero-Section (Basis)
|
||||||
|
- [x] Hero-Section updaten mit neuem Messaging
|
||||||
|
- [x] Service-Tags hinzufügen (Website | Backend | Hosting | Support)
|
||||||
|
|
||||||
|
### Phase 2: Konfigurator ✅
|
||||||
|
- [x] Konfigurator-Layout (Split: Formular + Live-Preview)
|
||||||
|
- [x] Schritt 1: Website-Typ Auswahl (6 Optionen)
|
||||||
|
- [x] Schritt 2: Stil-Auswahl (5 Optionen)
|
||||||
|
- [x] Custom-Option (Referenz-Link, Beschreibung, Bild-Upload)
|
||||||
|
- [x] Schritt 3: Farbauswahl
|
||||||
|
- [x] Logo-Upload mit automatischer Farb-Extraktion
|
||||||
|
- [x] RGB Color Picker (max. 3 Farben)
|
||||||
|
- [x] Hell/Dunkel Toggle
|
||||||
|
- [x] Live-Preview Komponente
|
||||||
|
- [x] Preview reagiert auf alle Auswahlen in Echtzeit
|
||||||
|
|
||||||
|
### Phase 3: Checkout & Payment
|
||||||
|
- [ ] Stripe-Integration
|
||||||
|
- [ ] Checkout-Flow
|
||||||
|
- [ ] Bestätigungs-E-Mail
|
||||||
|
- [ ] Auftrags-Dashboard (Admin)
|
||||||
|
|
||||||
|
### Phase 4: Backend
|
||||||
|
- [ ] Auftragsverarbeitung
|
||||||
|
- [ ] Kunden-Datenbank
|
||||||
|
- [ ] AI-Integration für Website-Generierung
|
||||||
|
- [ ] Delivery-System (48h Timer)
|
||||||
|
|
||||||
|
### Phase 5: Hosting-Addon
|
||||||
|
- [ ] Hetzner-Integration
|
||||||
|
- [ ] Server-Provisioning
|
||||||
|
- [ ] Domain-Verwaltung
|
||||||
|
- [ ] Monatliche Abrechnung
|
||||||
|
|
||||||
|
### Phase 6: Polish & UX
|
||||||
|
- [ ] Dark Mode (automatisch nach System-Einstellung via `prefers-color-scheme`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt am: 06.01.2026*
|
||||||
|
*Version: 1.0*
|
||||||
|
*Zuletzt aktualisiert: 06.01.2026*
|
||||||
|
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="webklar – das Web maßgeschneidert auf Ihr Unternehmen. Professionelle Webentwicklung für Ihren digitalen Erfolg." />
|
||||||
|
<title>webklar – Webentwicklung</title>
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4959
package-lock.json
generated
Normal file
4959
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "webklar",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@stripe/stripe-js": "^8.6.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"stripe": "^20.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
public/logo.svg
Normal file
40
public/logo.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left V-shape (lighter green) -->
|
||||||
|
<path d="M70 120 L70 280 L165 375 L165 320 L110 265 L110 120 Z" fill="url(#gradient-left)"/>
|
||||||
|
|
||||||
|
<!-- Center chevron (medium green) -->
|
||||||
|
<path d="M165 215 L250 130 L335 215 L335 270 L250 185 L165 270 Z" fill="url(#gradient-center)"/>
|
||||||
|
|
||||||
|
<!-- Right V-shape (darker green) -->
|
||||||
|
<path d="M430 120 L430 280 L335 375 L335 320 L390 265 L390 120 Z" fill="url(#gradient-right)"/>
|
||||||
|
|
||||||
|
<!-- Bottom connection left -->
|
||||||
|
<path d="M165 320 L165 375 L250 460 L250 405 Z" fill="url(#gradient-bottom-left)"/>
|
||||||
|
|
||||||
|
<!-- Bottom connection right -->
|
||||||
|
<path d="M335 320 L335 375 L250 460 L250 405 Z" fill="url(#gradient-bottom-right)"/>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9AA67F"/>
|
||||||
|
<stop offset="100%" stop-color="#0F5010"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-center" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0F5010"/>
|
||||||
|
<stop offset="100%" stop-color="#0A400C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0A400C"/>
|
||||||
|
<stop offset="100%" stop-color="#052006"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0F5010"/>
|
||||||
|
<stop offset="100%" stop-color="#0A400C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0A400C"/>
|
||||||
|
<stop offset="100%" stop-color="#052006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
146
server/index.js
Normal file
146
server/index.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Initialize Stripe
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session
|
||||||
|
app.post('/api/checkout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderData } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!orderData?.contact?.email || !orderData?.contact?.name) {
|
||||||
|
return res.status(400).json({ error: 'Name and email are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
mode: 'payment',
|
||||||
|
customer_email: orderData.contact.email,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: 'eur',
|
||||||
|
product_data: {
|
||||||
|
name: 'Website in 48h',
|
||||||
|
description: `${orderData.websiteType} | ${orderData.style} | ${orderData.theme === 'dark' ? 'Dunkel' : 'Hell'}`,
|
||||||
|
images: ['https://webklar.de/og-image.png'], // Optional
|
||||||
|
},
|
||||||
|
unit_amount: 19900, // 199€ in cents
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
customerName: orderData.contact.name,
|
||||||
|
customerCompany: orderData.contact.company || '',
|
||||||
|
customerPhone: orderData.contact.phone || '',
|
||||||
|
websiteType: orderData.websiteType,
|
||||||
|
style: orderData.style,
|
||||||
|
theme: orderData.theme,
|
||||||
|
colorPrimary: orderData.colors?.primary || '#0A400C',
|
||||||
|
colorSecondary: orderData.colors?.secondary || '#819067',
|
||||||
|
colorAccent: orderData.colors?.accent || '#B1AB86',
|
||||||
|
customReferenceUrl: orderData.customInput?.referenceUrl || '',
|
||||||
|
customDescription: orderData.customInput?.description || '',
|
||||||
|
},
|
||||||
|
success_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/konfigurator`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sessionId: session.id,
|
||||||
|
url: session.url
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stripe error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to create checkout session',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify session (for success page)
|
||||||
|
app.get('/api/session/:sessionId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(req.params.sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: session.id,
|
||||||
|
status: session.payment_status,
|
||||||
|
customerEmail: session.customer_email,
|
||||||
|
amountTotal: session.amount_total,
|
||||||
|
metadata: session.metadata,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Session verification error:', error);
|
||||||
|
res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe Webhook (for production)
|
||||||
|
app.post('/api/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
||||||
|
const sig = req.headers['stripe-signature'];
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
console.warn('Webhook secret not configured');
|
||||||
|
return res.status(400).send('Webhook secret not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
const session = event.data.object;
|
||||||
|
console.log('✅ Payment successful:', session.id);
|
||||||
|
// TODO: Save order to database, send confirmation email, etc.
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'payment_intent.payment_failed':
|
||||||
|
console.log('❌ Payment failed:', event.data.object.id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error.message);
|
||||||
|
res.status(400).send(`Webhook Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`\n🚀 Server running on http://localhost:${port}`);
|
||||||
|
console.log(`\n📋 Endpoints:`);
|
||||||
|
console.log(` GET /api/health`);
|
||||||
|
console.log(` POST /api/checkout`);
|
||||||
|
console.log(` GET /api/session/:sessionId`);
|
||||||
|
console.log(` POST /api/webhook\n`);
|
||||||
|
});
|
||||||
|
|
||||||
45
src/App.tsx
Normal file
45
src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Header, Hero, Configurator, SuccessPage } from './components';
|
||||||
|
|
||||||
|
type Page = 'home' | 'success';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [page, setPage] = useState<Page>('home');
|
||||||
|
|
||||||
|
// Check URL for success page
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('session_id')) {
|
||||||
|
setPage('success');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGoHome = () => {
|
||||||
|
// Clear URL params
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
setPage('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (page === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<SuccessPage onClose={handleGoHome} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<Configurator />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
319
src/components/Checkout.tsx
Normal file
319
src/components/Checkout.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ConfigState, WebsiteType, StyleType } from './Configurator';
|
||||||
|
import { redirectToCheckout, STRIPE_CONFIG } from '../lib/stripe';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ConfigState;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactForm {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const websiteTypeLabels: Record<WebsiteType, string> = {
|
||||||
|
ecommerce: 'eCommerce',
|
||||||
|
scheduling: 'Terminbuchung',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
courses: 'Online Kurse',
|
||||||
|
events: 'Events',
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleLabels: Record<StyleType, string> = {
|
||||||
|
modern: 'Modern',
|
||||||
|
bold: 'Bold',
|
||||||
|
elegant: 'Elegant',
|
||||||
|
minimal: 'Minimalistisch',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Checkout({ config, onBack }: Props) {
|
||||||
|
const [contact, setContact] = useState<ContactForm>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
phone: '',
|
||||||
|
});
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if Stripe is configured
|
||||||
|
if (!STRIPE_CONFIG.publishableKey) {
|
||||||
|
throw new Error('Stripe ist noch nicht konfiguriert. Bitte Stripe-Keys in .env eintragen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await redirectToCheckout({
|
||||||
|
websiteType: config.websiteType || 'portfolio',
|
||||||
|
style: config.style || 'modern',
|
||||||
|
theme: config.theme,
|
||||||
|
colors: {
|
||||||
|
primary: config.colors.primary,
|
||||||
|
secondary: config.colors.secondary,
|
||||||
|
accent: config.colors.accent,
|
||||||
|
},
|
||||||
|
customInput: config.style === 'custom' ? {
|
||||||
|
referenceUrl: config.customInput.referenceUrl,
|
||||||
|
description: config.customInput.description,
|
||||||
|
} : undefined,
|
||||||
|
contact: {
|
||||||
|
name: contact.name,
|
||||||
|
email: contact.email,
|
||||||
|
company: contact.company || undefined,
|
||||||
|
phone: contact.phone || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Checkout error:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = contact.name && contact.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-secondary hover:text-primary transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span>Zurück zum Konfigurator</span>
|
||||||
|
</button>
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary">
|
||||||
|
Checkout
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary mt-2">Fast geschafft! Überprüfe deine Auswahl.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-800">Fehler</p>
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-5 gap-8">
|
||||||
|
{/* Left: Order Summary */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-6 shadow-lg sticky top-24">
|
||||||
|
<h3 className="font-bold text-primary text-xl mb-6">Deine Website</h3>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Typ</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.websiteType ? websiteTypeLabels[config.websiteType] : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Stil</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.style ? styleLabels[config.style] : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Theme</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.theme === 'dark' ? '🌙 Dunkel' : '☀️ Hell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Farben</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.primary }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.secondary }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.accent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Lieferzeit</span>
|
||||||
|
<span className="font-semibold text-primary">48 Stunden</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="flex justify-between py-4 mt-2">
|
||||||
|
<span className="text-xl font-bold text-primary">Gesamt</span>
|
||||||
|
<span className="text-2xl font-bold text-primary">199€</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust badges */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-tertiary/20 flex flex-wrap gap-2">
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ 100% individuell
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ Keine Templates
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ SSL-verschlüsselt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Contact Form */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-[1.5rem] p-6 shadow-lg">
|
||||||
|
<h3 className="font-bold text-primary text-xl mb-6">Kontaktdaten</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={contact.name}
|
||||||
|
onChange={(e) => setContact({ ...contact, name: e.target.value })}
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
E-Mail *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={contact.email}
|
||||||
|
onChange={(e) => setContact({ ...contact, email: e.target.value })}
|
||||||
|
placeholder="max@beispiel.de"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Unternehmen <span className="text-secondary font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={contact.company}
|
||||||
|
onChange={(e) => setContact({ ...contact, company: e.target.value })}
|
||||||
|
placeholder="Muster GmbH"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Telefon <span className="text-secondary font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={contact.phone}
|
||||||
|
onChange={(e) => setContact({ ...contact, phone: e.target.value })}
|
||||||
|
placeholder="+49 123 456789"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-6 border-t border-tertiary/20" />
|
||||||
|
|
||||||
|
{/* Payment Info */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="font-semibold text-primary mb-3">Zahlungsmethode</h4>
|
||||||
|
<div className="bg-tertiary/10 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-12 h-7 bg-[#635BFF] rounded flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-4" viewBox="0 0 60 25" fill="none">
|
||||||
|
<path d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.27 4.06-.68v2.91c-1.1.47-2.47.88-4.47.88-4.15 0-6.6-2.27-6.6-6.37 0-3.55 2.1-6.49 5.88-6.49 3.66 0 5.99 2.58 5.99 6.37v.83zm-6.3-4.77c-1.11 0-2.04.85-2.18 2.43h4.2c-.01-1.58-.89-2.43-2.02-2.43zM43.9 17.95c-.78.36-1.73.55-2.85.55-3.04 0-4.83-1.54-4.83-4.72V9.33h-1.98V6.4h1.98V3.29l4.03-.86v4h3.55v2.93h-3.55v3.9c0 1.42.76 1.94 1.64 1.94.5 0 1.04-.1 1.54-.31l.47 3.06zM30.04 18.26V6.4h4.03v11.86h-4.03zm0-13.74V1.1h4.03v3.42h-4.03zM22.7 7.05c1.1 0 1.99.18 2.68.45V4.2c-.69-.36-1.64-.62-2.94-.62-2.56 0-4.37 1.23-4.37 3.78v.97h-2.02v2.93h2.02v7.01h4.03v-7.01h2.55V8.33H22.1v-.7c0-.4.23-.58.6-.58zM11.72 7.09c1.57 0 2.68.55 3.47 1.42l-.04-1.11V1.1H19v17.16h-3.55l-.23-1.4c-.81.95-2.05 1.64-3.72 1.64-3.33 0-5.62-2.72-5.62-5.88s2.33-5.53 5.84-5.53zm.93 8.84c1.64 0 2.57-1.23 2.57-3.19s-.93-3.06-2.57-3.06c-1.51 0-2.53 1.14-2.53 3.06s1.02 3.19 2.53 3.19z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-secondary text-sm">Sichere Zahlung mit Kreditkarte</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary mt-2">
|
||||||
|
Du wirst zu Stripe weitergeleitet, um die Zahlung sicher abzuschließen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || isProcessing}
|
||||||
|
className={`w-full py-4 rounded-full font-bold text-lg transition-all flex items-center justify-center gap-2 ${
|
||||||
|
isFormValid && !isProcessing
|
||||||
|
? 'bg-primary text-white hover:bg-primary-light hover:shadow-lg'
|
||||||
|
: 'bg-tertiary/30 text-secondary cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Weiterleitung zu Stripe...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>199€ – Weiter zur Zahlung</span>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<p className="text-xs text-secondary text-center mt-4">
|
||||||
|
Mit dem Kauf akzeptierst du unsere{' '}
|
||||||
|
<a href="#" className="underline hover:text-primary">AGB</a> und{' '}
|
||||||
|
<a href="#" className="underline hover:text-primary">Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
458
src/components/Configurator.tsx
Normal file
458
src/components/Configurator.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ConfiguratorPreview } from './ConfiguratorPreview';
|
||||||
|
import { Checkout } from './Checkout';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type WebsiteType = 'ecommerce' | 'scheduling' | 'portfolio' | 'blog' | 'courses' | 'events';
|
||||||
|
export type StyleType = 'modern' | 'bold' | 'elegant' | 'minimal' | 'custom';
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ConfigState {
|
||||||
|
websiteType: WebsiteType | null;
|
||||||
|
style: StyleType | null;
|
||||||
|
customInput: {
|
||||||
|
referenceUrl: string;
|
||||||
|
description: string;
|
||||||
|
image: File | null;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
useLogoColors: boolean;
|
||||||
|
logo: File | null;
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
theme: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConfigState = {
|
||||||
|
websiteType: 'ecommerce', // Pre-selected
|
||||||
|
style: null,
|
||||||
|
customInput: {
|
||||||
|
referenceUrl: '',
|
||||||
|
description: '',
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
useLogoColors: false,
|
||||||
|
logo: null,
|
||||||
|
primary: '#0A400C',
|
||||||
|
secondary: '#819067',
|
||||||
|
accent: '#B1AB86',
|
||||||
|
},
|
||||||
|
theme: 'light',
|
||||||
|
};
|
||||||
|
|
||||||
|
const websiteTypes: { id: WebsiteType; label: string; description: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'ecommerce',
|
||||||
|
label: 'eCommerce',
|
||||||
|
description: 'Online verkaufen – Bestellungen, Versand und mehr an einem Ort verwalten.',
|
||||||
|
icon: '🛒'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scheduling',
|
||||||
|
label: 'Scheduling',
|
||||||
|
description: 'Services anbieten, Buchungen annehmen, Zahlungen erhalten und Personal verwalten.',
|
||||||
|
icon: '📅'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'portfolio',
|
||||||
|
label: 'Portfolio',
|
||||||
|
description: 'Zeige deine Arbeit und gewinne neue Kunden mit einem Online-Portfolio.',
|
||||||
|
icon: '🎨'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blog',
|
||||||
|
label: 'Blog',
|
||||||
|
description: 'Erstelle einen Blog, um deine Community zu vergrößern und mehr Traffic zu generieren.',
|
||||||
|
icon: '📝'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'courses',
|
||||||
|
label: 'Online courses',
|
||||||
|
description: 'Erstelle, bewerbe und verkaufe Kurse und Coaching-Programme.',
|
||||||
|
icon: '🎓'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
label: 'Events',
|
||||||
|
description: 'Verkaufe Tickets, verwalte RSVPs und bewerbe Online- oder Vor-Ort-Events.',
|
||||||
|
icon: '🎉'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const styleTypes: { id: StyleType; label: string; description: string }[] = [
|
||||||
|
{ id: 'modern', label: 'Modern', description: 'Clean & zeitlos' },
|
||||||
|
{ id: 'bold', label: 'Bold', description: 'Auffällig & mutig' },
|
||||||
|
{ id: 'elegant', label: 'Elegant', description: 'Premium & hochwertig' },
|
||||||
|
{ id: 'minimal', label: 'Minimalistisch', description: 'Reduziert & klar' },
|
||||||
|
{ id: 'custom', label: 'Custom', description: 'Eigene Vorstellung' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Configurator() {
|
||||||
|
const [config, setConfig] = useState<ConfigState>(initialState);
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<ConfigState>) => {
|
||||||
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const canProceed = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return config.websiteType !== null;
|
||||||
|
case 2:
|
||||||
|
return config.style !== null;
|
||||||
|
case 3:
|
||||||
|
return true; // Custom input or colors - always have defaults
|
||||||
|
case 4:
|
||||||
|
return true; // Colors always have defaults
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine max steps based on whether custom style is selected
|
||||||
|
const maxSteps = config.style === 'custom' ? 4 : 3;
|
||||||
|
const isLastStep = currentStep === maxSteps ||
|
||||||
|
(currentStep === 3 && config.style !== 'custom') ||
|
||||||
|
(currentStep === 4 && config.style === 'custom');
|
||||||
|
|
||||||
|
// Show Checkout
|
||||||
|
if (showCheckout) {
|
||||||
|
return <Checkout config={config} onBack={() => setShowCheckout(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="konfigurator" className="py-16 sm:py-24 bg-tertiary/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header - Full Width Above Both Columns */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary">
|
||||||
|
Füge alles hinzu, was dein Business braucht
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps - Dynamic based on custom style */}
|
||||||
|
<div className="flex mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Array.from({ length: maxSteps }, (_, i) => i + 1).map((step) => (
|
||||||
|
<div key={step} className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => step < currentStep && setCurrentStep(step)}
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm transition-all ${
|
||||||
|
step === currentStep
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: step < currentStep
|
||||||
|
? 'bg-primary/30 text-primary cursor-pointer hover:bg-primary/40'
|
||||||
|
: 'bg-white/60 text-secondary border border-tertiary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step < currentStep ? '✓' : step}
|
||||||
|
</button>
|
||||||
|
{step < maxSteps && (
|
||||||
|
<div
|
||||||
|
className={`w-8 sm:w-12 h-0.5 mx-1 rounded ${
|
||||||
|
step < currentStep ? 'bg-primary/40' : 'bg-tertiary/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - Split Layout */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
||||||
|
{/* Left: Form - No background, buttons float */}
|
||||||
|
<div className="min-h-[500px]">
|
||||||
|
{/* Step 1: Website Type - Fixed corner radius, only height changes */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{websiteTypes.map((type) => {
|
||||||
|
const isSelected = config.websiteType === type.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => updateConfig({ websiteType: type.id })}
|
||||||
|
className={`w-full rounded-[1.75rem] cursor-pointer overflow-hidden transition-shadow duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-white shadow-lg'
|
||||||
|
: 'bg-white/70 hover:bg-white hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-primary text-lg">{type.label}</h3>
|
||||||
|
{/* Chevron Arrow - rotates when open */}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-primary transition-transform duration-300 ${isSelected ? 'rotate-180' : ''}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="41 68 118 65"
|
||||||
|
>
|
||||||
|
<path d="M41.149 73.893 47.04 68l53.028 53.039L153.107 68 159 73.893l-58.926 58.925-58.925-58.925Z" fillRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Expandable Content - smooth height animation */}
|
||||||
|
<div
|
||||||
|
className={`grid transition-[grid-template-rows] duration-300 ease-out ${
|
||||||
|
isSelected ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-6 pb-5 pt-2">
|
||||||
|
<p className="text-secondary text-sm leading-relaxed">
|
||||||
|
{type.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Style - Floating buttons like Step 1 */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{styleTypes.map((style) => {
|
||||||
|
const isSelected = config.style === style.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={style.id}
|
||||||
|
onClick={() => updateConfig({ style: style.id })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 flex items-center justify-between transition-all duration-300 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-primary text-xl block text-left">{style.label}</span>
|
||||||
|
<span className="text-secondary text-left block">{style.description}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-6 h-6 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Custom Input (only if custom style selected) */}
|
||||||
|
{currentStep === 3 && config.style === 'custom' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-6 shadow-lg space-y-4">
|
||||||
|
<p className="font-bold text-primary text-xl">Beschreibe deine Vorstellung</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="🔗 Referenz-Link (optional)"
|
||||||
|
value={config.customInput.referenceUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, referenceUrl: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="📝 Beschreibung (optional)"
|
||||||
|
value={config.customInput.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, description: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-secondary font-medium">🖼️ Bild hochladen (optional)</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, image: e.target.files?.[0] || null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-2 block w-full text-sm text-secondary file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:bg-primary file:text-white file:font-medium hover:file:bg-primary-light file:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 or 4: Colors - Floating buttons */}
|
||||||
|
{((currentStep === 3 && config.style !== 'custom') || currentStep === 4) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Logo Upload Toggle - as floating buttons */}
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ colors: { ...config.colors, useLogoColors: true } })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer text-center ${
|
||||||
|
config.colors.useLogoColors
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-3xl block mb-2">📁</span>
|
||||||
|
<span className="font-bold text-primary block">Logo hochladen</span>
|
||||||
|
<span className="text-sm text-secondary">Farben automatisch</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ colors: { ...config.colors, useLogoColors: false } })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer text-center ${
|
||||||
|
!config.colors.useLogoColors
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-3xl block mb-2">🎨</span>
|
||||||
|
<span className="font-bold text-primary block">Farben wählen</span>
|
||||||
|
<span className="text-sm text-secondary">Manuell auswählen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logo Upload */}
|
||||||
|
{config.colors.useLogoColors && (
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-5 shadow-lg">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-semibold text-primary block mb-3">Logo hochladen</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
colors: { ...config.colors, logo: e.target.files?.[0] || null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="block w-full text-sm text-secondary file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:bg-primary file:text-white file:font-medium hover:file:bg-primary-light file:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Color Picker */}
|
||||||
|
{!config.colors.useLogoColors && (
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-5 shadow-lg">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Primär</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.primary}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, primary: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Sekundär</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.secondary}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, secondary: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Akzent</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.accent}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, accent: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Toggle - as floating buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ theme: 'light' })}
|
||||||
|
className={`rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer flex items-center justify-center gap-3 ${
|
||||||
|
config.theme === 'light'
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">☀️</span>
|
||||||
|
<span className="font-bold text-primary text-lg">Hell</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ theme: 'dark' })}
|
||||||
|
className={`rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer flex items-center justify-center gap-3 ${
|
||||||
|
config.theme === 'dark'
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🌙</span>
|
||||||
|
<span className="font-bold text-primary text-lg">Dunkel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
{currentStep > 1 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep((prev) => Math.max(1, prev - 1))}
|
||||||
|
className="px-6 py-3 rounded-full font-medium bg-white text-primary shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{!isLastStep ? (
|
||||||
|
<button
|
||||||
|
onClick={() => canProceed() && setCurrentStep((prev) => prev + 1)}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
className={`px-8 py-3 rounded-full font-semibold transition-all ${
|
||||||
|
canProceed()
|
||||||
|
? 'bg-primary text-white hover:bg-primary-light hover:shadow-lg'
|
||||||
|
: 'bg-tertiary/30 text-secondary cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCheckout(true)}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>199€ – Jetzt kaufen</span>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 12 8">
|
||||||
|
<path d="M7.755 8H6.09l3.44-3.36H.155V3.488H9.53L6.09.128h1.664l4.096 3.936L7.755 8Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Live Preview - Matching Height */}
|
||||||
|
<div className="lg:sticky lg:top-24">
|
||||||
|
<div className="bg-background rounded-[2rem] overflow-hidden min-h-[500px]">
|
||||||
|
<ConfiguratorPreview config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
316
src/components/ConfiguratorPreview.tsx
Normal file
316
src/components/ConfiguratorPreview.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { ConfigState } from './Configurator';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ConfigState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const websiteTypeLabels: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
ecommerce: { title: 'Mein Shop', subtitle: 'Entdecke unsere Produkte' },
|
||||||
|
scheduling: { title: 'Termine buchen', subtitle: 'Finde deinen perfekten Termin' },
|
||||||
|
portfolio: { title: 'Meine Arbeiten', subtitle: 'Kreative Projekte & Designs' },
|
||||||
|
blog: { title: 'Mein Blog', subtitle: 'Gedanken & Geschichten' },
|
||||||
|
courses: { title: 'Lerne mit uns', subtitle: 'Online-Kurse für jeden' },
|
||||||
|
events: { title: 'Unsere Events', subtitle: 'Veranstaltungen & Termine' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfiguratorPreview({ config }: Props) {
|
||||||
|
const colors = {
|
||||||
|
primary: config.colors.primary,
|
||||||
|
secondary: config.colors.secondary,
|
||||||
|
accent: config.colors.accent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDark = config.theme === 'dark';
|
||||||
|
const bgColor = isDark ? '#1a1a2e' : '#ffffff';
|
||||||
|
const textColor = isDark ? '#ffffff' : colors.primary;
|
||||||
|
// Use secondary color in dark mode too, just lighten it
|
||||||
|
const mutedColor = isDark ? colors.secondary : colors.secondary;
|
||||||
|
|
||||||
|
// Style-based adjustments
|
||||||
|
const getBorderRadius = () => {
|
||||||
|
switch (config.style) {
|
||||||
|
case 'modern':
|
||||||
|
return '12px';
|
||||||
|
case 'bold':
|
||||||
|
return '0px';
|
||||||
|
case 'elegant':
|
||||||
|
return '4px';
|
||||||
|
case 'minimal':
|
||||||
|
return '8px';
|
||||||
|
default:
|
||||||
|
return '12px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFont = () => {
|
||||||
|
switch (config.style) {
|
||||||
|
case 'modern':
|
||||||
|
return "'DM Sans', sans-serif";
|
||||||
|
case 'bold':
|
||||||
|
return "'Arial Black', sans-serif";
|
||||||
|
case 'elegant':
|
||||||
|
return "'Playfair Display', serif";
|
||||||
|
case 'minimal':
|
||||||
|
return "'Helvetica Neue', sans-serif";
|
||||||
|
default:
|
||||||
|
return "'DM Sans', sans-serif";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeInfo = config.websiteType
|
||||||
|
? websiteTypeLabels[config.websiteType]
|
||||||
|
: { title: 'Deine Website', subtitle: 'Wähle einen Typ um die Preview zu sehen' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-3xl shadow-xl overflow-hidden transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#0d0d14' : '#ffffff',
|
||||||
|
border: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Preview Header - Browser Chrome */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 flex items-center gap-2 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#1a1a2e' : 'rgba(177,171,134,0.1)',
|
||||||
|
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-4">
|
||||||
|
<div
|
||||||
|
className="rounded-md px-3 py-1 text-xs text-center transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.1)' : '#ffffff',
|
||||||
|
color: isDark ? 'rgba(255,255,255,0.6)' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
www.deine-website.de
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium transition-colors duration-500"
|
||||||
|
style={{ color: isDark ? 'rgba(255,255,255,0.5)' : colors.secondary }}
|
||||||
|
>
|
||||||
|
Live Preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Content */}
|
||||||
|
<div
|
||||||
|
className="p-6 min-h-[400px] transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
fontFamily: getFont(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mock Navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg"
|
||||||
|
style={{ backgroundColor: colors.primary }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{['Home', 'Über', 'Kontakt'].map((item) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className="text-xs transition-colors"
|
||||||
|
style={{ color: mutedColor }}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3
|
||||||
|
className="text-2xl font-bold mb-2 transition-all"
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
fontFamily: getFont(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeInfo.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-sm mb-4 transition-colors"
|
||||||
|
style={{ color: mutedColor }}
|
||||||
|
>
|
||||||
|
{typeInfo.subtitle}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-white text-sm font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mehr erfahren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Cards - Always use selected colors */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-3 transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}15` : `${colors.accent}20`,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
border: `1px solid ${colors.accent}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-12 mb-2 transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}30`,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-3/4 rounded transition-colors"
|
||||||
|
style={{ backgroundColor: isDark ? `${colors.primary}60` : `${colors.primary}30` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-1/2 rounded mt-1 transition-colors"
|
||||||
|
style={{ backgroundColor: isDark ? `${colors.secondary}50` : `${colors.secondary}20` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific elements - Colors work in dark mode too */}
|
||||||
|
{config.websiteType === 'ecommerce' && (
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<span className="text-lg">🛒</span>
|
||||||
|
<span className="text-xs" style={{ color: isDark ? colors.accent : mutedColor }}>
|
||||||
|
Warenkorb (0)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'scheduling' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}30` : `${colors.primary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 Nächster freier Termin: Morgen, 10:00
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'blog' && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{['Tech', 'Design', 'Life'].map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}50` : `${colors.accent}30`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'events' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}40` : `${colors.accent}20`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉 Nächstes Event: Samstag, 19:00
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'portfolio' && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{['Web', 'Print', 'Brand'].map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}20`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'courses' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}30` : `${colors.primary}10`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎓 12 Kurse verfügbar • 4.9 ⭐ Bewertung
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Summary */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(177,171,134,0.05)',
|
||||||
|
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{config.websiteType && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}40` : `${colors.primary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{websiteTypeLabels[config.websiteType]?.title || config.websiteType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{config.style && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.style}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(177,171,134,0.2)',
|
||||||
|
color: isDark ? '#ffffff' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.theme === 'dark' ? '🌙 Dunkel' : '☀️ Hell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
227
src/components/EditorPreview.tsx
Normal file
227
src/components/EditorPreview.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function EditorPreview() {
|
||||||
|
const [animationPhase, setAnimationPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setAnimationPhase((prev) => (prev + 1) % 4);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Decorative background elements */}
|
||||||
|
<div className="absolute -inset-4 bg-gradient-to-br from-primary/5 via-secondary/10 to-tertiary/20 rounded-3xl blur-2xl" />
|
||||||
|
|
||||||
|
{/* Browser Window */}
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-2xl shadow-primary/10 overflow-hidden border border-tertiary/30">
|
||||||
|
{/* Browser Header */}
|
||||||
|
<div className="bg-gradient-to-r from-gray-100 to-gray-50 px-4 py-3 flex items-center gap-3 border-b border-gray-200">
|
||||||
|
{/* Traffic lights */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||||
|
</div>
|
||||||
|
{/* URL Bar */}
|
||||||
|
<div className="flex-1 bg-white rounded-md px-3 py-1.5 text-sm text-gray-500 border border-gray-200">
|
||||||
|
www.ihre-website.de
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website Content Preview */}
|
||||||
|
<div className="p-6 bg-gradient-to-br from-background to-white min-h-[320px] relative">
|
||||||
|
{/* Mock Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="w-24 h-6 bg-primary/20 rounded" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area with Animation */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Left content */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="w-full h-4 bg-primary/30 rounded" />
|
||||||
|
<div className="w-4/5 h-4 bg-primary/20 rounded" />
|
||||||
|
<div className="w-3/5 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="mt-4 w-24 h-8 bg-primary rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Animated Element */}
|
||||||
|
<div className="relative">
|
||||||
|
<AnimatedElement phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom elements */}
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animated Cursor */}
|
||||||
|
<AnimatedCursor phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor UI Overlay Elements */}
|
||||||
|
<EditorOverlays phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedElement({ phase }: { phase: number }) {
|
||||||
|
const sizes = [
|
||||||
|
{ width: '100%', height: '100px' },
|
||||||
|
{ width: '120%', height: '120px' },
|
||||||
|
{ width: '80%', height: '80px' },
|
||||||
|
{ width: '100%', height: '100px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 20, y: 10 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-br from-secondary/30 to-tertiary/40 rounded-xl relative transition-all duration-1000 ease-in-out"
|
||||||
|
style={{
|
||||||
|
width: sizes[phase].width,
|
||||||
|
height: sizes[phase].height,
|
||||||
|
transform: `translate(${positions[phase].x}px, ${positions[phase].y}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Resize handles - visible during resize phases */}
|
||||||
|
{(phase === 0 || phase === 1) && (
|
||||||
|
<>
|
||||||
|
<div className="absolute -right-1 -bottom-1 w-3 h-3 bg-primary border-2 border-white rounded-sm shadow-md" />
|
||||||
|
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-6 bg-primary/60 rounded-full" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-2 w-6 bg-primary/60 rounded-full" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection border */}
|
||||||
|
<div className="absolute inset-0 border-2 border-primary border-dashed rounded-xl opacity-60" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedCursor({ phase }: { phase: number }) {
|
||||||
|
const positions = [
|
||||||
|
{ x: '75%', y: '55%', action: 'resize' },
|
||||||
|
{ x: '85%', y: '65%', action: 'resize' },
|
||||||
|
{ x: '70%', y: '45%', action: 'drag' },
|
||||||
|
{ x: '75%', y: '50%', action: 'drag' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute transition-all duration-1000 ease-in-out pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
left: positions[phase].x,
|
||||||
|
top: positions[phase].y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cursor SVG */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="drop-shadow-lg"
|
||||||
|
>
|
||||||
|
{positions[phase].action === 'resize' ? (
|
||||||
|
// Resize cursor
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M14 10L20 4M20 4H14M20 4V10"
|
||||||
|
stroke="#0A400C"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 14L4 20M4 20H10M4 20V14"
|
||||||
|
stroke="#0A400C"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Move/drag cursor
|
||||||
|
<path
|
||||||
|
d="M3 3L10 21L12 12L21 10L3 3Z"
|
||||||
|
fill="#0A400C"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Action tooltip */}
|
||||||
|
<div className="absolute left-6 top-0 bg-primary text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-lg">
|
||||||
|
{positions[phase].action === 'resize' ? 'Größe anpassen' : 'Verschieben'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorOverlays({ phase }: { phase: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating toolbar */}
|
||||||
|
<div className="absolute -top-4 -right-4 bg-white rounded-xl shadow-xl p-2 flex gap-1 border border-tertiary/30">
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Properties panel hint */}
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-2 -left-2 bg-white rounded-lg shadow-lg p-3 border border-tertiary/30 transition-all duration-500 ${
|
||||||
|
phase === 1 || phase === 2 ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-secondary mb-1">Breite</div>
|
||||||
|
<div className="text-sm font-mono text-primary font-semibold">
|
||||||
|
{phase === 1 ? '420px' : phase === 2 ? '280px' : '350px'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layers indicator */}
|
||||||
|
<div className="absolute top-1/2 -left-6 transform -translate-y-1/2">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-2 border border-tertiary/30">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="w-4 h-1 bg-primary rounded" />
|
||||||
|
<div className="w-4 h-1 bg-secondary rounded" />
|
||||||
|
<div className="w-4 h-1 bg-tertiary rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
104
src/components/Header.tsx
Normal file
104
src/components/Header.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Logo } from './Logo';
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ name: 'Über uns', href: '#about' },
|
||||||
|
{ name: 'Leistungen', href: '#services' },
|
||||||
|
{ name: 'Kontakt', href: '#contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-tertiary/30">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16 sm:h-20">
|
||||||
|
{/* Logo */}
|
||||||
|
<a href="/" className="flex items-center gap-2 group">
|
||||||
|
<Logo size={36} className="transition-transform group-hover:scale-105" />
|
||||||
|
<span className="font-heading text-xl sm:text-2xl font-semibold text-primary">
|
||||||
|
webklar
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
className="text-secondary hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="bg-primary hover:bg-primary-light text-white px-5 py-2.5 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Projekt starten
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="md:hidden p-2 text-primary"
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<nav className="md:hidden py-4 border-t border-tertiary/30">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
className="text-secondary hover:text-primary transition-colors font-medium py-2"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="bg-primary hover:bg-primary-light text-white px-5 py-2.5 rounded-lg font-medium transition-colors text-center mt-2"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Projekt starten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
92
src/components/Hero.tsx
Normal file
92
src/components/Hero.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { EditorPreview } from './EditorPreview';
|
||||||
|
|
||||||
|
const serviceTags = ['Website', 'Backend', 'Hosting', 'Support'];
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen pt-20 sm:pt-24 flex items-center">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
{/* Left Side - Text Content */}
|
||||||
|
<div className="order-2 lg:order-1">
|
||||||
|
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight mb-4">
|
||||||
|
webklar – das Web{' '}
|
||||||
|
<span className="text-secondary">maßgeschneidert</span> auf Ihr
|
||||||
|
Unternehmen
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl sm:text-2xl text-secondary-dark mb-6 max-w-xl leading-relaxed">
|
||||||
|
Wir übernehmen die Technik, Sie konzentrieren sich aufs Geschäft.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Service Tags */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
|
{serviceTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-4 py-2 bg-tertiary/30 text-primary rounded-full text-sm font-medium border border-tertiary/50"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price Badge */}
|
||||||
|
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/20 rounded-2xl px-5 py-3 mb-8">
|
||||||
|
<span className="text-3xl font-bold text-primary">199€</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-semibold text-primary">Festpreis</p>
|
||||||
|
<p className="text-xs text-secondary">Lieferung in 48h</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<a
|
||||||
|
href="#konfigurator"
|
||||||
|
className="inline-flex items-center justify-center bg-primary hover:bg-primary-light text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all hover:shadow-lg hover:shadow-primary/20 hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
Jetzt konfigurieren
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#projects"
|
||||||
|
className="inline-flex items-center justify-center border-2 border-secondary text-secondary hover:bg-secondary hover:text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
Projekte ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust indicators */}
|
||||||
|
<div className="mt-10 pt-6 border-t border-tertiary/40">
|
||||||
|
<div className="flex flex-wrap gap-6 text-secondary-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>100% individuell</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>AI-gestützt</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Keine Templates</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Editor Preview */}
|
||||||
|
<div className="order-1 lg:order-2">
|
||||||
|
<EditorPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
72
src/components/Logo.tsx
Normal file
72
src/components/Logo.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ className = '', size = 40 }: LogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
aria-label="webklar Logo"
|
||||||
|
>
|
||||||
|
{/* Left V-shape (lighter green) */}
|
||||||
|
<path
|
||||||
|
d="M70 120 L70 280 L165 375 L165 320 L110 265 L110 120 Z"
|
||||||
|
fill="url(#gradient-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center chevron (medium green) */}
|
||||||
|
<path
|
||||||
|
d="M165 215 L250 130 L335 215 L335 270 L250 185 L165 270 Z"
|
||||||
|
fill="url(#gradient-center)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right V-shape (darker green) */}
|
||||||
|
<path
|
||||||
|
d="M430 120 L430 280 L335 375 L335 320 L390 265 L390 120 Z"
|
||||||
|
fill="url(#gradient-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom connection left */}
|
||||||
|
<path
|
||||||
|
d="M165 320 L165 375 L250 460 L250 405 Z"
|
||||||
|
fill="url(#gradient-bottom-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom connection right */}
|
||||||
|
<path
|
||||||
|
d="M335 320 L335 375 L250 460 L250 405 Z"
|
||||||
|
fill="url(#gradient-bottom-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#9AA67F" />
|
||||||
|
<stop offset="100%" stopColor="#0F5010" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-center" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0F5010" />
|
||||||
|
<stop offset="100%" stopColor="#0A400C" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0A400C" />
|
||||||
|
<stop offset="100%" stopColor="#052006" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0F5010" />
|
||||||
|
<stop offset="100%" stopColor="#0A400C" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0A400C" />
|
||||||
|
<stop offset="100%" stopColor="#052006" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
src/components/OrderConfirmation.tsx
Normal file
103
src/components/OrderConfirmation.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
interface Props {
|
||||||
|
orderNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderConfirmation({ orderNumber = 'WK-2026-0001', email = 'kunde@beispiel.de', onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-12 h-12 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary mb-4">
|
||||||
|
Vielen Dank! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-secondary mb-8">
|
||||||
|
Deine Bestellung wurde erfolgreich aufgegeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Order Card */}
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-8 shadow-lg mb-8 text-left">
|
||||||
|
<div className="flex justify-between items-center pb-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestellnummer</span>
|
||||||
|
<span className="font-mono font-bold text-primary">{orderNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-6 border-b border-tertiary/20">
|
||||||
|
<h3 className="font-bold text-primary text-lg mb-4">Was passiert jetzt?</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Bestätigung per E-Mail</p>
|
||||||
|
<p className="text-secondary text-sm">Du erhältst eine E-Mail an {email} mit allen Details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Wir starten sofort</p>
|
||||||
|
<p className="text-secondary text-sm">Unser Team beginnt direkt mit der Umsetzung deiner Website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Lieferung in 48h</p>
|
||||||
|
<p className="text-secondary text-sm">Du bekommst deine fertige Website spätestens in 48 Stunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-between">
|
||||||
|
<span className="text-secondary text-sm">Fragen? support@webklar.de</span>
|
||||||
|
<span className="text-2xl">📧</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown Timer (visual only) */}
|
||||||
|
<div className="mt-12 bg-white/50 rounded-2xl p-6">
|
||||||
|
<p className="text-secondary text-sm mb-2">Voraussichtliche Lieferung</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">48</div>
|
||||||
|
<div className="text-xs text-secondary">Stunden</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-primary">:</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">00</div>
|
||||||
|
<div className="text-xs text-secondary">Minuten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
183
src/components/SuccessPage.tsx
Normal file
183
src/components/SuccessPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { verifySession, type SessionData } from '../lib/stripe';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuccessPage({ onClose }: Props) {
|
||||||
|
const [session, setSession] = useState<SessionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sessionId = params.get('session_id');
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
verifySession(sessionId)
|
||||||
|
.then((data) => {
|
||||||
|
setSession(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Session verification failed:', err);
|
||||||
|
setError('Bestellung konnte nicht verifiziert werden');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate order number
|
||||||
|
const orderNumber = session?.id
|
||||||
|
? `WK-${new Date().getFullYear()}-${session.id.slice(-6).toUpperCase()}`
|
||||||
|
: 'WK-2026-XXXXX';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="animate-spin w-12 h-12 text-primary mx-auto mb-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-secondary">Bestellung wird verifiziert...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-red-100 rounded-full flex items-center justify-center mb-8">
|
||||||
|
<svg className="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-primary mb-4">Etwas ist schiefgelaufen</h1>
|
||||||
|
<p className="text-secondary mb-8">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-primary/10 rounded-full flex items-center justify-center animate-bounce-once">
|
||||||
|
<svg className="w-12 h-12 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary mb-4">
|
||||||
|
Vielen Dank! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-secondary mb-8">
|
||||||
|
Deine Zahlung war erfolgreich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Order Card */}
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-8 shadow-lg mb-8 text-left">
|
||||||
|
<div className="flex justify-between items-center pb-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestellnummer</span>
|
||||||
|
<span className="font-mono font-bold text-primary">{orderNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<div className="flex justify-between items-center py-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Betrag</span>
|
||||||
|
<span className="font-bold text-primary">
|
||||||
|
{(session.amountTotal / 100).toFixed(2)}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.customerEmail && (
|
||||||
|
<div className="flex justify-between items-center py-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestätigung an</span>
|
||||||
|
<span className="font-semibold text-primary">{session.customerEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="py-6 border-b border-tertiary/20">
|
||||||
|
<h3 className="font-bold text-primary text-lg mb-4">Was passiert jetzt?</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Bestätigung per E-Mail</p>
|
||||||
|
<p className="text-secondary text-sm">Du erhältst in Kürze eine E-Mail mit allen Details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Wir starten sofort</p>
|
||||||
|
<p className="text-secondary text-sm">Unser Team beginnt direkt mit der Umsetzung deiner Website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Lieferung in 48h</p>
|
||||||
|
<p className="text-secondary text-sm">Du bekommst deine fertige Website spätestens in 48 Stunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-between">
|
||||||
|
<span className="text-secondary text-sm">Fragen? support@webklar.de</span>
|
||||||
|
<span className="text-2xl">📧</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Countdown Timer */}
|
||||||
|
<div className="mt-12 bg-white/50 rounded-2xl p-6">
|
||||||
|
<p className="text-secondary text-sm mb-2">Voraussichtliche Lieferung</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">48</div>
|
||||||
|
<div className="text-xs text-secondary">Stunden</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-primary">:</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">00</div>
|
||||||
|
<div className="text-xs text-secondary">Minuten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
10
src/components/index.ts
Normal file
10
src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { Logo } from './Logo';
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { Hero } from './Hero';
|
||||||
|
export { EditorPreview } from './EditorPreview';
|
||||||
|
export { Configurator } from './Configurator';
|
||||||
|
export { ConfiguratorPreview } from './ConfiguratorPreview';
|
||||||
|
export { Checkout } from './Checkout';
|
||||||
|
export { OrderConfirmation } from './OrderConfirmation';
|
||||||
|
export { SuccessPage } from './SuccessPage';
|
||||||
|
|
||||||
33
src/index.css
Normal file
33
src/index.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* webklar Custom Theme */
|
||||||
|
@theme {
|
||||||
|
--color-primary: #0A400C;
|
||||||
|
--color-primary-light: #0F5010;
|
||||||
|
--color-primary-dark: #052006;
|
||||||
|
--color-secondary: #819067;
|
||||||
|
--color-secondary-light: #9AA67F;
|
||||||
|
--color-secondary-dark: #6B7A58;
|
||||||
|
--color-tertiary: #B1AB86;
|
||||||
|
--color-background: #FEFAE0;
|
||||||
|
|
||||||
|
--font-family-heading: "Playfair Display", Georgia, serif;
|
||||||
|
--font-family-body: "DM Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Styles */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-body);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
}
|
||||||
98
src/lib/stripe.ts
Normal file
98
src/lib/stripe.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
|
||||||
|
// Stripe Configuration
|
||||||
|
export const STRIPE_CONFIG = {
|
||||||
|
publishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
|
||||||
|
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stripe instance (lazy loaded)
|
||||||
|
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||||
|
|
||||||
|
export function getStripe() {
|
||||||
|
if (!stripePromise && STRIPE_CONFIG.publishableKey) {
|
||||||
|
stripePromise = loadStripe(STRIPE_CONFIG.publishableKey);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface OrderData {
|
||||||
|
websiteType: string;
|
||||||
|
style: string;
|
||||||
|
theme: string;
|
||||||
|
colors: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
customInput?: {
|
||||||
|
referenceUrl: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
contact: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutResponse {
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
customerEmail: string;
|
||||||
|
amountTotal: number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session via backend API
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(orderData: OrderData): Promise<CheckoutResponse> {
|
||||||
|
const response = await fetch(`${STRIPE_CONFIG.apiUrl}/api/checkout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ orderData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.details || error.error || 'Failed to create checkout session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to Stripe Checkout
|
||||||
|
*/
|
||||||
|
export async function redirectToCheckout(orderData: OrderData): Promise<void> {
|
||||||
|
const { url } = await createCheckoutSession(orderData);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
throw new Error('No checkout URL returned');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a checkout session (for success page)
|
||||||
|
*/
|
||||||
|
export async function verifySession(sessionId: string): Promise<SessionData> {
|
||||||
|
const response = await fetch(`${STRIPE_CONFIG.apiUrl}/api/session/${sessionId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
15
webklar/.env
Normal file
15
webklar/.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Stripe Keys - Get from https://dashboard.stripe.com/apikeys
|
||||||
|
# Use TEST keys for development (pk_test_... and sk_test_...)
|
||||||
|
STRIPE_SECRET_KEY=sk_test_51SmzgcBONHRzgId9uiPW4houDfhY0nNQ6ECjSRFLNXEOi8YtMwxNezFA6e4MUQypZ3Sjo5AwvvTmkqLsNDcDX4E400XPoW9XCu
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SmzgcBONHRzgId9Da7vSODAkMfJvLJ2okPpLnumV6WY3IMizmSSeHjQtYwmyHrexzZBU76el1MJIj2gw8vVBW2o00GiqfDeEA
|
||||||
|
|
||||||
|
# Webhook Secret - Get from Stripe Dashboard > Webhooks
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE
|
||||||
|
|
||||||
|
# URLs
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
7
webklar/.env.example
Normal file
7
webklar/.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Stripe Configuration
|
||||||
|
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
|
||||||
|
|
||||||
|
# Backend URL (for production)
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
|
||||||
24
webklar/.gitignore
vendored
Normal file
24
webklar/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
225
webklar/KONZEPT.md
Normal file
225
webklar/KONZEPT.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# webklar - Konzept & Spezifikation
|
||||||
|
|
||||||
|
> Full-Stack Digitalagentur mit produktisiertem Website-Service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geschäftsmodell
|
||||||
|
|
||||||
|
### Hauptprodukt: Website in 48h
|
||||||
|
|
||||||
|
| Detail | Wert |
|
||||||
|
|--------|------|
|
||||||
|
| **Preis** | 199€ (Festpreis) |
|
||||||
|
| **Lieferzeit** | 48 Stunden |
|
||||||
|
| **Zahlung** | 100% Vorkasse (Online) |
|
||||||
|
| **Technologie** | AI-gestützt |
|
||||||
|
| **Änderungen** | X Runden inklusive |
|
||||||
|
|
||||||
|
### Optionales Hosting (Hybrid-Modell)
|
||||||
|
|
||||||
|
| Detail | Wert |
|
||||||
|
|--------|------|
|
||||||
|
| **Preis** | 30-100€/Monat |
|
||||||
|
| **Anbieter** | Hetzner (Managed) |
|
||||||
|
| **Status** | Optional - Kunde kann auch selbst hosten |
|
||||||
|
| **Enthält** | Server, Updates, Backups |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Website-Konfigurator
|
||||||
|
|
||||||
|
### Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┬──────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ FORMULAR │ LIVE-PREVIEW │
|
||||||
|
│ │ │
|
||||||
|
│ 1. Typ wählen │ [Website ändert │
|
||||||
|
│ 2. Stil wählen │ sich in │
|
||||||
|
│ 3. Farben/Logo │ Echtzeit] │
|
||||||
|
│ 4. Hell/Dunkel │ │
|
||||||
|
│ │ │
|
||||||
|
│ [💳 199€ kaufen] │ │
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 1: Website-Typ (6 Optionen)
|
||||||
|
|
||||||
|
| Typ | Icon | Beschreibung |
|
||||||
|
|-----|------|--------------|
|
||||||
|
| **eCommerce** | 🛒 | Online-Shop |
|
||||||
|
| **Scheduling** | 📅 | Terminbuchung (Ärzte, Friseure, etc.) |
|
||||||
|
| **Portfolio** | 🎨 | Kreative, Freelancer |
|
||||||
|
| **Blog** | 📝 | Content-Creator, Blogger |
|
||||||
|
| **Online courses** | 🎓 | Kurse verkaufen |
|
||||||
|
| **Events** | 🎉 | Veranstaltungen, Tickets |
|
||||||
|
|
||||||
|
### Schritt 2: Design-Stil (5 Optionen)
|
||||||
|
|
||||||
|
| Stil | Beschreibung |
|
||||||
|
|------|--------------|
|
||||||
|
| **Modern / Clean** | Zeitlos, professionell |
|
||||||
|
| **Bold / Auffällig** | Hebt sich ab, Marketing |
|
||||||
|
| **Elegant / Luxus** | Premium, hochwertig |
|
||||||
|
| **Minimalistisch** | Reduziert, viel Weißraum |
|
||||||
|
| **Custom** | Eigene Vorstellung |
|
||||||
|
|
||||||
|
#### Custom-Optionen (wenn gewählt):
|
||||||
|
|
||||||
|
- 🔗 **Referenz-Link** - URL einer Website als Inspiration
|
||||||
|
- 📝 **Beschreibung** - Freitext
|
||||||
|
- 🖼️ **Bild hochladen** - Screenshot, Moodboard, etc.
|
||||||
|
|
||||||
|
### Schritt 3: Farben
|
||||||
|
|
||||||
|
#### Option A: Logo hochladen
|
||||||
|
```
|
||||||
|
[Logo hochladen]
|
||||||
|
↓
|
||||||
|
Farben werden automatisch extrahiert
|
||||||
|
↓
|
||||||
|
[Hell ○ / Dunkel ○] ← Kunde kann überschreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Farben selbst wählen
|
||||||
|
```
|
||||||
|
[Farbe 1] ← RGB Picker (Primärfarbe) - Pflicht
|
||||||
|
[Farbe 2] ← RGB Picker (Sekundärfarbe) - Optional
|
||||||
|
[Farbe 3] ← RGB Picker (Akzentfarbe) - Optional
|
||||||
|
|
||||||
|
Max. 3 Farben
|
||||||
|
↓
|
||||||
|
[Hell ○ / Dunkel ○]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 4: Bezahlen
|
||||||
|
|
||||||
|
- Online-Zahlung (Stripe/PayPal)
|
||||||
|
- 100% Vorkasse
|
||||||
|
- Sofortige Bestätigung
|
||||||
|
- Lieferung in 48h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positionierung
|
||||||
|
|
||||||
|
### USP (Unique Selling Points)
|
||||||
|
|
||||||
|
1. **Schnell** - 48h Lieferzeit
|
||||||
|
2. **100% individuell** - Keine Templates
|
||||||
|
3. **AI-gestützt** - Effizient & modern
|
||||||
|
4. **Festpreis** - Keine versteckten Kosten
|
||||||
|
|
||||||
|
### Konkurrenz
|
||||||
|
|
||||||
|
Kunden vergleichen mit:
|
||||||
|
- Wix
|
||||||
|
- Squarespace
|
||||||
|
- Jimdo
|
||||||
|
- Shopify
|
||||||
|
|
||||||
|
### Hauptargument gegen Baukästen
|
||||||
|
|
||||||
|
> "100% individuelles Design – keine Template-Grenzen"
|
||||||
|
|
||||||
|
### Zielgruppe
|
||||||
|
|
||||||
|
- Alle (flexibel)
|
||||||
|
- Hauptsächlich: Kleine Unternehmen, Selbstständige, Startups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
### Tonfall
|
||||||
|
|
||||||
|
**Locker & unkompliziert** - aber professionell
|
||||||
|
|
||||||
|
### Slogan
|
||||||
|
|
||||||
|
> "webklar – das Web maßgeschneidert auf Ihr Unternehmen"
|
||||||
|
|
||||||
|
### Untertitel
|
||||||
|
|
||||||
|
> "Wir übernehmen die Technik, Sie konzentrieren sich aufs Geschäft"
|
||||||
|
|
||||||
|
### Farbschema
|
||||||
|
|
||||||
|
| Farbe | Hex | Verwendung |
|
||||||
|
|-------|-----|------------|
|
||||||
|
| Primary | `#0A400C` | Buttons, Akzente (Dunkelgrün) |
|
||||||
|
| Secondary | `#819067` | Hover-States, Sekundärtexte (Mittelgrün) |
|
||||||
|
| Tertiary | `#B1AB86` | Dekorative Elemente (Hellgrün-Beige) |
|
||||||
|
| Background | `#FEFAE0` | Seitenhintergrund (Creme) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Framework**: Vite + React
|
||||||
|
- **Sprache**: TypeScript
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Ziel**: Ressourcensparend
|
||||||
|
|
||||||
|
### Hosting (für Kunden)
|
||||||
|
|
||||||
|
- **Anbieter**: Hetzner
|
||||||
|
- **Verwaltung**: Komplett durch webklar (Kunde sieht nur "webklar")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
### Phase 1: Landing Page ✅
|
||||||
|
- [x] Projekt-Setup (Vite + TypeScript + Tailwind)
|
||||||
|
- [x] Farbschema konfigurieren
|
||||||
|
- [x] Logo-Komponente erstellen
|
||||||
|
- [x] Header/Navigation
|
||||||
|
- [x] Hero-Section (Basis)
|
||||||
|
- [x] Hero-Section updaten mit neuem Messaging
|
||||||
|
- [x] Service-Tags hinzufügen (Website | Backend | Hosting | Support)
|
||||||
|
|
||||||
|
### Phase 2: Konfigurator ✅
|
||||||
|
- [x] Konfigurator-Layout (Split: Formular + Live-Preview)
|
||||||
|
- [x] Schritt 1: Website-Typ Auswahl (6 Optionen)
|
||||||
|
- [x] Schritt 2: Stil-Auswahl (5 Optionen)
|
||||||
|
- [x] Custom-Option (Referenz-Link, Beschreibung, Bild-Upload)
|
||||||
|
- [x] Schritt 3: Farbauswahl
|
||||||
|
- [x] Logo-Upload mit automatischer Farb-Extraktion
|
||||||
|
- [x] RGB Color Picker (max. 3 Farben)
|
||||||
|
- [x] Hell/Dunkel Toggle
|
||||||
|
- [x] Live-Preview Komponente
|
||||||
|
- [x] Preview reagiert auf alle Auswahlen in Echtzeit
|
||||||
|
|
||||||
|
### Phase 3: Checkout & Payment
|
||||||
|
- [ ] Stripe-Integration
|
||||||
|
- [ ] Checkout-Flow
|
||||||
|
- [ ] Bestätigungs-E-Mail
|
||||||
|
- [ ] Auftrags-Dashboard (Admin)
|
||||||
|
|
||||||
|
### Phase 4: Backend
|
||||||
|
- [ ] Auftragsverarbeitung
|
||||||
|
- [ ] Kunden-Datenbank
|
||||||
|
- [ ] AI-Integration für Website-Generierung
|
||||||
|
- [ ] Delivery-System (48h Timer)
|
||||||
|
|
||||||
|
### Phase 5: Hosting-Addon
|
||||||
|
- [ ] Hetzner-Integration
|
||||||
|
- [ ] Server-Provisioning
|
||||||
|
- [ ] Domain-Verwaltung
|
||||||
|
- [ ] Monatliche Abrechnung
|
||||||
|
|
||||||
|
### Phase 6: Polish & UX
|
||||||
|
- [ ] Dark Mode (automatisch nach System-Einstellung via `prefers-color-scheme`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt am: 06.01.2026*
|
||||||
|
*Version: 1.0*
|
||||||
|
*Zuletzt aktualisiert: 06.01.2026*
|
||||||
|
|
||||||
73
webklar/README.md
Normal file
73
webklar/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
webklar/eslint.config.js
Normal file
23
webklar/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
18
webklar/index.html
Normal file
18
webklar/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="webklar – das Web maßgeschneidert auf Ihr Unternehmen. Professionelle Webentwicklung für Ihren digitalen Erfolg." />
|
||||||
|
<title>webklar – Webentwicklung</title>
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4959
webklar/package-lock.json
generated
Normal file
4959
webklar/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
webklar/package.json
Normal file
39
webklar/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "webklar",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@stripe/stripe-js": "^8.6.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"stripe": "^20.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
webklar/public/logo.svg
Normal file
40
webklar/public/logo.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left V-shape (lighter green) -->
|
||||||
|
<path d="M70 120 L70 280 L165 375 L165 320 L110 265 L110 120 Z" fill="url(#gradient-left)"/>
|
||||||
|
|
||||||
|
<!-- Center chevron (medium green) -->
|
||||||
|
<path d="M165 215 L250 130 L335 215 L335 270 L250 185 L165 270 Z" fill="url(#gradient-center)"/>
|
||||||
|
|
||||||
|
<!-- Right V-shape (darker green) -->
|
||||||
|
<path d="M430 120 L430 280 L335 375 L335 320 L390 265 L390 120 Z" fill="url(#gradient-right)"/>
|
||||||
|
|
||||||
|
<!-- Bottom connection left -->
|
||||||
|
<path d="M165 320 L165 375 L250 460 L250 405 Z" fill="url(#gradient-bottom-left)"/>
|
||||||
|
|
||||||
|
<!-- Bottom connection right -->
|
||||||
|
<path d="M335 320 L335 375 L250 460 L250 405 Z" fill="url(#gradient-bottom-right)"/>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9AA67F"/>
|
||||||
|
<stop offset="100%" stop-color="#0F5010"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-center" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0F5010"/>
|
||||||
|
<stop offset="100%" stop-color="#0A400C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0A400C"/>
|
||||||
|
<stop offset="100%" stop-color="#052006"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0F5010"/>
|
||||||
|
<stop offset="100%" stop-color="#0A400C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0A400C"/>
|
||||||
|
<stop offset="100%" stop-color="#052006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
146
webklar/server/index.js
Normal file
146
webklar/server/index.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Initialize Stripe
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session
|
||||||
|
app.post('/api/checkout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderData } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!orderData?.contact?.email || !orderData?.contact?.name) {
|
||||||
|
return res.status(400).json({ error: 'Name and email are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
mode: 'payment',
|
||||||
|
customer_email: orderData.contact.email,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: 'eur',
|
||||||
|
product_data: {
|
||||||
|
name: 'Website in 48h',
|
||||||
|
description: `${orderData.websiteType} | ${orderData.style} | ${orderData.theme === 'dark' ? 'Dunkel' : 'Hell'}`,
|
||||||
|
images: ['https://webklar.de/og-image.png'], // Optional
|
||||||
|
},
|
||||||
|
unit_amount: 19900, // 199€ in cents
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
customerName: orderData.contact.name,
|
||||||
|
customerCompany: orderData.contact.company || '',
|
||||||
|
customerPhone: orderData.contact.phone || '',
|
||||||
|
websiteType: orderData.websiteType,
|
||||||
|
style: orderData.style,
|
||||||
|
theme: orderData.theme,
|
||||||
|
colorPrimary: orderData.colors?.primary || '#0A400C',
|
||||||
|
colorSecondary: orderData.colors?.secondary || '#819067',
|
||||||
|
colorAccent: orderData.colors?.accent || '#B1AB86',
|
||||||
|
customReferenceUrl: orderData.customInput?.referenceUrl || '',
|
||||||
|
customDescription: orderData.customInput?.description || '',
|
||||||
|
},
|
||||||
|
success_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/konfigurator`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sessionId: session.id,
|
||||||
|
url: session.url
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stripe error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to create checkout session',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify session (for success page)
|
||||||
|
app.get('/api/session/:sessionId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(req.params.sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: session.id,
|
||||||
|
status: session.payment_status,
|
||||||
|
customerEmail: session.customer_email,
|
||||||
|
amountTotal: session.amount_total,
|
||||||
|
metadata: session.metadata,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Session verification error:', error);
|
||||||
|
res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe Webhook (for production)
|
||||||
|
app.post('/api/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
||||||
|
const sig = req.headers['stripe-signature'];
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
console.warn('Webhook secret not configured');
|
||||||
|
return res.status(400).send('Webhook secret not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
const session = event.data.object;
|
||||||
|
console.log('✅ Payment successful:', session.id);
|
||||||
|
// TODO: Save order to database, send confirmation email, etc.
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'payment_intent.payment_failed':
|
||||||
|
console.log('❌ Payment failed:', event.data.object.id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error.message);
|
||||||
|
res.status(400).send(`Webhook Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`\n🚀 Server running on http://localhost:${port}`);
|
||||||
|
console.log(`\n📋 Endpoints:`);
|
||||||
|
console.log(` GET /api/health`);
|
||||||
|
console.log(` POST /api/checkout`);
|
||||||
|
console.log(` GET /api/session/:sessionId`);
|
||||||
|
console.log(` POST /api/webhook\n`);
|
||||||
|
});
|
||||||
|
|
||||||
45
webklar/src/App.tsx
Normal file
45
webklar/src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Header, Hero, Configurator, SuccessPage } from './components';
|
||||||
|
|
||||||
|
type Page = 'home' | 'success';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [page, setPage] = useState<Page>('home');
|
||||||
|
|
||||||
|
// Check URL for success page
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('session_id')) {
|
||||||
|
setPage('success');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGoHome = () => {
|
||||||
|
// Clear URL params
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
setPage('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (page === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<SuccessPage onClose={handleGoHome} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<Configurator />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
319
webklar/src/components/Checkout.tsx
Normal file
319
webklar/src/components/Checkout.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ConfigState, WebsiteType, StyleType } from './Configurator';
|
||||||
|
import { redirectToCheckout, STRIPE_CONFIG } from '../lib/stripe';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ConfigState;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactForm {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const websiteTypeLabels: Record<WebsiteType, string> = {
|
||||||
|
ecommerce: 'eCommerce',
|
||||||
|
scheduling: 'Terminbuchung',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
courses: 'Online Kurse',
|
||||||
|
events: 'Events',
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleLabels: Record<StyleType, string> = {
|
||||||
|
modern: 'Modern',
|
||||||
|
bold: 'Bold',
|
||||||
|
elegant: 'Elegant',
|
||||||
|
minimal: 'Minimalistisch',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Checkout({ config, onBack }: Props) {
|
||||||
|
const [contact, setContact] = useState<ContactForm>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
phone: '',
|
||||||
|
});
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if Stripe is configured
|
||||||
|
if (!STRIPE_CONFIG.publishableKey) {
|
||||||
|
throw new Error('Stripe ist noch nicht konfiguriert. Bitte Stripe-Keys in .env eintragen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await redirectToCheckout({
|
||||||
|
websiteType: config.websiteType || 'portfolio',
|
||||||
|
style: config.style || 'modern',
|
||||||
|
theme: config.theme,
|
||||||
|
colors: {
|
||||||
|
primary: config.colors.primary,
|
||||||
|
secondary: config.colors.secondary,
|
||||||
|
accent: config.colors.accent,
|
||||||
|
},
|
||||||
|
customInput: config.style === 'custom' ? {
|
||||||
|
referenceUrl: config.customInput.referenceUrl,
|
||||||
|
description: config.customInput.description,
|
||||||
|
} : undefined,
|
||||||
|
contact: {
|
||||||
|
name: contact.name,
|
||||||
|
email: contact.email,
|
||||||
|
company: contact.company || undefined,
|
||||||
|
phone: contact.phone || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Checkout error:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = contact.name && contact.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-secondary hover:text-primary transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span>Zurück zum Konfigurator</span>
|
||||||
|
</button>
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary">
|
||||||
|
Checkout
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary mt-2">Fast geschafft! Überprüfe deine Auswahl.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-800">Fehler</p>
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-5 gap-8">
|
||||||
|
{/* Left: Order Summary */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-6 shadow-lg sticky top-24">
|
||||||
|
<h3 className="font-bold text-primary text-xl mb-6">Deine Website</h3>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Typ</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.websiteType ? websiteTypeLabels[config.websiteType] : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Stil</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.style ? styleLabels[config.style] : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Theme</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{config.theme === 'dark' ? '🌙 Dunkel' : '☀️ Hell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Farben</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.primary }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.secondary }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: config.colors.accent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<div className="flex justify-between py-3 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Lieferzeit</span>
|
||||||
|
<span className="font-semibold text-primary">48 Stunden</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="flex justify-between py-4 mt-2">
|
||||||
|
<span className="text-xl font-bold text-primary">Gesamt</span>
|
||||||
|
<span className="text-2xl font-bold text-primary">199€</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust badges */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-tertiary/20 flex flex-wrap gap-2">
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ 100% individuell
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ Keine Templates
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-tertiary/20 text-secondary px-3 py-1 rounded-full">
|
||||||
|
✓ SSL-verschlüsselt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Contact Form */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-[1.5rem] p-6 shadow-lg">
|
||||||
|
<h3 className="font-bold text-primary text-xl mb-6">Kontaktdaten</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={contact.name}
|
||||||
|
onChange={(e) => setContact({ ...contact, name: e.target.value })}
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
E-Mail *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={contact.email}
|
||||||
|
onChange={(e) => setContact({ ...contact, email: e.target.value })}
|
||||||
|
placeholder="max@beispiel.de"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Unternehmen <span className="text-secondary font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={contact.company}
|
||||||
|
onChange={(e) => setContact({ ...contact, company: e.target.value })}
|
||||||
|
placeholder="Muster GmbH"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-primary mb-2">
|
||||||
|
Telefon <span className="text-secondary font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={contact.phone}
|
||||||
|
onChange={(e) => setContact({ ...contact, phone: e.target.value })}
|
||||||
|
placeholder="+49 123 456789"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-6 border-t border-tertiary/20" />
|
||||||
|
|
||||||
|
{/* Payment Info */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="font-semibold text-primary mb-3">Zahlungsmethode</h4>
|
||||||
|
<div className="bg-tertiary/10 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-12 h-7 bg-[#635BFF] rounded flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-4" viewBox="0 0 60 25" fill="none">
|
||||||
|
<path d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.27 4.06-.68v2.91c-1.1.47-2.47.88-4.47.88-4.15 0-6.6-2.27-6.6-6.37 0-3.55 2.1-6.49 5.88-6.49 3.66 0 5.99 2.58 5.99 6.37v.83zm-6.3-4.77c-1.11 0-2.04.85-2.18 2.43h4.2c-.01-1.58-.89-2.43-2.02-2.43zM43.9 17.95c-.78.36-1.73.55-2.85.55-3.04 0-4.83-1.54-4.83-4.72V9.33h-1.98V6.4h1.98V3.29l4.03-.86v4h3.55v2.93h-3.55v3.9c0 1.42.76 1.94 1.64 1.94.5 0 1.04-.1 1.54-.31l.47 3.06zM30.04 18.26V6.4h4.03v11.86h-4.03zm0-13.74V1.1h4.03v3.42h-4.03zM22.7 7.05c1.1 0 1.99.18 2.68.45V4.2c-.69-.36-1.64-.62-2.94-.62-2.56 0-4.37 1.23-4.37 3.78v.97h-2.02v2.93h2.02v7.01h4.03v-7.01h2.55V8.33H22.1v-.7c0-.4.23-.58.6-.58zM11.72 7.09c1.57 0 2.68.55 3.47 1.42l-.04-1.11V1.1H19v17.16h-3.55l-.23-1.4c-.81.95-2.05 1.64-3.72 1.64-3.33 0-5.62-2.72-5.62-5.88s2.33-5.53 5.84-5.53zm.93 8.84c1.64 0 2.57-1.23 2.57-3.19s-.93-3.06-2.57-3.06c-1.51 0-2.53 1.14-2.53 3.06s1.02 3.19 2.53 3.19z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-secondary text-sm">Sichere Zahlung mit Kreditkarte</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary mt-2">
|
||||||
|
Du wirst zu Stripe weitergeleitet, um die Zahlung sicher abzuschließen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || isProcessing}
|
||||||
|
className={`w-full py-4 rounded-full font-bold text-lg transition-all flex items-center justify-center gap-2 ${
|
||||||
|
isFormValid && !isProcessing
|
||||||
|
? 'bg-primary text-white hover:bg-primary-light hover:shadow-lg'
|
||||||
|
: 'bg-tertiary/30 text-secondary cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Weiterleitung zu Stripe...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>199€ – Weiter zur Zahlung</span>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<p className="text-xs text-secondary text-center mt-4">
|
||||||
|
Mit dem Kauf akzeptierst du unsere{' '}
|
||||||
|
<a href="#" className="underline hover:text-primary">AGB</a> und{' '}
|
||||||
|
<a href="#" className="underline hover:text-primary">Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
458
webklar/src/components/Configurator.tsx
Normal file
458
webklar/src/components/Configurator.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ConfiguratorPreview } from './ConfiguratorPreview';
|
||||||
|
import { Checkout } from './Checkout';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type WebsiteType = 'ecommerce' | 'scheduling' | 'portfolio' | 'blog' | 'courses' | 'events';
|
||||||
|
export type StyleType = 'modern' | 'bold' | 'elegant' | 'minimal' | 'custom';
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ConfigState {
|
||||||
|
websiteType: WebsiteType | null;
|
||||||
|
style: StyleType | null;
|
||||||
|
customInput: {
|
||||||
|
referenceUrl: string;
|
||||||
|
description: string;
|
||||||
|
image: File | null;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
useLogoColors: boolean;
|
||||||
|
logo: File | null;
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
theme: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConfigState = {
|
||||||
|
websiteType: 'ecommerce', // Pre-selected
|
||||||
|
style: null,
|
||||||
|
customInput: {
|
||||||
|
referenceUrl: '',
|
||||||
|
description: '',
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
useLogoColors: false,
|
||||||
|
logo: null,
|
||||||
|
primary: '#0A400C',
|
||||||
|
secondary: '#819067',
|
||||||
|
accent: '#B1AB86',
|
||||||
|
},
|
||||||
|
theme: 'light',
|
||||||
|
};
|
||||||
|
|
||||||
|
const websiteTypes: { id: WebsiteType; label: string; description: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'ecommerce',
|
||||||
|
label: 'eCommerce',
|
||||||
|
description: 'Online verkaufen – Bestellungen, Versand und mehr an einem Ort verwalten.',
|
||||||
|
icon: '🛒'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scheduling',
|
||||||
|
label: 'Scheduling',
|
||||||
|
description: 'Services anbieten, Buchungen annehmen, Zahlungen erhalten und Personal verwalten.',
|
||||||
|
icon: '📅'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'portfolio',
|
||||||
|
label: 'Portfolio',
|
||||||
|
description: 'Zeige deine Arbeit und gewinne neue Kunden mit einem Online-Portfolio.',
|
||||||
|
icon: '🎨'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blog',
|
||||||
|
label: 'Blog',
|
||||||
|
description: 'Erstelle einen Blog, um deine Community zu vergrößern und mehr Traffic zu generieren.',
|
||||||
|
icon: '📝'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'courses',
|
||||||
|
label: 'Online courses',
|
||||||
|
description: 'Erstelle, bewerbe und verkaufe Kurse und Coaching-Programme.',
|
||||||
|
icon: '🎓'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
label: 'Events',
|
||||||
|
description: 'Verkaufe Tickets, verwalte RSVPs und bewerbe Online- oder Vor-Ort-Events.',
|
||||||
|
icon: '🎉'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const styleTypes: { id: StyleType; label: string; description: string }[] = [
|
||||||
|
{ id: 'modern', label: 'Modern', description: 'Clean & zeitlos' },
|
||||||
|
{ id: 'bold', label: 'Bold', description: 'Auffällig & mutig' },
|
||||||
|
{ id: 'elegant', label: 'Elegant', description: 'Premium & hochwertig' },
|
||||||
|
{ id: 'minimal', label: 'Minimalistisch', description: 'Reduziert & klar' },
|
||||||
|
{ id: 'custom', label: 'Custom', description: 'Eigene Vorstellung' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Configurator() {
|
||||||
|
const [config, setConfig] = useState<ConfigState>(initialState);
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<ConfigState>) => {
|
||||||
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const canProceed = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return config.websiteType !== null;
|
||||||
|
case 2:
|
||||||
|
return config.style !== null;
|
||||||
|
case 3:
|
||||||
|
return true; // Custom input or colors - always have defaults
|
||||||
|
case 4:
|
||||||
|
return true; // Colors always have defaults
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine max steps based on whether custom style is selected
|
||||||
|
const maxSteps = config.style === 'custom' ? 4 : 3;
|
||||||
|
const isLastStep = currentStep === maxSteps ||
|
||||||
|
(currentStep === 3 && config.style !== 'custom') ||
|
||||||
|
(currentStep === 4 && config.style === 'custom');
|
||||||
|
|
||||||
|
// Show Checkout
|
||||||
|
if (showCheckout) {
|
||||||
|
return <Checkout config={config} onBack={() => setShowCheckout(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="konfigurator" className="py-16 sm:py-24 bg-tertiary/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header - Full Width Above Both Columns */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary">
|
||||||
|
Füge alles hinzu, was dein Business braucht
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps - Dynamic based on custom style */}
|
||||||
|
<div className="flex mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Array.from({ length: maxSteps }, (_, i) => i + 1).map((step) => (
|
||||||
|
<div key={step} className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => step < currentStep && setCurrentStep(step)}
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm transition-all ${
|
||||||
|
step === currentStep
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: step < currentStep
|
||||||
|
? 'bg-primary/30 text-primary cursor-pointer hover:bg-primary/40'
|
||||||
|
: 'bg-white/60 text-secondary border border-tertiary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step < currentStep ? '✓' : step}
|
||||||
|
</button>
|
||||||
|
{step < maxSteps && (
|
||||||
|
<div
|
||||||
|
className={`w-8 sm:w-12 h-0.5 mx-1 rounded ${
|
||||||
|
step < currentStep ? 'bg-primary/40' : 'bg-tertiary/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - Split Layout */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
||||||
|
{/* Left: Form - No background, buttons float */}
|
||||||
|
<div className="min-h-[500px]">
|
||||||
|
{/* Step 1: Website Type - Fixed corner radius, only height changes */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{websiteTypes.map((type) => {
|
||||||
|
const isSelected = config.websiteType === type.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => updateConfig({ websiteType: type.id })}
|
||||||
|
className={`w-full rounded-[1.75rem] cursor-pointer overflow-hidden transition-shadow duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-white shadow-lg'
|
||||||
|
: 'bg-white/70 hover:bg-white hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-primary text-lg">{type.label}</h3>
|
||||||
|
{/* Chevron Arrow - rotates when open */}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-primary transition-transform duration-300 ${isSelected ? 'rotate-180' : ''}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="41 68 118 65"
|
||||||
|
>
|
||||||
|
<path d="M41.149 73.893 47.04 68l53.028 53.039L153.107 68 159 73.893l-58.926 58.925-58.925-58.925Z" fillRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Expandable Content - smooth height animation */}
|
||||||
|
<div
|
||||||
|
className={`grid transition-[grid-template-rows] duration-300 ease-out ${
|
||||||
|
isSelected ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-6 pb-5 pt-2">
|
||||||
|
<p className="text-secondary text-sm leading-relaxed">
|
||||||
|
{type.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Style - Floating buttons like Step 1 */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{styleTypes.map((style) => {
|
||||||
|
const isSelected = config.style === style.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={style.id}
|
||||||
|
onClick={() => updateConfig({ style: style.id })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 flex items-center justify-between transition-all duration-300 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-primary text-xl block text-left">{style.label}</span>
|
||||||
|
<span className="text-secondary text-left block">{style.description}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-6 h-6 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Custom Input (only if custom style selected) */}
|
||||||
|
{currentStep === 3 && config.style === 'custom' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-6 shadow-lg space-y-4">
|
||||||
|
<p className="font-bold text-primary text-xl">Beschreibe deine Vorstellung</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="🔗 Referenz-Link (optional)"
|
||||||
|
value={config.customInput.referenceUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, referenceUrl: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="📝 Beschreibung (optional)"
|
||||||
|
value={config.customInput.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, description: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-tertiary/30 focus:border-primary focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-secondary font-medium">🖼️ Bild hochladen (optional)</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
customInput: { ...config.customInput, image: e.target.files?.[0] || null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-2 block w-full text-sm text-secondary file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:bg-primary file:text-white file:font-medium hover:file:bg-primary-light file:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 or 4: Colors - Floating buttons */}
|
||||||
|
{((currentStep === 3 && config.style !== 'custom') || currentStep === 4) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Logo Upload Toggle - as floating buttons */}
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ colors: { ...config.colors, useLogoColors: true } })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer text-center ${
|
||||||
|
config.colors.useLogoColors
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-3xl block mb-2">📁</span>
|
||||||
|
<span className="font-bold text-primary block">Logo hochladen</span>
|
||||||
|
<span className="text-sm text-secondary">Farben automatisch</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ colors: { ...config.colors, useLogoColors: false } })}
|
||||||
|
className={`w-full rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer text-center ${
|
||||||
|
!config.colors.useLogoColors
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-3xl block mb-2">🎨</span>
|
||||||
|
<span className="font-bold text-primary block">Farben wählen</span>
|
||||||
|
<span className="text-sm text-secondary">Manuell auswählen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logo Upload */}
|
||||||
|
{config.colors.useLogoColors && (
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-5 shadow-lg">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-semibold text-primary block mb-3">Logo hochladen</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
colors: { ...config.colors, logo: e.target.files?.[0] || null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="block w-full text-sm text-secondary file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:bg-primary file:text-white file:font-medium hover:file:bg-primary-light file:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Color Picker */}
|
||||||
|
{!config.colors.useLogoColors && (
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-5 shadow-lg">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Primär</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.primary}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, primary: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Sekundär</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.secondary}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, secondary: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-primary block mb-2">Akzent</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.colors.accent}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ colors: { ...config.colors, accent: e.target.value } })
|
||||||
|
}
|
||||||
|
className="w-full h-14 rounded-xl cursor-pointer border-0 shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Toggle - as floating buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ theme: 'light' })}
|
||||||
|
className={`rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer flex items-center justify-center gap-3 ${
|
||||||
|
config.theme === 'light'
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">☀️</span>
|
||||||
|
<span className="font-bold text-primary text-lg">Hell</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ theme: 'dark' })}
|
||||||
|
className={`rounded-[1.5rem] p-5 transition-all duration-300 cursor-pointer flex items-center justify-center gap-3 ${
|
||||||
|
config.theme === 'dark'
|
||||||
|
? 'bg-white shadow-xl ring-2 ring-primary'
|
||||||
|
: 'bg-white/80 hover:bg-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🌙</span>
|
||||||
|
<span className="font-bold text-primary text-lg">Dunkel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
{currentStep > 1 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep((prev) => Math.max(1, prev - 1))}
|
||||||
|
className="px-6 py-3 rounded-full font-medium bg-white text-primary shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{!isLastStep ? (
|
||||||
|
<button
|
||||||
|
onClick={() => canProceed() && setCurrentStep((prev) => prev + 1)}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
className={`px-8 py-3 rounded-full font-semibold transition-all ${
|
||||||
|
canProceed()
|
||||||
|
? 'bg-primary text-white hover:bg-primary-light hover:shadow-lg'
|
||||||
|
: 'bg-tertiary/30 text-secondary cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCheckout(true)}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>199€ – Jetzt kaufen</span>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 12 8">
|
||||||
|
<path d="M7.755 8H6.09l3.44-3.36H.155V3.488H9.53L6.09.128h1.664l4.096 3.936L7.755 8Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Live Preview - Matching Height */}
|
||||||
|
<div className="lg:sticky lg:top-24">
|
||||||
|
<div className="bg-background rounded-[2rem] overflow-hidden min-h-[500px]">
|
||||||
|
<ConfiguratorPreview config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
316
webklar/src/components/ConfiguratorPreview.tsx
Normal file
316
webklar/src/components/ConfiguratorPreview.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { ConfigState } from './Configurator';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ConfigState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const websiteTypeLabels: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
ecommerce: { title: 'Mein Shop', subtitle: 'Entdecke unsere Produkte' },
|
||||||
|
scheduling: { title: 'Termine buchen', subtitle: 'Finde deinen perfekten Termin' },
|
||||||
|
portfolio: { title: 'Meine Arbeiten', subtitle: 'Kreative Projekte & Designs' },
|
||||||
|
blog: { title: 'Mein Blog', subtitle: 'Gedanken & Geschichten' },
|
||||||
|
courses: { title: 'Lerne mit uns', subtitle: 'Online-Kurse für jeden' },
|
||||||
|
events: { title: 'Unsere Events', subtitle: 'Veranstaltungen & Termine' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfiguratorPreview({ config }: Props) {
|
||||||
|
const colors = {
|
||||||
|
primary: config.colors.primary,
|
||||||
|
secondary: config.colors.secondary,
|
||||||
|
accent: config.colors.accent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDark = config.theme === 'dark';
|
||||||
|
const bgColor = isDark ? '#1a1a2e' : '#ffffff';
|
||||||
|
const textColor = isDark ? '#ffffff' : colors.primary;
|
||||||
|
// Use secondary color in dark mode too, just lighten it
|
||||||
|
const mutedColor = isDark ? colors.secondary : colors.secondary;
|
||||||
|
|
||||||
|
// Style-based adjustments
|
||||||
|
const getBorderRadius = () => {
|
||||||
|
switch (config.style) {
|
||||||
|
case 'modern':
|
||||||
|
return '12px';
|
||||||
|
case 'bold':
|
||||||
|
return '0px';
|
||||||
|
case 'elegant':
|
||||||
|
return '4px';
|
||||||
|
case 'minimal':
|
||||||
|
return '8px';
|
||||||
|
default:
|
||||||
|
return '12px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFont = () => {
|
||||||
|
switch (config.style) {
|
||||||
|
case 'modern':
|
||||||
|
return "'DM Sans', sans-serif";
|
||||||
|
case 'bold':
|
||||||
|
return "'Arial Black', sans-serif";
|
||||||
|
case 'elegant':
|
||||||
|
return "'Playfair Display', serif";
|
||||||
|
case 'minimal':
|
||||||
|
return "'Helvetica Neue', sans-serif";
|
||||||
|
default:
|
||||||
|
return "'DM Sans', sans-serif";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeInfo = config.websiteType
|
||||||
|
? websiteTypeLabels[config.websiteType]
|
||||||
|
: { title: 'Deine Website', subtitle: 'Wähle einen Typ um die Preview zu sehen' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-3xl shadow-xl overflow-hidden transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#0d0d14' : '#ffffff',
|
||||||
|
border: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Preview Header - Browser Chrome */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 flex items-center gap-2 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#1a1a2e' : 'rgba(177,171,134,0.1)',
|
||||||
|
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-4">
|
||||||
|
<div
|
||||||
|
className="rounded-md px-3 py-1 text-xs text-center transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.1)' : '#ffffff',
|
||||||
|
color: isDark ? 'rgba(255,255,255,0.6)' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
www.deine-website.de
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium transition-colors duration-500"
|
||||||
|
style={{ color: isDark ? 'rgba(255,255,255,0.5)' : colors.secondary }}
|
||||||
|
>
|
||||||
|
Live Preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Content */}
|
||||||
|
<div
|
||||||
|
className="p-6 min-h-[400px] transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
fontFamily: getFont(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mock Navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg"
|
||||||
|
style={{ backgroundColor: colors.primary }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{['Home', 'Über', 'Kontakt'].map((item) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className="text-xs transition-colors"
|
||||||
|
style={{ color: mutedColor }}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3
|
||||||
|
className="text-2xl font-bold mb-2 transition-all"
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
fontFamily: getFont(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeInfo.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-sm mb-4 transition-colors"
|
||||||
|
style={{ color: mutedColor }}
|
||||||
|
>
|
||||||
|
{typeInfo.subtitle}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-white text-sm font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mehr erfahren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Cards - Always use selected colors */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-3 transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}15` : `${colors.accent}20`,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
border: `1px solid ${colors.accent}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-12 mb-2 transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}30`,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-3/4 rounded transition-colors"
|
||||||
|
style={{ backgroundColor: isDark ? `${colors.primary}60` : `${colors.primary}30` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-1/2 rounded mt-1 transition-colors"
|
||||||
|
style={{ backgroundColor: isDark ? `${colors.secondary}50` : `${colors.secondary}20` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific elements - Colors work in dark mode too */}
|
||||||
|
{config.websiteType === 'ecommerce' && (
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<span className="text-lg">🛒</span>
|
||||||
|
<span className="text-xs" style={{ color: isDark ? colors.accent : mutedColor }}>
|
||||||
|
Warenkorb (0)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'scheduling' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}30` : `${colors.primary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 Nächster freier Termin: Morgen, 10:00
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'blog' && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{['Tech', 'Design', 'Life'].map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}50` : `${colors.accent}30`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'events' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.accent}40` : `${colors.accent}20`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉 Nächstes Event: Samstag, 19:00
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'portfolio' && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{['Web', 'Print', 'Brand'].map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}20`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.websiteType === 'courses' && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-2 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}30` : `${colors.primary}10`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
borderRadius: getBorderRadius(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎓 12 Kurse verfügbar • 4.9 ⭐ Bewertung
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Summary */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(177,171,134,0.05)',
|
||||||
|
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(177,171,134,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{config.websiteType && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.primary}40` : `${colors.primary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{websiteTypeLabels[config.websiteType]?.title || config.websiteType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{config.style && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${colors.secondary}40` : `${colors.secondary}15`,
|
||||||
|
color: isDark ? '#ffffff' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.style}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded-full transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(177,171,134,0.2)',
|
||||||
|
color: isDark ? '#ffffff' : colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.theme === 'dark' ? '🌙 Dunkel' : '☀️ Hell'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
227
webklar/src/components/EditorPreview.tsx
Normal file
227
webklar/src/components/EditorPreview.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function EditorPreview() {
|
||||||
|
const [animationPhase, setAnimationPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setAnimationPhase((prev) => (prev + 1) % 4);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Decorative background elements */}
|
||||||
|
<div className="absolute -inset-4 bg-gradient-to-br from-primary/5 via-secondary/10 to-tertiary/20 rounded-3xl blur-2xl" />
|
||||||
|
|
||||||
|
{/* Browser Window */}
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-2xl shadow-primary/10 overflow-hidden border border-tertiary/30">
|
||||||
|
{/* Browser Header */}
|
||||||
|
<div className="bg-gradient-to-r from-gray-100 to-gray-50 px-4 py-3 flex items-center gap-3 border-b border-gray-200">
|
||||||
|
{/* Traffic lights */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||||
|
</div>
|
||||||
|
{/* URL Bar */}
|
||||||
|
<div className="flex-1 bg-white rounded-md px-3 py-1.5 text-sm text-gray-500 border border-gray-200">
|
||||||
|
www.ihre-website.de
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website Content Preview */}
|
||||||
|
<div className="p-6 bg-gradient-to-br from-background to-white min-h-[320px] relative">
|
||||||
|
{/* Mock Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="w-24 h-6 bg-primary/20 rounded" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="w-12 h-4 bg-tertiary/40 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area with Animation */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Left content */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="w-full h-4 bg-primary/30 rounded" />
|
||||||
|
<div className="w-4/5 h-4 bg-primary/20 rounded" />
|
||||||
|
<div className="w-3/5 h-4 bg-tertiary/40 rounded" />
|
||||||
|
<div className="mt-4 w-24 h-8 bg-primary rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Animated Element */}
|
||||||
|
<div className="relative">
|
||||||
|
<AnimatedElement phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom elements */}
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
<div className="h-16 bg-secondary/10 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animated Cursor */}
|
||||||
|
<AnimatedCursor phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor UI Overlay Elements */}
|
||||||
|
<EditorOverlays phase={animationPhase} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedElement({ phase }: { phase: number }) {
|
||||||
|
const sizes = [
|
||||||
|
{ width: '100%', height: '100px' },
|
||||||
|
{ width: '120%', height: '120px' },
|
||||||
|
{ width: '80%', height: '80px' },
|
||||||
|
{ width: '100%', height: '100px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 20, y: 10 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-br from-secondary/30 to-tertiary/40 rounded-xl relative transition-all duration-1000 ease-in-out"
|
||||||
|
style={{
|
||||||
|
width: sizes[phase].width,
|
||||||
|
height: sizes[phase].height,
|
||||||
|
transform: `translate(${positions[phase].x}px, ${positions[phase].y}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Resize handles - visible during resize phases */}
|
||||||
|
{(phase === 0 || phase === 1) && (
|
||||||
|
<>
|
||||||
|
<div className="absolute -right-1 -bottom-1 w-3 h-3 bg-primary border-2 border-white rounded-sm shadow-md" />
|
||||||
|
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-6 bg-primary/60 rounded-full" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-2 w-6 bg-primary/60 rounded-full" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection border */}
|
||||||
|
<div className="absolute inset-0 border-2 border-primary border-dashed rounded-xl opacity-60" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedCursor({ phase }: { phase: number }) {
|
||||||
|
const positions = [
|
||||||
|
{ x: '75%', y: '55%', action: 'resize' },
|
||||||
|
{ x: '85%', y: '65%', action: 'resize' },
|
||||||
|
{ x: '70%', y: '45%', action: 'drag' },
|
||||||
|
{ x: '75%', y: '50%', action: 'drag' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute transition-all duration-1000 ease-in-out pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
left: positions[phase].x,
|
||||||
|
top: positions[phase].y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cursor SVG */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="drop-shadow-lg"
|
||||||
|
>
|
||||||
|
{positions[phase].action === 'resize' ? (
|
||||||
|
// Resize cursor
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M14 10L20 4M20 4H14M20 4V10"
|
||||||
|
stroke="#0A400C"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 14L4 20M4 20H10M4 20V14"
|
||||||
|
stroke="#0A400C"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Move/drag cursor
|
||||||
|
<path
|
||||||
|
d="M3 3L10 21L12 12L21 10L3 3Z"
|
||||||
|
fill="#0A400C"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Action tooltip */}
|
||||||
|
<div className="absolute left-6 top-0 bg-primary text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-lg">
|
||||||
|
{positions[phase].action === 'resize' ? 'Größe anpassen' : 'Verschieben'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorOverlays({ phase }: { phase: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating toolbar */}
|
||||||
|
<div className="absolute -top-4 -right-4 bg-white rounded-xl shadow-xl p-2 flex gap-1 border border-tertiary/30">
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Properties panel hint */}
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-2 -left-2 bg-white rounded-lg shadow-lg p-3 border border-tertiary/30 transition-all duration-500 ${
|
||||||
|
phase === 1 || phase === 2 ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-secondary mb-1">Breite</div>
|
||||||
|
<div className="text-sm font-mono text-primary font-semibold">
|
||||||
|
{phase === 1 ? '420px' : phase === 2 ? '280px' : '350px'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layers indicator */}
|
||||||
|
<div className="absolute top-1/2 -left-6 transform -translate-y-1/2">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-2 border border-tertiary/30">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="w-4 h-1 bg-primary rounded" />
|
||||||
|
<div className="w-4 h-1 bg-secondary rounded" />
|
||||||
|
<div className="w-4 h-1 bg-tertiary rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
104
webklar/src/components/Header.tsx
Normal file
104
webklar/src/components/Header.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Logo } from './Logo';
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ name: 'Über uns', href: '#about' },
|
||||||
|
{ name: 'Leistungen', href: '#services' },
|
||||||
|
{ name: 'Kontakt', href: '#contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-tertiary/30">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16 sm:h-20">
|
||||||
|
{/* Logo */}
|
||||||
|
<a href="/" className="flex items-center gap-2 group">
|
||||||
|
<Logo size={36} className="transition-transform group-hover:scale-105" />
|
||||||
|
<span className="font-heading text-xl sm:text-2xl font-semibold text-primary">
|
||||||
|
webklar
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
className="text-secondary hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="bg-primary hover:bg-primary-light text-white px-5 py-2.5 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Projekt starten
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="md:hidden p-2 text-primary"
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<nav className="md:hidden py-4 border-t border-tertiary/30">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
className="text-secondary hover:text-primary transition-colors font-medium py-2"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="bg-primary hover:bg-primary-light text-white px-5 py-2.5 rounded-lg font-medium transition-colors text-center mt-2"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Projekt starten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
92
webklar/src/components/Hero.tsx
Normal file
92
webklar/src/components/Hero.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { EditorPreview } from './EditorPreview';
|
||||||
|
|
||||||
|
const serviceTags = ['Website', 'Backend', 'Hosting', 'Support'];
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen pt-20 sm:pt-24 flex items-center">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
{/* Left Side - Text Content */}
|
||||||
|
<div className="order-2 lg:order-1">
|
||||||
|
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight mb-4">
|
||||||
|
webklar – das Web{' '}
|
||||||
|
<span className="text-secondary">maßgeschneidert</span> auf Ihr
|
||||||
|
Unternehmen
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl sm:text-2xl text-secondary-dark mb-6 max-w-xl leading-relaxed">
|
||||||
|
Wir übernehmen die Technik, Sie konzentrieren sich aufs Geschäft.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Service Tags */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
|
{serviceTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-4 py-2 bg-tertiary/30 text-primary rounded-full text-sm font-medium border border-tertiary/50"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price Badge */}
|
||||||
|
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/20 rounded-2xl px-5 py-3 mb-8">
|
||||||
|
<span className="text-3xl font-bold text-primary">199€</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-semibold text-primary">Festpreis</p>
|
||||||
|
<p className="text-xs text-secondary">Lieferung in 48h</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<a
|
||||||
|
href="#konfigurator"
|
||||||
|
className="inline-flex items-center justify-center bg-primary hover:bg-primary-light text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all hover:shadow-lg hover:shadow-primary/20 hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
Jetzt konfigurieren
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#projects"
|
||||||
|
className="inline-flex items-center justify-center border-2 border-secondary text-secondary hover:bg-secondary hover:text-white px-8 py-4 rounded-xl font-semibold text-lg transition-all hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
Projekte ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust indicators */}
|
||||||
|
<div className="mt-10 pt-6 border-t border-tertiary/40">
|
||||||
|
<div className="flex flex-wrap gap-6 text-secondary-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>100% individuell</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>AI-gestützt</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Keine Templates</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Editor Preview */}
|
||||||
|
<div className="order-1 lg:order-2">
|
||||||
|
<EditorPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
72
webklar/src/components/Logo.tsx
Normal file
72
webklar/src/components/Logo.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ className = '', size = 40 }: LogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
aria-label="webklar Logo"
|
||||||
|
>
|
||||||
|
{/* Left V-shape (lighter green) */}
|
||||||
|
<path
|
||||||
|
d="M70 120 L70 280 L165 375 L165 320 L110 265 L110 120 Z"
|
||||||
|
fill="url(#gradient-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center chevron (medium green) */}
|
||||||
|
<path
|
||||||
|
d="M165 215 L250 130 L335 215 L335 270 L250 185 L165 270 Z"
|
||||||
|
fill="url(#gradient-center)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right V-shape (darker green) */}
|
||||||
|
<path
|
||||||
|
d="M430 120 L430 280 L335 375 L335 320 L390 265 L390 120 Z"
|
||||||
|
fill="url(#gradient-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom connection left */}
|
||||||
|
<path
|
||||||
|
d="M165 320 L165 375 L250 460 L250 405 Z"
|
||||||
|
fill="url(#gradient-bottom-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom connection right */}
|
||||||
|
<path
|
||||||
|
d="M335 320 L335 375 L250 460 L250 405 Z"
|
||||||
|
fill="url(#gradient-bottom-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#9AA67F" />
|
||||||
|
<stop offset="100%" stopColor="#0F5010" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-center" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0F5010" />
|
||||||
|
<stop offset="100%" stopColor="#0A400C" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0A400C" />
|
||||||
|
<stop offset="100%" stopColor="#052006" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-left" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0F5010" />
|
||||||
|
<stop offset="100%" stopColor="#0A400C" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient-bottom-right" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0A400C" />
|
||||||
|
<stop offset="100%" stopColor="#052006" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
webklar/src/components/OrderConfirmation.tsx
Normal file
103
webklar/src/components/OrderConfirmation.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
interface Props {
|
||||||
|
orderNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderConfirmation({ orderNumber = 'WK-2026-0001', email = 'kunde@beispiel.de', onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-12 h-12 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary mb-4">
|
||||||
|
Vielen Dank! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-secondary mb-8">
|
||||||
|
Deine Bestellung wurde erfolgreich aufgegeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Order Card */}
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-8 shadow-lg mb-8 text-left">
|
||||||
|
<div className="flex justify-between items-center pb-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestellnummer</span>
|
||||||
|
<span className="font-mono font-bold text-primary">{orderNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-6 border-b border-tertiary/20">
|
||||||
|
<h3 className="font-bold text-primary text-lg mb-4">Was passiert jetzt?</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Bestätigung per E-Mail</p>
|
||||||
|
<p className="text-secondary text-sm">Du erhältst eine E-Mail an {email} mit allen Details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Wir starten sofort</p>
|
||||||
|
<p className="text-secondary text-sm">Unser Team beginnt direkt mit der Umsetzung deiner Website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Lieferung in 48h</p>
|
||||||
|
<p className="text-secondary text-sm">Du bekommst deine fertige Website spätestens in 48 Stunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-between">
|
||||||
|
<span className="text-secondary text-sm">Fragen? support@webklar.de</span>
|
||||||
|
<span className="text-2xl">📧</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown Timer (visual only) */}
|
||||||
|
<div className="mt-12 bg-white/50 rounded-2xl p-6">
|
||||||
|
<p className="text-secondary text-sm mb-2">Voraussichtliche Lieferung</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">48</div>
|
||||||
|
<div className="text-xs text-secondary">Stunden</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-primary">:</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">00</div>
|
||||||
|
<div className="text-xs text-secondary">Minuten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
183
webklar/src/components/SuccessPage.tsx
Normal file
183
webklar/src/components/SuccessPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { verifySession, type SessionData } from '../lib/stripe';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuccessPage({ onClose }: Props) {
|
||||||
|
const [session, setSession] = useState<SessionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sessionId = params.get('session_id');
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
verifySession(sessionId)
|
||||||
|
.then((data) => {
|
||||||
|
setSession(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Session verification failed:', err);
|
||||||
|
setError('Bestellung konnte nicht verifiziert werden');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate order number
|
||||||
|
const orderNumber = session?.id
|
||||||
|
? `WK-${new Date().getFullYear()}-${session.id.slice(-6).toUpperCase()}`
|
||||||
|
: 'WK-2026-XXXXX';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="animate-spin w-12 h-12 text-primary mx-auto mb-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-secondary">Bestellung wird verifiziert...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-red-100 rounded-full flex items-center justify-center mb-8">
|
||||||
|
<svg className="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-primary mb-4">Etwas ist schiefgelaufen</h1>
|
||||||
|
<p className="text-secondary mb-8">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 sm:py-24 bg-tertiary/20 min-h-screen flex items-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-primary/10 rounded-full flex items-center justify-center animate-bounce-once">
|
||||||
|
<svg className="w-12 h-12 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="font-heading text-3xl sm:text-4xl lg:text-5xl font-bold text-primary mb-4">
|
||||||
|
Vielen Dank! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-secondary mb-8">
|
||||||
|
Deine Zahlung war erfolgreich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Order Card */}
|
||||||
|
<div className="bg-white rounded-[1.5rem] p-8 shadow-lg mb-8 text-left">
|
||||||
|
<div className="flex justify-between items-center pb-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestellnummer</span>
|
||||||
|
<span className="font-mono font-bold text-primary">{orderNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<div className="flex justify-between items-center py-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Betrag</span>
|
||||||
|
<span className="font-bold text-primary">
|
||||||
|
{(session.amountTotal / 100).toFixed(2)}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.customerEmail && (
|
||||||
|
<div className="flex justify-between items-center py-4 border-b border-tertiary/20">
|
||||||
|
<span className="text-secondary">Bestätigung an</span>
|
||||||
|
<span className="font-semibold text-primary">{session.customerEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="py-6 border-b border-tertiary/20">
|
||||||
|
<h3 className="font-bold text-primary text-lg mb-4">Was passiert jetzt?</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Bestätigung per E-Mail</p>
|
||||||
|
<p className="text-secondary text-sm">Du erhältst in Kürze eine E-Mail mit allen Details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Wir starten sofort</p>
|
||||||
|
<p className="text-secondary text-sm">Unser Team beginnt direkt mit der Umsetzung deiner Website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-primary">Lieferung in 48h</p>
|
||||||
|
<p className="text-secondary text-sm">Du bekommst deine fertige Website spätestens in 48 Stunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-between">
|
||||||
|
<span className="text-secondary text-sm">Fragen? support@webklar.de</span>
|
||||||
|
<span className="text-2xl">📧</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-3 rounded-full font-semibold bg-primary text-white hover:bg-primary-light hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Countdown Timer */}
|
||||||
|
<div className="mt-12 bg-white/50 rounded-2xl p-6">
|
||||||
|
<p className="text-secondary text-sm mb-2">Voraussichtliche Lieferung</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">48</div>
|
||||||
|
<div className="text-xs text-secondary">Stunden</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-primary">:</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-primary">00</div>
|
||||||
|
<div className="text-xs text-secondary">Minuten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
10
webklar/src/components/index.ts
Normal file
10
webklar/src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { Logo } from './Logo';
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { Hero } from './Hero';
|
||||||
|
export { EditorPreview } from './EditorPreview';
|
||||||
|
export { Configurator } from './Configurator';
|
||||||
|
export { ConfiguratorPreview } from './ConfiguratorPreview';
|
||||||
|
export { Checkout } from './Checkout';
|
||||||
|
export { OrderConfirmation } from './OrderConfirmation';
|
||||||
|
export { SuccessPage } from './SuccessPage';
|
||||||
|
|
||||||
33
webklar/src/index.css
Normal file
33
webklar/src/index.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* webklar Custom Theme */
|
||||||
|
@theme {
|
||||||
|
--color-primary: #0A400C;
|
||||||
|
--color-primary-light: #0F5010;
|
||||||
|
--color-primary-dark: #052006;
|
||||||
|
--color-secondary: #819067;
|
||||||
|
--color-secondary-light: #9AA67F;
|
||||||
|
--color-secondary-dark: #6B7A58;
|
||||||
|
--color-tertiary: #B1AB86;
|
||||||
|
--color-background: #FEFAE0;
|
||||||
|
|
||||||
|
--font-family-heading: "Playfair Display", Georgia, serif;
|
||||||
|
--font-family-body: "DM Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Styles */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-body);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
}
|
||||||
98
webklar/src/lib/stripe.ts
Normal file
98
webklar/src/lib/stripe.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
|
||||||
|
// Stripe Configuration
|
||||||
|
export const STRIPE_CONFIG = {
|
||||||
|
publishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
|
||||||
|
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stripe instance (lazy loaded)
|
||||||
|
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||||
|
|
||||||
|
export function getStripe() {
|
||||||
|
if (!stripePromise && STRIPE_CONFIG.publishableKey) {
|
||||||
|
stripePromise = loadStripe(STRIPE_CONFIG.publishableKey);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface OrderData {
|
||||||
|
websiteType: string;
|
||||||
|
style: string;
|
||||||
|
theme: string;
|
||||||
|
colors: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
customInput?: {
|
||||||
|
referenceUrl: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
contact: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutResponse {
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
customerEmail: string;
|
||||||
|
amountTotal: number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session via backend API
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(orderData: OrderData): Promise<CheckoutResponse> {
|
||||||
|
const response = await fetch(`${STRIPE_CONFIG.apiUrl}/api/checkout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ orderData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.details || error.error || 'Failed to create checkout session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to Stripe Checkout
|
||||||
|
*/
|
||||||
|
export async function redirectToCheckout(orderData: OrderData): Promise<void> {
|
||||||
|
const { url } = await createCheckoutSession(orderData);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
throw new Error('No checkout URL returned');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a checkout session (for success page)
|
||||||
|
*/
|
||||||
|
export async function verifySession(sessionId: string): Promise<SessionData> {
|
||||||
|
const response = await fetch(`${STRIPE_CONFIG.apiUrl}/api/session/${sessionId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
10
webklar/src/main.tsx
Normal file
10
webklar/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
28
webklar/tsconfig.app.json
Normal file
28
webklar/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
webklar/tsconfig.json
Normal file
7
webklar/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
webklar/tsconfig.node.json
Normal file
26
webklar/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
8
webklar/vite.config.ts
Normal file
8
webklar/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user