first commit

This commit is contained in:
Web-serfer 2026-03-29 17:24:16 +05:00
commit 0065c017e4
496 changed files with 54265 additions and 0 deletions

26
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
/generated/prisma

30
frontend/Dockerfile Normal file
View file

@ -0,0 +1,30 @@
# Используем официальный образ Bun через зеркало
FROM dockerhub.timeweb.cloud/oven/bun:alpine AS build
WORKDIR /app
# Копируем файлы зависимостей
COPY package.json bun.lockb* ./
RUN bun install
# Копируем проект и собираем
COPY . .
RUN bun run build
# Финальный образ
FROM dockerhub.timeweb.cloud/oven/bun:alpine
WORKDIR /app
# Копируем билд из предыдущего этапа
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
# Указываем порт
EXPOSE 4321
# Переменные окружения для Coolify (ВАЖНО!)
ENV HOST=0.0.0.0
ENV PORT=4321
# Запуск через Bun
CMD ["bun", "./dist/server/entry.mjs"]

65
frontend/README.md Normal file
View file

@ -0,0 +1,65 @@
# Локальное тестирование почтового сервера
Для тестирования почтового сервера в локальной среде используется maildev.
## Запуск локального почтового сервера
1. Установите maildev (уже установлен как dev зависимость):
```bash
bun install
```
2. Запустите maildev:
```bash
bun run maildev
```
Это запустит SMTP сервер на порту 1025 и веб-интерфейс на порту 1080.
## Запуск всего проекта с почтовым сервером
Вы можете запустить весь проект (frontend, backend и maildev) одной командой:
```bash
bun run dev:all
```
## Проверка работы почтового сервера
1. Откройте веб-интерфейс maildev по адресу: http://localhost:1080
2. Отправьте тестовое письмо через API вашего приложения
3. Проверьте, что письмо появилось в веб-интерфейсе maildev
## API для отправки писем
- `POST /api/send-email` - отправка произвольного письма
- `POST /api/send-booking-confirmation` - отправка подтверждения бронирования
- `POST /api/send-admin-notification` - уведомление администратора
Пример запроса:
```javascript
fetch('/api/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: 'test@example.com',
subject: 'Тестовое письмо',
html: '<p>Это тестовое письмо</p>'
})
})
```
## Конфигурация
Локальная конфигурация находится в `.env` файле:
```env
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_FROM=no-reply@minv-berlin.de
RECIPIENT_EMAIL=test@minv-berlin.de
```
Для продакшена раскомментируйте соответствующие строки SMTP настроек.

21
frontend/astro.config.mjs Normal file
View file

@ -0,0 +1,21 @@
// @ts-check
import { defineConfig } from 'astro/config';
import solidJs from '@astrojs/solid-js';
import tailwindcss from '@tailwindcss/vite';
import { default as astroIcon } from 'astro-icon';
import mdx from '@astrojs/mdx';
import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({
site: 'https://minivan-berlin.de',
integrations: [astroIcon(), solidJs(), mdx(), sitemap()],
prefetch: true,
vite: {
plugins: [tailwindcss()],
envPrefix: ['MAIL_', 'SMTP_', 'SENDER_', 'ADMIN_'],
},
output: 'server',
adapter: node({ mode: 'standalone' }),
});

1265
frontend/bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neue Buchungsanfrage - Premium</title>
</head>
<body style="margin: 0; padding: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f3f4f6; color: #1f2937;">
<!-- Главный контейнер -->
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-color: #f3f4f6; padding: 40px 0;">
<tr>
<td align="center">
<!-- Карточка -->
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); max-width: 100%; overflow: hidden; border: 1px solid #e5e7eb;">
<!-- Шапка -->
<tr>
<td style="background: linear-gradient(135deg, #2563eb, #1d4ed8); padding: 40px 40px 20px 40px; text-align: center; color: white;">
<h1 style="margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px; text-transform: uppercase;">
MINIVAN <span style="color: #ffffff;">BERLIN</span>
</h1>
<h2 style="margin: 10px 0 0 0; font-size: 20px; font-weight: 600; color: #ffffff;">Neue Buchungsanfrage - Premium</h2>
</td>
</tr>
<!-- Основной контейнер контента -->
<tr>
<td style="padding: 40px 40px;">
<!-- Карточка с информацией о транспорте и скидке -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; background-color: #fef3c7; border-left: 4px solid #d97706; border-radius: 6px; padding: 16px;">
<tr>
<td style="padding: 0;">
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%;">
<tr>
<td style="width: 50%; padding: 4px 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Fahrzeug</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{carDetails}</p>
</td>
<td style="width: 50%; padding: 4px 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Rabatt</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{discount}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Информация об отъезде -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; background-color: #fef2f2; border-left: 4px solid #dc2626; border-radius: 6px; padding: 16px; margin: 16px 0;">
<tr>
<td style="padding: 0;">
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%;">
<tr>
<td style="width: 50%; padding: 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Abholung</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{pickupDate} um {pickupTime}</p>
</td>
<td style="width: 50%; padding: 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Von</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{pickup}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Информация о прибытии -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; background-color: #fef2f2; border-left: 4px solid #dc2626; border-radius: 6px; padding: 16px; margin: 16px 0;">
<tr>
<td style="padding: 0;">
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%;">
<tr>
<td style="width: 50%; padding: 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Rückgabe</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{dropoffDate} um {dropoffTime}</p>
</td>
<td style="width: 50%; padding: 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Nach</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{dropoff}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Разделитель -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; margin: 20px 0;">
<tr>
<td style="border-bottom: 1px solid #e5e7eb; height: 1px; font-size: 0;"></td>
</tr>
</table>
<!-- Контактная информация -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%;">
<tr>
<td style="width: 50%; padding: 4px 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Passagiere</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{passengers}</p>
</td>
<td style="width: 50%; padding: 4px 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Name</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{name}</p>
</td>
</tr>
</table>
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; margin: 16px 0;">
<tr>
<td style="width: 100%; padding: 4px 8px;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Telefon</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #1f2937;">{phone}</p>
</td>
</tr>
</table>
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="width: 100%; background-color: #f8fafc; border-radius: 6px; padding: 16px;">
<tr>
<td style="padding: 0;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Zusätzliche Informationen</p>
<p style="margin: 8px 0 0 0; font-size: 16px; font-weight: 400; color: #1f2937;">{additionalInfo}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Подвал -->
<tr>
<td style="background-color: #f9fafb; padding: 24px; text-align: center; font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 8px 0;">&copy; Minivan Berlin Transfers.</p>
<p style="margin: 0;">
Diese Nachricht wurde über das Buchungsformular auf Ihrer Website gesendet.
</p>
</td>
</tr>
</table>
<!-- Отступ снизу -->
<table role="presentation" border="0" cellspacing="0" cellpadding="0" style="height: 40px;"><tr><td></td></tr></table>
</td>
</tr>
</table>
</body>
</html>

32
frontend/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "astro_minivan",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "5.0.2",
"@astrojs/node": "10.0.3",
"@astrojs/sitemap": "3.7.1",
"@astrojs/solid-js": "6.0.1",
"@tailwindcss/vite": "^4.1.17",
"astro": "6.0.8",
"flatpickr": "^4.6.13",
"imask": "^7.6.1",
"nodemailer": "^6.9.16",
"pocketbase": "^0.26.8",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.10",
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@types/nodemailer": "^7.0.4",
"astro-icon": "1.1.5",
"typescript": "^5.9.3"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -9 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="57" height="39" rx="3.5" fill="#006FCF" stroke="#F3F3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.8632 28.8937V20.6592H21.1869L22.1872 21.8787L23.2206 20.6592H57.0632V28.3258C57.0632 28.3258 56.1782 28.8855 55.1546 28.8937H36.4152L35.2874 27.5957V28.8937H31.5916V26.6779C31.5916 26.6779 31.0867 26.9872 29.9953 26.9872H28.7373V28.8937H23.1415L22.1426 27.6481L21.1284 28.8937H11.8632ZM1 14.4529L3.09775 9.86914H6.7256L7.9161 12.4368V9.86914H12.4258L13.1346 11.7249L13.8216 9.86914H34.0657V10.8021C34.0657 10.8021 35.1299 9.86914 36.8789 9.86914L43.4474 9.89066L44.6173 12.4247V9.86914H48.3913L49.43 11.3247V9.86914H53.2386V18.1037H49.43L48.4346 16.6434V18.1037H42.8898L42.3321 16.8056H40.8415L40.293 18.1037H36.5327C35.0277 18.1037 34.0657 17.1897 34.0657 17.1897V18.1037H28.3961L27.2708 16.8056V18.1037H6.18816L5.63093 16.8056H4.14505L3.59176 18.1037H1V14.4529ZM1.01082 17.05L3.84023 10.8843H5.98528L8.81199 17.05H6.92932L6.40997 15.8154H3.37498L2.85291 17.05H1.01082ZM5.81217 14.4768L4.88706 12.3192L3.95925 14.4768H5.81217ZM9.00675 17.049V10.8832L11.6245 10.8924L13.147 14.8676L14.6331 10.8832H17.2299V17.049H15.5853V12.5058L13.8419 17.049H12.3996L10.6514 12.5058V17.049H9.00675ZM18.3552 17.049V10.8832H23.7219V12.2624H20.0171V13.3171H23.6353V14.6151H20.0171V15.7104H23.7219V17.049H18.3552ZM24.674 17.05V10.8843H28.3339C29.5465 10.8843 30.6331 11.5871 30.6331 12.8846C30.6331 13.9938 29.717 14.7082 28.8289 14.7784L30.9929 17.05H28.9831L27.0111 14.8596H26.3186V17.05H24.674ZM28.1986 12.2635H26.3186V13.5615H28.223C28.5526 13.5615 28.9776 13.3221 28.9776 12.9125C28.9776 12.5941 28.6496 12.2635 28.1986 12.2635ZM32.9837 17.049H31.3045V10.8832H32.9837V17.049ZM36.9655 17.049H36.603C34.8492 17.049 33.7844 15.754 33.7844 13.9915C33.7844 12.1854 34.8373 10.8832 37.052 10.8832H38.8698V12.3436H36.9856C36.0865 12.3436 35.4507 13.0012 35.4507 14.0067C35.4507 15.2008 36.1777 15.7023 37.2251 15.7023H37.6579L36.9655 17.049ZM37.7147 17.05L40.5441 10.8843H42.6892L45.5159 17.05H43.6332L43.1139 15.8154H40.0789L39.5568 17.05H37.7147ZM42.5161 14.4768L41.591 12.3192L40.6632 14.4768H42.5161ZM45.708 17.049V10.8832H47.7989L50.4687 14.7571V10.8832H52.1134V17.049H50.09L47.3526 13.0737V17.049H45.708ZM12.9885 27.8391V21.6733H18.3552V23.0525H14.6504V24.1072H18.2686V25.4052H14.6504V26.5005H18.3552V27.8391H12.9885ZM39.2853 27.8391V21.6733H44.6519V23.0525H40.9472V24.1072H44.5481V25.4052H40.9472V26.5005H44.6519V27.8391H39.2853ZM18.5635 27.8391L21.1765 24.7942L18.5012 21.6733H20.5733L22.1665 23.6026L23.7651 21.6733H25.756L23.1159 24.7562L25.7338 27.8391H23.6621L22.1151 25.9402L20.6057 27.8391H18.5635ZM25.9291 27.8401V21.6744H29.5619C31.0525 21.6744 31.9234 22.5748 31.9234 23.7482C31.9234 25.1647 30.8131 25.893 29.3482 25.893H27.617V27.8401H25.9291ZM29.4402 23.0687H27.617V24.4885H29.4348C29.9151 24.4885 30.2517 24.1901 30.2517 23.7786C30.2517 23.3406 29.9134 23.0687 29.4402 23.0687ZM32.6375 27.8391V21.6733H36.2973C37.51 21.6733 38.5966 22.3761 38.5966 23.6736C38.5966 24.7828 37.6805 25.4972 36.7923 25.5675L38.9563 27.8391H36.9465L34.9746 25.6486H34.2821V27.8391H32.6375ZM36.1621 23.0525H34.2821V24.3505H36.1864C36.5161 24.3505 36.9411 24.1112 36.9411 23.7015C36.9411 23.3831 36.6131 23.0525 36.1621 23.0525ZM45.4137 27.8391V26.5005H48.7051C49.1921 26.5005 49.403 26.2538 49.403 25.9833C49.403 25.7241 49.1928 25.462 48.7051 25.462H47.2177C45.9249 25.462 45.2048 24.7237 45.2048 23.6153C45.2048 22.6267 45.8642 21.6733 47.7854 21.6733H50.9881L50.2956 23.0606H47.5257C46.9962 23.0606 46.8332 23.321 46.8332 23.5697C46.8332 23.8253 47.0347 24.1072 47.4392 24.1072H48.9972C50.4384 24.1072 51.0638 24.8734 51.0638 25.8768C51.0638 26.9555 50.367 27.8391 48.9188 27.8391H45.4137ZM51.2088 27.8391V26.5005H54.5002C54.9873 26.5005 55.1981 26.2538 55.1981 25.9833C55.1981 25.7241 54.9879 25.462 54.5002 25.462H53.0129C51.72 25.462 51 24.7237 51 23.6153C51 22.6267 51.6594 21.6733 53.5806 21.6733H56.7833L56.0908 23.0606H53.3209C52.7914 23.0606 52.6284 23.321 52.6284 23.5697C52.6284 23.8253 52.8298 24.1072 53.2343 24.1072H54.7924C56.2336 24.1072 56.859 24.8734 56.859 25.8768C56.859 26.9555 56.1621 27.8391 54.7139 27.8391H51.2088Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -9 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="57" height="39" rx="3.5" fill="white" stroke="#F3F3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4388 20.2562L26.6913 18.6477L26.1288 18.6346H23.4429L25.3095 6.76505C25.3153 6.72911 25.3341 6.69575 25.3616 6.67201C25.3892 6.64827 25.4243 6.63525 25.4611 6.63525H29.9901C31.4937 6.63525 32.5313 6.94897 33.073 7.56826C33.327 7.85879 33.4887 8.16246 33.567 8.49653C33.6491 8.84713 33.6505 9.26596 33.5704 9.77689L33.5646 9.81405V10.1415L33.8186 10.2858C34.0324 10.3996 34.2024 10.5298 34.3328 10.6788C34.55 10.9273 34.6905 11.2431 34.7499 11.6173C34.8113 12.0022 34.791 12.4604 34.6905 12.979C34.5746 13.5755 34.3873 14.0951 34.1343 14.5202C33.9016 14.9119 33.6052 15.2369 33.2531 15.4886C32.9171 15.7279 32.5178 15.9095 32.0664 16.0257C31.6288 16.1399 31.1301 16.1975 30.583 16.1975H30.2305C29.9786 16.1975 29.7338 16.2886 29.5416 16.4517C29.3489 16.6183 29.2215 16.8459 29.1824 17.0947L29.1558 17.2396L28.7096 20.0747L28.6894 20.1787C28.684 20.2117 28.6748 20.2281 28.6613 20.2392C28.6493 20.2494 28.632 20.2562 28.615 20.2562H26.4388" fill="#28356A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.0589 9.85181C34.0455 9.93848 34.03 10.027 34.0126 10.1181C33.4154 13.1934 31.372 14.2558 28.7623 14.2558H27.4335C27.1143 14.2558 26.8453 14.4881 26.7957 14.8038L25.9227 20.3573C25.8904 20.5647 26.0497 20.7514 26.2582 20.7514H28.615C28.894 20.7514 29.1311 20.5481 29.1751 20.2721L29.1982 20.1521L29.6419 17.3281L29.6705 17.1732C29.7139 16.8962 29.9515 16.6928 30.2305 16.6928H30.583C32.8663 16.6928 34.6538 15.7632 35.1763 13.0728C35.3944 11.9489 35.2815 11.0105 34.704 10.3505C34.5293 10.1516 34.3125 9.98635 34.0589 9.85181" fill="#298FC2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.4342 9.60206C33.3429 9.57534 33.2488 9.5512 33.1522 9.52936C33.0551 9.50807 32.9557 9.48922 32.8533 9.47267C32.4951 9.41462 32.1025 9.38708 31.682 9.38708H28.1322C28.0447 9.38708 27.9617 9.40689 27.8874 9.44269C27.7236 9.52163 27.602 9.67707 27.5726 9.86736L26.8174 14.6641L26.7957 14.8039C26.8454 14.4882 27.1144 14.2558 27.4335 14.2558H28.7623C31.372 14.2558 33.4154 13.1929 34.0127 10.1181C34.0305 10.0271 34.0455 9.93856 34.0589 9.85189C33.9078 9.77146 33.7442 9.7027 33.568 9.64411C33.5244 9.62959 33.4795 9.61562 33.4342 9.60206" fill="#22284F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.5726 9.86737C27.6021 9.67708 27.7236 9.52165 27.8874 9.44325C27.9622 9.40731 28.0447 9.38751 28.1322 9.38751H31.682C32.1025 9.38751 32.4951 9.41518 32.8534 9.47323C32.9557 9.48964 33.0551 9.50863 33.1522 9.52992C33.2488 9.55162 33.3429 9.5759 33.4342 9.60248C33.4795 9.61605 33.5244 9.63015 33.5684 9.64412C33.7446 9.70272 33.9084 9.77202 34.0595 9.85191C34.2372 8.71545 34.058 7.94168 33.4453 7.241C32.7698 6.46953 31.5507 6.1394 29.9906 6.1394H25.4615C25.1429 6.1394 24.8711 6.37174 24.8218 6.68803L22.9354 18.6796C22.8982 18.9168 23.0807 19.1309 23.3193 19.1309H26.1153L27.5726 9.86737" fill="#28356A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0946 23.5209H9.79248C9.56648 23.5209 9.3743 23.6855 9.339 23.9093L8.00345 32.4009C7.97695 32.5686 8.10638 32.7195 8.27584 32.7195H9.85225C10.0782 32.7195 10.2704 32.555 10.3057 32.3308L10.6659 30.0404C10.7006 29.8162 10.8932 29.6516 11.1188 29.6516H12.1641C14.3393 29.6516 15.5946 28.5959 15.9226 26.5042C16.0703 25.589 15.9288 24.87 15.5014 24.3664C15.0321 23.8134 14.1997 23.5209 13.0946 23.5209ZM13.4755 26.6224C13.2949 27.8106 12.3896 27.8106 11.5143 27.8106H11.0159L11.3655 25.5914C11.3863 25.4573 11.5021 25.3585 11.6374 25.3585H11.8658C12.4621 25.3585 13.0246 25.3585 13.3152 25.6994C13.4886 25.9027 13.5416 26.2049 13.4755 26.6224Z" fill="#28356A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.0496 26.5199H21.4683C21.3336 26.5199 21.2171 26.6187 21.1964 26.7528L21.1264 27.1963L21.0159 27.0356C20.6736 26.5373 19.9101 26.3707 19.1483 26.3707C17.4008 26.3707 15.9084 27.698 15.6177 29.5598C15.4666 30.4885 15.6814 31.3766 16.2068 31.9959C16.6887 32.5653 17.3782 32.8026 18.1985 32.8026C19.6065 32.8026 20.3871 31.8947 20.3871 31.8947L20.3167 32.3354C20.2902 32.5038 20.4196 32.6549 20.5881 32.6549H22.0124C22.2389 32.6549 22.4301 32.4903 22.4659 32.2661L23.3205 26.8385C23.3475 26.6714 23.2185 26.5199 23.0496 26.5199ZM20.8453 29.6064C20.6928 30.5122 19.9759 31.1204 19.0613 31.1204C18.6022 31.1204 18.2353 30.9727 17.9995 30.6929C17.7658 30.415 17.6771 30.0194 17.7513 29.5787C17.8939 28.6805 18.6229 28.0524 19.5235 28.0524C19.9725 28.0524 20.3375 28.2022 20.578 28.4843C20.8188 28.7695 20.9145 29.1676 20.8453 29.6064Z" fill="#28356A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3495 26.6556H29.7604C29.6088 26.6556 29.4664 26.7312 29.3805 26.8576L27.1888 30.095L26.2598 26.9839C26.2014 26.7892 26.0223 26.6556 25.8195 26.6556H24.2581C24.0682 26.6556 23.9365 26.8416 23.9968 27.0208L25.7471 32.1718L24.1016 34.5014C23.9722 34.6849 24.1025 34.9372 24.3261 34.9372H25.9132C26.0639 34.9372 26.2048 34.8635 26.2903 34.7397L31.5754 27.089C31.702 26.906 31.572 26.6556 31.3495 26.6556" fill="#28356A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6469 23.5209H33.3444C33.1189 23.5209 32.9267 23.6855 32.8914 23.9093L31.5559 32.4009C31.5294 32.5686 31.6588 32.7195 31.8273 32.7195H33.5221C33.6794 32.7195 33.8141 32.6044 33.8387 32.4475L34.2178 30.0404C34.2525 29.8162 34.4453 29.6516 34.6707 29.6516H35.7156C37.8912 29.6516 39.1461 28.5959 39.4745 26.5042C39.6227 25.589 39.4803 24.87 39.0529 24.3664C38.584 23.8134 37.7521 23.5209 36.6469 23.5209ZM37.0279 26.6224C36.8478 27.8106 35.9424 27.8106 35.0666 27.8106H34.5689L34.9189 25.5914C34.9396 25.4573 35.0545 25.3585 35.1902 25.3585H35.4186C36.0144 25.3585 36.5774 25.3585 36.868 25.6994C37.0414 25.9027 37.094 26.2049 37.0279 26.6224Z" fill="#298FC2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.5999 26.5199H45.0195C44.8839 26.5199 44.7685 26.6187 44.7482 26.7528L44.6782 27.1963L44.5671 27.0356C44.2248 26.5373 43.4619 26.3707 42.6999 26.3707C40.9526 26.3707 39.4607 27.698 39.1701 29.5598C39.0194 30.4885 39.2332 31.3766 39.7585 31.9959C40.2415 32.5653 40.9299 32.8026 41.7503 32.8026C43.1582 32.8026 43.9389 31.8947 43.9389 31.8947L43.8685 32.3354C43.842 32.5038 43.9713 32.6549 44.1408 32.6549H45.5647C45.7902 32.6549 45.9823 32.4903 46.0176 32.2661L46.8727 26.8385C46.8988 26.6714 46.7693 26.5199 46.5999 26.5199ZM44.3958 29.6064C44.2442 30.5122 43.5262 31.1204 42.6116 31.1204C42.1534 31.1204 41.7856 30.9727 41.5498 30.6929C41.3163 30.415 41.2283 30.0194 41.3016 29.5787C41.4451 28.6805 42.1732 28.0524 43.0738 28.0524C43.5228 28.0524 43.8878 28.2022 44.1283 28.4843C44.3701 28.7695 44.4657 29.1676 44.3958 29.6064Z" fill="#298FC2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.3324 23.7543L46.9771 32.4013C46.9506 32.569 47.0799 32.7199 47.2484 32.7199H48.611C48.8375 32.7199 49.0296 32.5554 49.0643 32.3312L50.4008 23.84C50.4275 23.6724 50.298 23.5209 50.1295 23.5209H48.6038C48.4691 23.5213 48.3532 23.6202 48.3324 23.7543" fill="#298FC2"/>
</svg>

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 192.756 192.756" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path fill="#ffffff" d="M0 0h192.756v192.756H0V0z"/>
<path d="M189.922 50.809c0-8.986-4.67-13.444-13.729-13.444H16.562c-4.528 0-7.854 1.203-10.048 3.679-2.476 2.477-3.68 5.661-3.68 9.765v91.138c0 4.104 1.204 7.217 3.68 9.764 2.548 2.477 5.803 3.68 10.048 3.68h159.631c9.059 0 13.729-4.527 13.729-13.443V50.809zm-13.729-11.321c7.5 0 11.322 3.821 11.322 11.321v91.138c0 7.57-3.822 11.32-11.322 11.32H16.562c-3.609 0-6.368-1.061-8.42-3.184-2.123-2.053-3.184-4.883-3.184-8.137V50.809c0-7.5 3.75-11.321 11.604-11.321h159.631z" fill="#315881"/>
<path d="M17.835 44.724c-3.042 0-4.953.495-6.014 1.557-.92 1.203-1.344 3.184-1.344 6.085v19.741h171.802V52.366c0-5.165-2.549-7.642-7.643-7.642H17.835z" fill="#315881"/>
<path d="M10.477 140.107c0 5.234 2.476 7.924 7.358 7.924h156.801c5.094 0 7.643-2.689 7.643-7.924v-19.742H10.477v19.742z" fill="#dfa43b"/>
<path d="M67.367 80.528c0 .92-.142 1.627-.495 2.123l-12.383 21.582-.779-26.323H33.898l6.651 3.184c1.91 1.203 2.901 2.759 2.901 4.741l1.839 27.951h9.694l23.21-35.876H66.306c.707.637 1.061 1.627 1.061 2.618zM147.467 78.971l.777-1.062h-12.1c.424.424.566.637.566.778-.143.565-.426.92-.566 1.344l-17.619 32.124c-.424.566-.85 1.062-1.344 1.629h9.977l-.496-1.062c0-.92.496-2.617 1.557-5.023l2.123-3.963h10.26c.426 3.326.709 6.086.85 8.139l-.85 1.91h12.383l-1.84-2.689-3.678-32.125zm-7.36 19.742h-7.359l6.297-12.1 1.062 12.1zM109.539 76.07c-3.82 0-7.076 1.062-9.977 3.184-3.185 1.84-4.741 4.175-4.741 7.077 0 3.326 1.132 6.227 3.396 8.42l6.865 4.74c2.477 1.77 3.68 3.326 3.68 4.742 0 1.344-.639 2.547-1.84 3.467-1.203.92-2.549 1.344-4.246 1.344-2.477 0-6.722-1.768-12.595-5.023v6.58c4.599 2.76 9.058 4.176 13.373 4.176 4.105 0 7.572-1.133 10.545-3.68 3.184-2.336 4.74-5.094 4.74-8.137 0-2.549-1.133-4.883-3.68-7.36l-6.582-4.741c-2.191-1.769-3.395-3.326-3.395-4.528 0-2.759 1.627-4.175 4.953-4.175 2.264 0 5.59 1.274 10.047 3.963l1.346-6.864c-3.752-2.124-7.643-3.185-11.889-3.185zM83.217 113.785c-.142-1.486-.425-2.83-.567-4.246l8.987-29.011 2.123-2.618H80.811c.142.637.283 1.486.425 2.123 0 .637 0 1.416-.142 2.123l-8.986 28.728-1.84 2.902h12.949v-.001z" fill="#315881"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,11 @@
User-agent: *
Disallow: /login
Disallow: /register
Disallow: /forgot-password
Disallow: /reset-password
Disallow: /verify-email
Disallow: /email-verified
Disallow: /profile
Disallow: /auth/
Sitemap: https://www.minivan-berlin.de/sitemap-index.xml

View file

@ -0,0 +1,136 @@
---
import { aboutPageLayout } from './aboutData';
import HeroBlock from './HeroBlock.astro';
import StoryBlock from './StoryBlock.astro';
import ValuesBlock from './ValuesBlock.astro';
import FleetBlock from './FleetBlock.astro';
import SectionWrapper from './SectionWrapper.astro';
---
<div>
<HeroBlock
title={aboutPageLayout[0].title}
subtitle={aboutPageLayout[0].subtitle}
/>
<SectionWrapper className="bg-gray-50 py-8 -mx-4">
<StoryBlock
title={aboutPageLayout[1].title}
content={aboutPageLayout[1].content}
image={aboutPageLayout[1].image}
/>
</SectionWrapper>
<SectionWrapper className="bg-white py-8">
<ValuesBlock
title={aboutPageLayout[2].title}
values={aboutPageLayout[2].values}
/>
</SectionWrapper>
<SectionWrapper className="bg-gray-50 py-8">
<FleetBlock
title={aboutPageLayout[3].title}
vehicles={aboutPageLayout[3].vehicles}
/>
</SectionWrapper>
<div class="container mx-auto px-4 max-w-7xl pb-12">
<section class="relative bg-gradient-to-r from-blue-600 to-indigo-700 rounded-3xl overflow-hidden text-white text-center py-12 px-8 mt-12 opacity-0 animate-fadeInUp delay-700">
<div class="absolute inset-0 opacity-10">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<defs>
<pattern
id="grid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="white"
stroke-width="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
<div class="relative z-10">
<h2 class="text-3xl md:text-4xl font-bold mb-4 opacity-0 animate-fadeInUp delay-400">
{aboutPageLayout[4].title}
</h2>
{aboutPageLayout[4].subtitle && (
<p class="text-lg mb-8 max-w-2xl mx-auto opacity-95 animate-fadeInUp delay-500">
{aboutPageLayout[4].subtitle}
</p>
)}
{aboutPageLayout[4].button && (
<a
href={aboutPageLayout[4].button.link}
class="cta-button inline-block bg-white text-blue-700 font-semibold px-8 py-4 rounded-xl shadow-lg transition-all duration-300"
>
{aboutPageLayout[4].button.text}
</a>
)}
</div>
</section>
</div>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes buttonHover {
to {
transform: scale(1.05);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
}
}
@keyframes buttonTap {
to {
transform: scale(0.95);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s ease-out forwards;
}
.delay-700 {
animation-delay: 0.7s;
}
.delay-400 {
animation-delay: 0.4s;
}
.delay-500 {
animation-delay: 0.5s;
}
.cta-button {
transform: scale(1);
transition: all 0.3s ease;
}
.cta-button:hover {
animation: buttonHover 0.3s ease forwards;
}
.cta-button:active {
animation: buttonTap 0.1s ease forwards;
}
</style>

View file

@ -0,0 +1,60 @@
---
import HeroBlock from './HeroBlock.astro';
import StoryBlock from './StoryBlock.astro';
import ValuesBlock from './ValuesBlock.astro';
import FleetBlock from './FleetBlock.astro';
import CtaBlock from './CtaBlock.astro';
import SectionWrapper from './SectionWrapper.astro';
import type { AboutPageBlock } from '@/types/globalInterfaces';
interface Props {
block: AboutPageBlock;
}
const { block } = Astro.props;
// Логика фонов для секций, чтобы сохранить дизайн
const getWrapperClass = (type: string) => {
switch (type) {
case 'story': return 'bg-gray-50 py-16 -mx-4'; // Серый фон
case 'values': return 'bg-white py-16'; // Белый фон
case 'fleet': return 'bg-gray-50 py-16'; // Серый фон
default: return 'py-8';
}
};
---
{block.blockType === 'hero' && (
<HeroBlock title={block.title} subtitle={block.subtitle} />
)}
{block.blockType === 'cta' && (
<CtaBlock title={block.title} subtitle={block.subtitle} button={block.button} />
)}
{/* Для Story, Values и Fleet используем обертку SectionWrapper */}
{['story', 'values', 'fleet'].includes(block.blockType) && (
<SectionWrapper className={getWrapperClass(block.blockType)}>
{block.blockType === 'story' && (
<StoryBlock
title={block.title}
content={block.content}
image={block.image}
/>
)}
{block.blockType === 'values' && (
<ValuesBlock
title={block.title}
values={block.values}
/>
)}
{block.blockType === 'fleet' && (
<FleetBlock
title={block.title}
vehicles={block.vehicles}
/>
)}
</SectionWrapper>
)}

View file

@ -0,0 +1,91 @@
---
import { authService } from '@/lib/authService';
interface CtaData {
id: string;
title: string;
subtitle?: string | null;
button_text?: string | null;
button_link?: string | null;
is_active?: boolean;
}
// Получаем данные из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
let ctaData: CtaData | null = null;
try {
const response = await pb.collection('about_cta').getFirstListItem('is_active = true');
ctaData = {
id: response.id,
title: response.title,
subtitle: response.subtitle,
button_text: response.button_text,
button_link: response.button_link,
is_active: response.is_active
};
} catch (error) {
console.error('Ошибка при загрузке данных из коллекции about_cta:', error);
// В случае ошибки используем null, чтобы показать сообщение об отсутствии данных
ctaData = null;
}
---
{ctaData ? (
<div class="container mx-auto px-4 max-w-7xl pb-12 pt-12">
<section class="relative bg-gradient-to-r from-blue-600 to-indigo-700 rounded-3xl overflow-hidden text-white text-center py-12 px-8 opacity-0 animate-fadeInUp delay-700 shadow-2xl">
<div class="absolute inset-0 opacity-10">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
<div class="relative z-10">
<h2 class="text-3xl md:text-4xl font-bold mb-4 opacity-0 animate-fadeInUp delay-400">
{ctaData.title}
</h2>
{ctaData.subtitle && (
<p class="text-lg mb-8 max-w-2xl mx-auto opacity-95 animate-fadeInUp delay-500">
{ctaData.subtitle}
</p>
)}
{ctaData.button_text && ctaData.button_link && (
<a
href={ctaData.button_link}
class="cta-button inline-block bg-white text-blue-700 font-semibold px-8 py-4 rounded-xl shadow-lg transition-all duration-300"
>
{ctaData.button_text}
</a>
)}
</div>
</section>
</div>
) : (
<div class="container mx-auto px-4 max-w-7xl pb-12 pt-12">
<div class="text-center py-10 text-gray-500">
Keine CTA-Daten gefunden.
</div>
</div>
)}
<style>
@keyframes buttonHover {
to { transform: scale(1.05); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); }
}
@keyframes buttonTap {
to { transform: scale(0.95); }
}
.cta-button:hover {
animation: buttonHover 0.3s ease forwards;
}
.cta-button:active {
animation: buttonTap 0.1s ease forwards;
}
</style>

View file

@ -0,0 +1,131 @@
---
import { authService } from '@/lib/authService';
interface VehicleItem {
id: string;
title: string;
capacity?: string | null;
models?: string | null;
image?: string | null;
alt_text?: string | null;
order?: number;
is_active?: boolean;
collectionId: string;
}
interface Props {
title?: string;
collectionName?: string;
limit?: number;
}
const {
title = 'Unsere Flotte',
collectionName = 'about_fleet',
limit = 100
} = Astro.props;
// Получаем данные из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
let vehicles: VehicleItem[] = [];
try {
const response = await pb.collection(collectionName).getList(1, limit, {
filter: 'is_active = true',
sort: 'order'
});
vehicles = response.items.map(item => ({
id: item.id,
title: item.title,
capacity: item.capacity,
models: item.models,
image: item.image,
alt_text: item.alt_text,
order: item.order,
is_active: item.is_active,
collectionId: item.collectionId
}));
} catch (error) {
console.error(`Ошибка при загрузке данных из коллекции ${collectionName}:`, error);
// В случае ошибки используем пустой массив
vehicles = [];
}
// Функция для генерации URL изображения
const getImageUrl = (collectionId: string, id: string, imageName: string) => {
return `${import.meta.env.POCKETBASE_URL || import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/${collectionId}/${id}/${imageName}`;
};
---
<div class="mb-20 animate-staggerFadeIn">
<h2 class="text-2xl sm:text-3xl font-semibold mb-12 text-center text-gray-800 animate-fadeInUp">
{title}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle, index) => (
<div
class="fleet-card bg-white rounded-2xl overflow-hidden shadow-xl transition-all duration-300 transform animate-cardEntry"
style={`animation-delay: ${index * 0.1}s; animation-fill-mode: both;`}
>
{vehicle.image && (
<div class="relative h-48 overflow-hidden">
<img
src={getImageUrl(vehicle.collectionId, vehicle.id, vehicle.image)}
alt={vehicle.alt_text || vehicle.title}
class="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
loading="lazy"
/>
<div class="absolute inset-0 bg-linear-to-b from-transparent to-black/20"></div>
</div>
)}
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-2">
{vehicle.title}
</h3>
{vehicle.capacity && (
<p class="text-blue-600 font-medium mb-2">
{vehicle.capacity}
</p>
)}
{vehicle.models && (
<p class="text-sm text-gray-500">{vehicle.models}</p>
)}
</div>
</div>
))
) : (
<div class="col-span-3 text-center text-gray-400 py-10">
Keine Fahrzeuge gefunden.
</div>
)}
</div>
</div>
<style>
/* Локальные анимации */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes staggerFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cardEntry {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.8s ease-out forwards; }
.animate-staggerFadeIn { animation: staggerFadeIn 0.8s ease-out forwards; }
.animate-cardEntry { animation: cardEntry 0.6s ease-out forwards; }
.fleet-card:hover {
transform: translateY(-12px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
</style>

View file

@ -0,0 +1,19 @@
---
interface Props {
title: string;
subtitle?: string | null;
}
const { title, subtitle } = Astro.props;
---
<div class="my-12 text-center opacity-0 animate-fadeInUp">
<h1 class="text-4xl md:text-5xl font-bold text-gray-800 mb-4 leading-tight opacity-0 animate-fadeInUp">
{title}
</h1>
{subtitle && (
<p class="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed opacity-0 animate-fadeInUp delay-200">
{subtitle}
</p>
)}
</div>

View file

@ -0,0 +1,13 @@
---
interface Props {
className?: string;
}
const { className = '' } = Astro.props;
---
<section class={className}>
<div class="max-w-7xl mx-auto px-6 lg:px-8">
<slot />
</div>
</section>

View file

@ -0,0 +1,36 @@
---
import type { ImageMetadata } from 'astro';
interface Props {
title: string;
content?: string | null;
image?: ImageMetadata | { url: string; alt?: string } | null;
}
const { title, content, image } = Astro.props;
---
<div class="grid grid-cols-1 lg:grid-cols-2 gap-10 items-center opacity-0 animate-staggerFadeIn">
<div class="space-y-5 opacity-0 animate-slideInRight">
<h2 class="text-3xl font-semibold text-gray-800">
{title}
</h2>
{content && (
<div class="prose prose-lg max-w-none text-gray-600">
{content}
</div>
)}
</div>
{image && (
<div class="story-image relative h-80 w-full rounded-2xl overflow-hidden shadow-xl opacity-0 animate-slideInLeft">
<img
src={typeof image === 'object' && 'src' in image ? image.src : (image as any).url}
alt={typeof image === 'object' && 'alt' in image ? image.alt : title}
class="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
loading="eager"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</div>
)}
</div>

View file

@ -0,0 +1,75 @@
---
interface ValueItem {
icon?: string | null;
title: string;
description?: string | null;
id?: string | null;
}
interface Props {
title: string;
values?: ValueItem[] | null;
}
const { title, values } = Astro.props;
---
<div class="mb-20 animate-staggerFadeIn">
<h2 class="text-2xl sm:text-3xl font-semibold mb-12 text-center text-gray-800 animate-fadeInUp">
{title}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{values && values.length > 0 ? (
values.map((value, index) => {
return (
<div
class="value-card bg-white rounded-2xl p-6 shadow-lg transition-all duration-300 flex flex-col items-center text-center group animate-cardEntry"
style={`animation-delay: ${index * 0.1}s; animation-fill-mode: both;`}
>
<div class="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mb-4 group-hover:bg-blue-100 transition-colors duration-300 text-2xl">
{value.icon}
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">
{value.title}
</h3>
{value.description && (
<p class="text-gray-600 text-sm leading-relaxed">
{value.description}
</p>
)}
</div>
);
})
) : (
<div class="col-span-4 text-center text-gray-400 py-10">
Нет данных для отображения ценностей.
</div>
)}
</div>
</div>
<style>
/* Локальные анимации для гарантии отображения */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes staggerFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cardEntry {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.8s ease-out forwards; }
.animate-staggerFadeIn { animation: staggerFadeIn 0.8s ease-out forwards; }
.animate-cardEntry { animation: cardEntry 0.6s ease-out forwards; }
.value-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
</style>

View file

@ -0,0 +1,91 @@
export const aboutPageLayout = [
{
blockType: 'hero',
title: 'Über Berlin Minivan Transfers',
subtitle: 'Ihr vertrauensvoller Partner für Van- und Limousinenservices in Berlin'
},
{
blockType: 'story',
title: 'Unsere Geschichte',
content: 'Berlin Minivan Transfers wurde mit der Vision gegründet, erstklassige Fahrdienste in Berlin anzubieten. Seit unserer Gründung haben wir uns der Exzellenz, Sicherheit und Zuverlässigkeit verschrieben. Unser erfahrenes Team ist bestrebt, Ihren Transport zu einem unvergesslichen Erlebnis zu machen.',
image: {
url: '/images/about/about-team.avif',
alt: 'Team von Berlin Minivan Transfers'
}
},
{
blockType: 'values',
title: 'Unsere Werte',
values: [
{
id: '1',
icon: 'FaShuttleVan',
title: 'Exzellenz',
description: 'Wir bieten erstklassigen Service mit modernen Fahrzeugen.'
},
{
id: '2',
icon: 'FaAward',
title: 'Qualität',
description: 'Höchste Qualitätsstandards bei Fahrzeugen und Service.'
},
{
id: '3',
icon: 'FaUsers',
title: 'Kundenservice',
description: 'Individueller Service, der auf Ihre Bedürfnisse zugeschnitten ist.'
},
{
id: '4',
icon: 'FaMapMarkedAlt',
title: 'Zuverlässigkeit',
description: 'Pünktlich, zuverlässig und sicher an jedem Ort.'
}
]
},
{
blockType: 'fleet',
title: 'Unsere Flotte',
vehicles: [
{
id: '1',
image: {
url: '/images/about/minivan-standard.avif',
alt: 'Mercedes-Benz Vito'
},
title: 'Mercedes-Benz Vito',
capacity: 'Bis zu 8 Passagiere',
models: 'Geräumig, komfortabel, modern'
},
{
id: '2',
image: {
url: '/images/about/minivan-premium.avif',
alt: 'Mercedes-Benz V-Klasse'
},
title: 'Mercedes-Benz V-Klasse',
capacity: 'Bis zu 7 Passagiere',
models: 'Premium, Business-Class'
},
{
id: '3',
image: {
url: '/images/about/minivan-electric.avif',
alt: 'Volkswagen Multivan'
},
title: 'Volkswagen Multivan',
capacity: 'Bis zu 7 Passagiere',
models: 'Flexibel, vielseitig, alltagstauglich'
}
]
},
{
blockType: 'cta',
title: 'Bereit für eine Premium-Fahrt?',
subtitle: 'Entdecken Sie unseren exklusiven Van- und Limousinenservice in Berlin.',
button: {
text: 'Jetzt anfragen',
link: '/kontakt'
}
}
];

View file

@ -0,0 +1,395 @@
---
import Button from '@components/base/Button.astro';
// Получаем ошибку из параметров URL
const url = new URL(Astro.request.url);
const initialError = url.searchParams.get('error');
---
<div class="min-h-screen flex items-center justify-center bg-gray-100 p-4">
<div class="flex flex-col md:flex-row w-full max-w-4xl bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* ЛЕВАЯ КОЛОНКА (Изображение) */}
<div class="w-full md:w-1/2 relative min-h-[300px] md:min-h-0">
<img
src="/images/auth/sign-in.avif"
alt="Interior"
class="object-cover w-full h-full"
/>
<div class="absolute inset-0 bg-black/40 flex flex-col justify-end p-8">
<h2 class="text-white font-bold text-3xl">Minivan Berlin</h2>
<p class="text-white text-lg mt-2 font-light">
Ihr zuverlässiger Partner für komfortable Fahrten.
</p>
</div>
</div>
{/* ПРАВАЯ КОЛОНКА (Форма) */}
<div class="w-full md:w-1/2 p-8 md:p-12 flex flex-col justify-center">
<div class="mb-8">
<h2 class="font-bold text-gray-900 text-3xl">Willkommen zurück</h2>
<p class="text-gray-500 mt-2">
Melden Sie sich an, um fortzufahren.
</p>
</div>
{/* Глобальный блок ошибок (от сервера или API) */}
<div id="global-error-container" class={`mb-6 ${initialError ? '' : 'hidden'}`}>
<div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 rounded-r" role="alert">
<p class="font-bold">Anmeldung fehlgeschlagen</p>
<p id="global-error-message">{initialError}</p>
</div>
</div>
{/* ФОРМА */}
<form id="login-form" class="space-y-5" novalidate>
{/* EMAIL */}
<div class="group">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email-Adresse</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path></svg>
</div>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
minlength="5"
maxlength="100"
class="form-input block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="beispiel@email.de"
aria-describedby="emailError"
/>
</div>
<div id="emailError" class="text-xs mt-1 text-red-600 hidden h-4"></div>
<div class="text-xs text-gray-500 mt-1">5-100 Zeichen</div>
</div>
{/* PASSWORD */}
<div class="group">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
minlength="8"
maxlength="128"
class="form-input block w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="Mindestens 8 Zeichen"
aria-describedby="passwordError"
/>
<button type="button" id="togglePassword" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 cursor-pointer focus:outline-none transition-colors" aria-label="Passwort anzeigen">
<svg id="eyeIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</div>
<div id="passwordError" class="text-xs mt-1 text-red-600 hidden h-4"></div>
<div class="text-xs text-gray-500 mt-1">Mindestens 8 Zeichen</div>
</div>
<div class="flex items-center justify-end text-sm">
<a href="/auth/forgot-password" class="font-medium text-blue-600 hover:text-blue-500 transition-colors">
Passwort vergessen?
</a>
</div>
<div class="pt-2">
<Button
id="submitButton"
type="submit"
variant="blue"
size="md"
fullWidth={true}
disabled={true}
className="opacity-50 cursor-not-allowed transition-all duration-300"
>
Anmelden
</Button>
</div>
</form>
<div class="mt-8">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200" />
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-white text-gray-500">Oder weiter mit</span>
</div>
</div>
<div class="mt-6">
<a
href={`/api/auth/google${Astro.url.searchParams.get('callbackUrl') ? `?callbackUrl=${encodeURIComponent(Astro.url.searchParams.get('callbackUrl'))}` : ''}`}
class="w-full flex justify-center items-center px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 cursor-pointer"
>
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" width="24" height="24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Mit Google anmelden
</a>
</div>
</div>
<div class="mt-8 text-center">
<p class="text-sm text-gray-600">
Noch kein Konto?{' '}
<a href="/auth/register" class="font-medium text-blue-600 hover:text-blue-500 transition-colors">
Registrieren
</a>
</p>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// === ЭЛЕМЕНТЫ DOM ===
const form = document.getElementById('login-form') as HTMLFormElement;
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const emailError = document.getElementById('emailError') as HTMLDivElement;
const passwordError = document.getElementById('passwordError') as HTMLDivElement;
const globalErrorContainer = document.getElementById('global-error-container');
const globalErrorMessage = document.getElementById('global-error-message');
const togglePassword = document.getElementById('togglePassword');
const eyeIcon = document.getElementById('eyeIcon');
// === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===
const showError = (el: HTMLElement, message: string) => {
el.textContent = message;
el.classList.remove('hidden');
};
const hideError = (el: HTMLElement) => {
el.textContent = '';
el.classList.add('hidden');
};
const setValidState = (input: HTMLInputElement, isValid: boolean) => {
if (isValid) {
input.classList.remove('border-red-500', 'focus:ring-red-500', 'focus:border-red-500');
input.classList.add('border-green-500');
} else {
input.classList.remove('border-green-500');
input.classList.add('border-red-500', 'focus:ring-red-500', 'focus:border-red-500');
}
};
const resetState = (input: HTMLInputElement) => {
input.classList.remove('border-red-500', 'border-green-500');
};
// === 1. УПРАВЛЕНИЕ ПАРОЛЕМ (ГЛАЗОК) ===
if (togglePassword && passwordInput && eyeIcon) {
togglePassword.addEventListener('click', () => {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
if (type === 'text') {
// Глаз перечеркнут (скрыть)
eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
} else {
// Глаз открыт (показать)
eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
}
});
}
// === 2. ВАЛИДАЦИЯ EMAIL ===
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Запрет пробелов в Email
if (emailInput) {
emailInput.addEventListener('keydown', (e) => {
if (e.key === ' ') e.preventDefault();
});
// Санитизация при вставке
emailInput.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
if (target.value.includes(' ')) {
target.value = target.value.replace(/\s/g, '');
}
// Проверка длины
if (target.value.length > 100) {
target.value = target.value.substring(0, 100);
}
validateGlobal();
});
emailInput.addEventListener('blur', () => {
const val = emailInput.value.trim();
if (!val) {
showError(emailError, 'E-Mail ist erforderlich');
setValidState(emailInput, false);
} else if (val.length < 5) {
showError(emailError, 'E-Mail muss mindestens 5 Zeichen enthalten');
setValidState(emailInput, false);
} else if (val.length > 100) {
showError(emailError, 'E-Mail darf maximal 100 Zeichen enthalten');
setValidState(emailInput, false);
} else if (!emailRegex.test(val)) {
showError(emailError, 'Ungültiges E-Mail-Format');
setValidState(emailInput, false);
} else {
hideError(emailError);
setValidState(emailInput, true);
}
validateGlobal();
});
}
// === 3. ВАЛИДАЦИЯ ПАРОЛЯ ===
if (passwordInput) {
// Запрет пробелов в начале и конце пароля
passwordInput.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
// Проверка длины
if (target.value.length > 128) {
target.value = target.value.substring(0, 128);
}
validateGlobal();
});
passwordInput.addEventListener('blur', () => {
const val = passwordInput.value;
if (!val) {
showError(passwordError, 'Passwort ist erforderlich');
setValidState(passwordInput, false);
} else if (val.length < 8) {
showError(passwordError, 'Passwort muss mindestens 8 Zeichen enthalten');
setValidState(passwordInput, false);
} else if (val.length > 128) {
showError(passwordError, 'Passwort darf maximal 128 Zeichen enthalten');
setValidState(passwordInput, false);
} else {
hideError(passwordError);
resetState(passwordInput); // Пароль не красим в зеленый для безопасности, просто убираем красный
}
validateGlobal();
});
passwordInput.addEventListener('input', validateGlobal);
}
// === 4. ГЛОБАЛЬНАЯ ВАЛИДАЦИЯ КНОПКИ ===
function validateGlobal() {
if (!emailInput || !passwordInput || !submitButton) return;
const isEmailFilled = emailInput.value.trim().length > 0;
const isEmailLengthValid = emailInput.value.trim().length >= 5 && emailInput.value.trim().length <= 100;
const isEmailFormatValid = emailRegex.test(emailInput.value.trim());
const isPasswordFilled = passwordInput.value.length > 0;
const isPasswordLengthValid = passwordInput.value.length >= 8 && passwordInput.value.length <= 128;
if (isEmailFilled && isEmailLengthValid && isEmailFormatValid && isPasswordFilled && isPasswordLengthValid) {
submitButton.disabled = false;
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
submitButton.classList.add('hover:shadow-lg', 'cursor-pointer');
} else {
submitButton.disabled = true;
submitButton.classList.add('opacity-50', 'cursor-not-allowed');
submitButton.classList.remove('hover:shadow-lg', 'cursor-pointer');
}
}
// Инициализация
validateGlobal();
// === 5. ОТПРАВКА ФОРМЫ (AJAX) ===
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Извлекаем callbackUrl из URL параметров
const urlParams = new URLSearchParams(window.location.search);
const callbackUrl = urlParams.get('callbackUrl') || '/bewertungen'; // По умолчанию остаемся на странице отзывов
// UI Loading State
const originalContent = submitButton.innerHTML;
const originalWidth = submitButton.offsetWidth; // Фиксируем ширину, чтобы кнопка не дергалась
submitButton.style.width = `${originalWidth}px`;
submitButton.innerHTML = `
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wird geladen...
</div>
`;
submitButton.disabled = true;
// Скрываем старые ошибки
if (globalErrorContainer) globalErrorContainer.classList.add('hidden');
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// Успех - редирект на callbackUrl с параметром для отображения уведомления
const successUrl = callbackUrl.includes('?')
? `${callbackUrl}&loginSuccess=true`
: `${callbackUrl}?loginSuccess=true`;
window.location.href = successUrl;
} else {
// Ошибка API
if (globalErrorMessage && globalErrorContainer) {
globalErrorMessage.textContent = result.message || 'E-Mail oder Passwort falsch.';
globalErrorContainer.classList.remove('hidden');
}
// Возвращаем кнопку
submitButton.innerHTML = originalContent;
submitButton.style.width = 'auto';
submitButton.disabled = false;
}
} catch (error) {
console.error(error);
if (globalErrorMessage && globalErrorContainer) {
globalErrorMessage.textContent = 'Verbindungsfehler. Bitte versuchen Sie es später erneut.';
globalErrorContainer.classList.remove('hidden');
}
submitButton.innerHTML = originalContent;
submitButton.style.width = 'auto';
submitButton.disabled = false;
}
});
}
});
</script>

View file

@ -0,0 +1,535 @@
---
import Button from '@components/base/Button.astro';
import PocketBase, { ClientResponseError } from 'pocketbase';
// Локальные переменные состояния
let isSuccess = false;
let formError = '';
let emailForDisplay = ''; // Переменная для отображения email после успешной регистрации
// === ЛОГИКА СЕРВЕРА ===
if (Astro.request.method === "POST") {
try {
const formData = await Astro.request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const confirmPassword = formData.get("confirmPassword") as string;
// 1. Проверка паролей на сервере
if (password !== confirmPassword) {
throw new Error("Passwörter stimmen nicht überein.");
}
// 2. Подключение к PocketBase
// Используем переменную окружения или дефолтный адрес
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
// 3. Создание пользователя
await pb.collection('users').create({
username: undefined, // Генерируется автоматически, если не задан
email: email,
emailVisibility: true,
password: password,
passwordConfirm: confirmPassword,
name: name,
});
// 4. Отправка письма подтверждения
await pb.collection('users').requestVerification(email);
// 5. Успех
isSuccess = true;
emailForDisplay = email; // Сохраняем email для отображения на странице успеха
} catch (e: unknown) {
console.error("Registration error:", e);
if (e instanceof ClientResponseError) {
const data = e.data.data;
if (data) {
// Преобразуем ошибки API PocketBase в читаемый вид
const errors = Object.entries(data).map(([key, val]) => {
const errorObj = val as { code: string; message: string };
if (key === 'email' && errorObj.code === 'validation_not_unique') {
return "Diese E-Mail-Adresse ist bereits vergeben. Bitte melden Sie sich an.";
}
if (key === 'password' && errorObj.code === 'validation_length_short') {
return "Das Passwort ist zu kurz (mindestens 8 Zeichen).";
}
return `${key}: ${errorObj.message}`;
}).join(' ');
formError = errors;
} else {
formError = e.message;
}
} else if (e instanceof Error) {
formError = e.message;
} else {
formError = "Ein unbekannter Fehler ist aufgetreten.";
}
}
}
---
<div class="min-h-screen flex items-center justify-center bg-gray-100 p-4">
<div class="flex flex-col md:flex-row w-full max-w-4xl bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* ЛЕВАЯ КОЛОНКА (Изображение) */}
<div class="w-full md:w-1/2 relative min-h-[300px] md:min-h-0">
<img
src="/images/auth/sign-in.avif"
alt="Интерьер комфортабельного минивэна"
class="object-cover w-full h-full"
/>
<div class="absolute inset-0 bg-black/40 flex flex-col justify-end p-8">
<h2 class="text-white font-bold text-3xl">Minivan Berlin</h2>
<p class="text-white text-lg mt-2 font-light">
Ihr zuverlässiger Partner für komfortable Fahrten.
</p>
</div>
</div>
{/* ПРАВАЯ КОЛОНКА (Форма) */}
<div class="w-full md:w-1/2 p-8 md:p-12 flex flex-col justify-center">
{isSuccess ? (
// === БЛОК УСПЕХА ===
<div class="text-center animate-fade-in">
<div class="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6">
<svg class="h-10 w-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">Bitte überprüfen Sie Ihre E-Mails</h2>
<p class="text-gray-600 mb-8 text-lg leading-relaxed">
Wir haben einen Bestätigungslink an <strong>{emailForDisplay}</strong> gesendet.<br/>
Bitte klicken Sie auf den Link, um Ihr Konto zu aktivieren.
</p>
<div class="p-4 bg-blue-50 text-blue-800 rounded-lg text-sm mb-6">
Keine E-Mail erhalten? Bitte überprüfen Sie Ihren Spam-Ordner.
</div>
<div>
<a href="/" class="text-gray-500 hover:text-gray-800 font-medium text-sm transition-colors">Zurück zur Startseite</a>
</div>
</div>
) : (
// === БЛОК ФОРМЫ ===
<>
<div class="mb-8">
<h2 class="font-bold text-gray-900 text-3xl">Erstellen Sie Ihr Konto</h2>
<p class="text-gray-500 mt-2">
Füllen Sie das Formular aus, um zu beginnen.
</p>
</div>
{formError && (
<div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-r" role="alert">
<p class="font-bold">Fehler bei der Registrierung</p>
<p>{formError}</p>
</div>
)}
<form method="POST" class="space-y-5" id="registerForm" novalidate>
{/* NAME */}
<div class="group">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
</div>
<input
id="name"
name="name"
type="text"
autocomplete="name"
required
minlength="2"
maxlength="50"
class="form-input block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="Ihr Name"
aria-describedby="nameError"
/>
</div>
<div id="nameError" class="text-xs mt-1 text-red-600 hidden h-4"></div>
</div>
{/* EMAIL */}
<div class="group">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">E-Mail-Adresse</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path></svg>
</div>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
class="form-input block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="beispiel@email.de"
aria-describedby="emailError"
/>
</div>
<div id="emailError" class="text-xs mt-1 text-red-600 hidden h-4"></div>
</div>
{/* PASSWORD */}
<div class="group">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="form-input block w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="Mindestens 8 Zeichen"
aria-describedby="passwordError"
/>
<button type="button" id="togglePassword" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer transition-colors" aria-label="Passwort anzeigen">
<svg id="eyeIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
</button>
</div>
{/* Индикатор силы пароля */}
<div class="mt-2 h-1 w-full bg-gray-200 rounded-full overflow-hidden">
<div id="passwordStrengthBar" class="h-full w-0 bg-red-500 transition-all duration-300 ease-out"></div>
</div>
<div class="flex justify-between items-start mt-1">
<div id="passwordError" class="text-xs text-red-600 hidden"></div>
<div id="passwordStrengthText" class="text-xs text-gray-400 ml-auto whitespace-nowrap"></div>
</div>
</div>
{/* CONFIRM PASSWORD */}
<div class="group">
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">Passwort bestätigen</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autocomplete="new-password"
required
class="form-input block w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-all"
placeholder="Passwort wiederholen"
/>
<button type="button" id="toggleConfirmPassword" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer transition-colors" aria-label="Passwort anzeigen">
<svg id="confirmEyeIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
</button>
</div>
<div id="passwordMatchMessage" class="text-xs mt-1 h-4 hidden"></div>
</div>
<div class="pt-2">
<Button
id="submitButton"
type="submit"
variant="blue"
size="md"
fullWidth={true}
disabled={true}
className="opacity-50 cursor-not-allowed transition-all duration-300"
>
Konto erstellen
</Button>
</div>
</form>
<div class="mt-8">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200" />
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-white text-gray-500">Oder weiter mit</span>
</div>
</div>
<div class="mt-6">
<a href="/api/auth/google" class="w-full flex justify-center items-center px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white font-medium text-gray-700 hover:bg-gray-50 hover:cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" width="24" height="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Mit Google anmelden
</a>
</div>
</div>
<div class="mt-8 text-center">
<p class="text-sm text-gray-600">
Haben Sie bereits ein Konto?{' '}
<a href="/auth/login" class="font-medium text-blue-600 hover:text-blue-500 transition-colors">
Anmelden
</a>
</p>
</div>
</>
)}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// === ПОЛУЧЕНИЕ ЭЛЕМЕНТОВ DOM ===
const form = document.querySelector('#registerForm') as HTMLFormElement | null;
if (!form) return;
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
const nameInput = document.getElementById('name') as HTMLInputElement;
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const confirmInput = document.getElementById('confirmPassword') as HTMLInputElement;
const nameError = document.getElementById('nameError') as HTMLDivElement;
const emailError = document.getElementById('emailError') as HTMLDivElement;
const passwordError = document.getElementById('passwordError') as HTMLDivElement;
const passwordMatchMessage = document.getElementById('passwordMatchMessage') as HTMLDivElement;
const passwordStrengthBar = document.getElementById('passwordStrengthBar') as HTMLDivElement;
const passwordStrengthText = document.getElementById('passwordStrengthText') as HTMLDivElement;
// Переключатели видимости пароля
const togglePassBtn = document.getElementById('togglePassword');
const toggleConfirmBtn = document.getElementById('toggleConfirmPassword');
const eyeIcon = document.getElementById('eyeIcon');
const confirmEyeIcon = document.getElementById('confirmEyeIcon');
// === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===
/** Показать ошибку */
const showError = (el: HTMLElement, message: string) => {
el.textContent = message;
el.classList.remove('hidden');
};
/** Скрыть ошибку */
const hideError = (el: HTMLElement) => {
el.textContent = '';
el.classList.add('hidden');
};
/** Переключение иконки глаза */
const updateEyeIcon = (icon: HTMLElement | null, isVisible: boolean) => {
if (!icon) return;
if (isVisible) {
// Иконка "скрыть" (глаз перечеркнут)
icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />`;
} else {
// Иконка "показать" (глаз обычный)
icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`;
}
};
// === 1. ВАЛИДАЦИЯ ИМЕНИ (СТРОГАЯ) ===
// Разрешаем: Латиница, Кириллица, пробел, дефис, апостроф.
const nameRegex = /^[a-zA-Zа-яА-ЯёЁ\s\-']+$/u;
// Блокировка ввода клавиш
nameInput.addEventListener('keydown', (e) => {
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];
if (allowedKeys.includes(e.key) || e.ctrlKey || e.metaKey || e.altKey) return;
// Если символ не соответствует регулярке - предотвращаем ввод
if (!nameRegex.test(e.key)) {
e.preventDefault();
}
});
// Санитизация ввода (против вставки и автозаполнения)
nameInput.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
// Удаляем всё, что не соответствует разрешенным символам
// Note: инвертируем диапазон для replace
const sanitized = target.value.replace(/[^a-zA-Zа-яА-ЯёЁ\s\-']/gu, '');
if (sanitized !== target.value) {
target.value = sanitized;
}
validateGlobal();
});
nameInput.addEventListener('blur', () => {
if (!nameInput.value.trim()) {
showError(nameError, 'Name ist erforderlich');
nameInput.classList.add('border-red-500', 'focus:ring-red-500', 'focus:border-red-500');
} else if (nameInput.value.trim().length < 2) {
showError(nameError, 'Name muss mindestens 2 Zeichen lang sein');
nameInput.classList.add('border-red-500');
} else {
hideError(nameError);
nameInput.classList.remove('border-red-500', 'focus:ring-red-500', 'focus:border-red-500');
nameInput.classList.add('border-green-500');
}
validateGlobal();
});
// === 2. ВАЛИДАЦИЯ EMAIL ===
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validateEmail = () => {
const val = emailInput.value.trim();
if (!val) return false;
return emailRegex.test(val);
};
emailInput.addEventListener('blur', () => {
if (!emailInput.value.trim()) {
showError(emailError, 'E-Mail ist erforderlich');
emailInput.classList.add('border-red-500');
} else if (!validateEmail()) {
showError(emailError, 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
emailInput.classList.add('border-red-500');
} else {
hideError(emailError);
emailInput.classList.remove('border-red-500');
emailInput.classList.add('border-green-500');
}
validateGlobal();
});
emailInput.addEventListener('input', validateGlobal);
// === 3. ВАЛИДАЦИЯ И СИЛА ПАРОЛЯ ===
const checkPasswordStrength = (pass: string) => {
let score = 0;
if (!pass) return 0;
if (pass.length >= 8) score += 1;
if (pass.length >= 12) score += 1;
if (/[A-Z]/.test(pass)) score += 1;
if (/[0-9]/.test(pass)) score += 1;
if (/[^A-Za-z0-9]/.test(pass)) score += 1;
return score; // Max 5
};
const updateStrengthMeter = (pass: string) => {
const score = checkPasswordStrength(pass);
const width = (score / 5) * 100;
passwordStrengthBar.style.width = `${width}%`;
// Цвета и текст
if (score <= 1) {
passwordStrengthBar.className = 'h-full bg-red-500 transition-all duration-300';
passwordStrengthText.textContent = 'Sehr schwach';
passwordStrengthText.className = 'text-xs text-red-500 ml-auto whitespace-nowrap font-medium';
} else if (score === 2) {
passwordStrengthBar.className = 'h-full bg-orange-500 transition-all duration-300';
passwordStrengthText.textContent = 'Schwach';
passwordStrengthText.className = 'text-xs text-orange-500 ml-auto whitespace-nowrap font-medium';
} else if (score === 3) {
passwordStrengthBar.className = 'h-full bg-yellow-500 transition-all duration-300';
passwordStrengthText.textContent = 'Mittel';
passwordStrengthText.className = 'text-xs text-yellow-600 ml-auto whitespace-nowrap font-medium';
} else if (score >= 4) {
passwordStrengthBar.className = 'h-full bg-green-500 transition-all duration-300';
passwordStrengthText.textContent = 'Stark';
passwordStrengthText.className = 'text-xs text-green-600 ml-auto whitespace-nowrap font-medium';
}
return score;
};
passwordInput.addEventListener('input', () => {
const val = passwordInput.value;
const score = updateStrengthMeter(val);
if (val.length > 0 && val.length < 8) {
showError(passwordError, 'Mindestens 8 Zeichen erforderlich');
} else {
hideError(passwordError);
}
validateGlobal();
});
// === 4. ПРОВЕРКА СОВПАДЕНИЯ ===
const checkMatch = () => {
const pass = passwordInput.value;
const conf = confirmInput.value;
if (!conf) {
hideError(passwordMatchMessage);
confirmInput.classList.remove('border-red-500', 'border-green-500');
return false;
}
if (pass !== conf) {
passwordMatchMessage.textContent = 'Passwörter stimmen nicht überein';
passwordMatchMessage.className = 'text-xs mt-1 text-red-600 block';
confirmInput.classList.add('border-red-500');
confirmInput.classList.remove('border-green-500');
return false;
} else {
passwordMatchMessage.textContent = 'Passwörter stimmen überein';
passwordMatchMessage.className = 'text-xs mt-1 text-green-600 block';
confirmInput.classList.remove('border-red-500');
confirmInput.classList.add('border-green-500');
return true;
}
};
confirmInput.addEventListener('input', () => {
checkMatch();
validateGlobal();
});
// Повторная проверка совпадения, если меняется основной пароль
passwordInput.addEventListener('input', () => {
if (confirmInput.value) checkMatch();
});
// === ГЛОБАЛЬНАЯ ВАЛИДАЦИЯ КНОПКИ ===
function validateGlobal() {
const isNameValid = nameInput.value.trim().length >= 2;
const isEmailValid = validateEmail();
const isPassValid = passwordInput.value.length >= 8 && checkPasswordStrength(passwordInput.value) >= 2; // Требуем хотя бы слабой сложности
const isMatch = passwordInput.value === confirmInput.value && confirmInput.value.length > 0;
if (isNameValid && isEmailValid && isPassValid && isMatch) {
submitButton.disabled = false;
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
submitButton.classList.add('hover:shadow-lg', 'cursor-pointer');
} else {
submitButton.disabled = true;
submitButton.classList.add('opacity-50', 'cursor-not-allowed');
submitButton.classList.remove('hover:shadow-lg', 'cursor-pointer');
}
}
// === ЛОГИКА ГЛАЗКОВ ===
if (togglePassBtn) {
togglePassBtn.addEventListener('click', () => {
const type = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = type;
updateEyeIcon(eyeIcon as HTMLElement, type === 'text');
});
}
if (toggleConfirmBtn) {
toggleConfirmBtn.addEventListener('click', () => {
const type = confirmInput.type === 'password' ? 'text' : 'password';
confirmInput.type = type;
updateEyeIcon(confirmEyeIcon as HTMLElement, type === 'text');
});
}
});
</script>

View file

@ -0,0 +1,67 @@
---
interface Breadcrumb {
label: string;
href?: string;
current?: boolean;
}
interface Props {
items: Breadcrumb[];
variant?: 'default' | 'minimal' | 'withHome';
separator?: 'chevron' | 'slash' | 'dot';
}
const {
items,
variant = 'default',
separator = 'chevron'
}: Props = Astro.props;
// Добавляем домашнюю страницу, если указан вариант withHome
const breadcrumbs = variant === 'withHome' && items[0]?.href !== '/'
? [{ label: 'Home', href: '/' }, ...items]
: items;
---
<nav aria-label="Breadcrumb" class="py-0.2 px-0">
<ol class="flex flex-wrap items-center text-sm">
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
const showSeparator = index < breadcrumbs.length - 1;
return (
<li class="flex items-center" key={index}>
{item.href && !isLast ? (
<a
href={item.href}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors duration-200 font-medium"
>
{item.label}
</a>
) : (
<span class={`font-medium ${isLast ? 'text-gray-900' : 'text-gray-500'}`}>
{item.label}
</span>
)}
{showSeparator && (
<span class="mx-2 text-gray-400" aria-hidden="true">
{separator === 'chevron' && (
<svg
class="w-4 h-4 inline-block"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
)}
{separator === 'slash' && <span>/</span>}
{separator === 'dot' && <span>•</span>}
</span>
)}
</li>
);
})}
</ol>
</nav>

View file

@ -0,0 +1,97 @@
---
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'a'>, HTMLAttributes<'button'> {
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'whatsapp' | 'link' | 'ghost' | 'blue';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
isLoading?: boolean;
href?: string;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
class?: string;
className?: string; // Добавлено для совместимости
}
const {
variant = 'primary',
size = 'md',
fullWidth = false,
disabled = false,
isLoading = false,
href,
type = 'button',
class: listClass, // Стандартный class Astro
className, // React-style className (из формы)
...attrs // Остальные атрибуты (id, onclick и т.д.)
} = Astro.props;
const variantClasses = {
primary: 'bg-amber-500 hover:bg-amber-600 text-white focus:ring-amber-500',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
success: 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500',
warning: 'bg-yellow-600 hover:bg-yellow-700 text-white focus:ring-yellow-500',
whatsapp: 'bg-green-500 hover:bg-green-600 text-white focus:ring-green-400',
link: 'text-blue-600 hover:text-blue-800 underline focus:ring-blue-500 bg-transparent shadow-none',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
blue: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500'
};
const sizeClasses = {
sm: 'px-3 py-2 text-sm rounded-md',
// Изменено px-5 на px-4, чтобы совпадать с Google кнопкой в форме
md: 'px-4 py-3 text-base rounded-lg',
lg: 'px-6 py-3.5 text-lg rounded-lg'
};
const isInactive = disabled || isLoading;
const baseClasses = [
'relative inline-flex items-center justify-center gap-2',
'font-semibold shadow-md transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
sizeClasses[size],
variantClasses[variant],
fullWidth ? 'w-full' : '',
// ВАЖНО: opacity-50 совпадает с тем, что удаляет ваш скрипт в форме
isInactive ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg cursor-pointer',
listClass,
className // Добавляем className в итоговую строку
].filter(Boolean).join(' ');
---
{href && !isInactive ? (
<a href={href} class={baseClasses} {...attrs}>
<slot />
</a>
) : (
<button type={href ? 'button' : type} class={baseClasses} disabled={isInactive} {...attrs}>
<span class:list={['flex items-center justify-center gap-2', { 'opacity-0': isLoading }]}>
<slot />
</span>
{isLoading && (
<span class="absolute inset-0 flex items-center justify-center">
<div class="loader"></div>
</span>
)}
</button>
)}
<style>
.loader {
width: 1.25em;
height: 1.25em;
border: 2px solid currentColor;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View file

@ -0,0 +1,15 @@
.loader {
width: 1.25em; /* 20px for text-base */
height: 1.25em;
border: 2px solid currentColor;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View file

@ -0,0 +1,107 @@
import { type ParentProps, type JSX, splitProps } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import './Buttons.css';
interface ButtonProps extends ParentProps {
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'whatsapp' | 'link';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
isLoading?: boolean;
href?: string;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
class?: string;
onClick?: (e: MouseEvent) => void;
[x: string]: unknown; // для дополнительных атрибутов
}
export const Button = (props: ButtonProps) => {
const [local, others] = splitProps(props, [
'variant', 'size', 'fullWidth', 'isLoading', 'href', 'type', 'disabled', 'class', 'onClick', 'children'
]);
// Значения по умолчанию
const variant = local.variant ?? 'primary';
const size = local.size ?? 'md';
const fullWidth = local.fullWidth ?? false;
const disabledValue = local.disabled ?? false;
const isLoadingValue = local.isLoading ?? false;
const href = local.href;
const type = local.type ?? 'button';
const onClick = local.onClick;
const className = local.class;
// Классы для разных состояний и вариантов
const variantClasses = {
primary: 'bg-amber-600 hover:bg-amber-700 text-white focus:ring-amber-500',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
success: 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500',
warning: 'bg-yellow-600 hover:bg-yellow-700 text-white focus:ring-yellow-500',
whatsapp: 'bg-green-500 hover:bg-green-600 text-white focus:ring-green-400',
link: 'text-blue-600 hover:text-blue-800 underline focus:ring-blue-500 bg-transparent shadow-none'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm rounded-md',
md: 'px-5 py-2.5 text-base rounded-lg',
lg: 'px-6 py-3 text-lg rounded-lg'
};
const isInactive = disabledValue || isLoadingValue;
const baseClasses = [
'relative inline-flex items-center justify-center gap-2',
'font-semibold shadow-md transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
sizeClasses[size],
variantClasses[variant],
fullWidth ? 'w-full' : '',
isInactive ? 'opacity-60 cursor-not-allowed' : 'hover:shadow-lg cursor-pointer',
className
].filter(Boolean).join(' ');
// Обработка клика
const handleClick = (e: MouseEvent) => {
if (onClick && !isInactive) {
onClick(e);
}
};
// Если есть href и не активна, возвращаем ссылку, иначе кнопку
if (href && !isInactive) {
return (
<Dynamic
component="a"
href={href}
class={baseClasses}
onClick={handleClick}
{...others}
>
{local.children}
</Dynamic>
);
} else {
return (
<Dynamic
component="button"
type={href ? 'button' : type}
class={baseClasses}
disabled={isInactive}
onClick={handleClick}
{...others}
>
<span classList={{ 'flex items-center justify-center gap-2': true, 'opacity-0': isLoadingValue }}>
{local.children}
</span>
{isLoadingValue && (
<span class="absolute inset-0 flex items-center justify-center">
<div class="loader"></div>
</span>
)}
</Dynamic>
);
}
};
export { SolidModal } from './SolidModal';

View file

@ -0,0 +1,38 @@
---
import type { HTMLAttributes } from 'astro/types';
interface ActionLinkProps extends HTMLAttributes<'a'> {
href: string;
variant?: 'default' | 'action';
children?: string;
}
const {
href,
children = '',
variant = 'default',
class: className = '',
...attrs
} = Astro.props;
const baseClasses = 'font-bold relative inline-block group hover:cursor-pointer';
const variantClasses = {
default: 'text-gray-600 hover:text-blue-700 group-hover:text-blue-700',
action: 'text-red-600 hover:text-red-700 group-hover:text-red-700'
};
const classes = [
baseClasses,
variantClasses[variant as keyof typeof variantClasses],
className
].filter(Boolean).join(' ');
---
<a href={href} class={classes} data-astro-prefetch {...attrs}>
<span class="relative z-10">
<slot />
{children}
</span>
<span class="absolute bottom-0 left-1/2 w-0 h-0.5 bg-current group-hover:w-full group-hover:left-0 transition-all duration-300 ease-in-out"></span>
</a>

View file

@ -0,0 +1,128 @@
---
import { COLOR_CLASSES } from '@constants/colors';
interface Props {
modalId: string;
triggerId?: string;
title?: string;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full';
maxHeight?: string;
modalBgClass?: string;
[key: string]: unknown; // Для любых дополнительных пропсов
}
const {
modalId,
triggerId = undefined,
title = '',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
maxWidth = 'lg',
maxHeight = '90vh',
modalBgClass = 'bg-white',
...attrs
} = Astro.props;
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
full: 'max-w-full'
};
---
<div id={modalId} class="modal fixed inset-0 z-[101] flex items-center justify-center p-4 transition-all duration-300 opacity-0 pointer-events-none" {...attrs}>
<div class="modal-overlay absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
<div class={`modal-content relative flex flex-col ${modalBgClass} rounded-xl shadow-2xl w-full ${maxWidthClasses[maxWidth]} max-h-[${maxHeight}] overflow-hidden transition-all duration-300 scale-95 opacity-0 -translate-y-10`}>
{showCloseButton && (
<button
type="button"
aria-label="Close modal"
class="modal-close-btn absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-gray-100 hover:cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-700" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</button>
)}
{title && (
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="font-bold text-gray-900 text-xl">{title}</h2>
</div>
)}
<div class={`flex-grow overflow-y-auto ${title ? 'p-6 sm:p-8 pt-0' : 'p-6 sm:p-8'}`}>
<slot />
</div>
</div>
</div>
<style>
.modal.is-open {
opacity: 1;
pointer-events: auto;
}
.modal.is-open .modal-content {
opacity: 1;
transform: translateY(0) scale(1);
}
</style>
<script define:vars={{ modalId, triggerId, closeOnOverlayClick, closeOnEscape }}>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById(modalId);
const openBtn = triggerId ? document.getElementById(triggerId) : null;
const closeBtn = modal.querySelector('.modal-close-btn');
const overlay = modal.querySelector('.modal-overlay');
if (!modal) return;
const openModal = () => modal.classList.add('is-open');
const closeModal = () => modal.classList.remove('is-open');
if (openBtn) {
openBtn.addEventListener('click', openModal);
}
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
if (overlay && closeOnOverlayClick) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal();
}
});
}
if (closeOnEscape) {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
closeModal();
}
});
}
// Метод для открытия модального окна извне
window.openModal = (id) => {
if (id === modalId) {
openModal();
}
};
// Метод для закрытия модального окна извне
window.closeModal = (id) => {
if (id === modalId) {
closeModal();
}
};
});
</script>

View file

@ -0,0 +1,109 @@
---
export interface Props {
message: string;
duration?: number;
type?: 'success' | 'error' | 'info' | 'warning';
}
const { message, duration = 5000, type = 'success' }: Props = Astro.props;
// Конфигурация стилей и иконок
const config = {
success: {
bg: 'bg-white border-green-500',
text: 'text-gray-800',
iconColor: 'text-green-500',
iconPath: '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'
},
error: {
bg: 'bg-white border-red-500',
text: 'text-gray-800',
iconColor: 'text-red-500',
iconPath: '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'
},
info: {
bg: 'bg-white border-blue-500',
text: 'text-gray-800',
iconColor: 'text-blue-500',
iconPath: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
},
warning: {
bg: 'bg-white border-yellow-500',
text: 'text-gray-800',
iconColor: 'text-yellow-500',
iconPath: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
}
};
const currentConfig = config[type];
---
<div
id="notification-toast"
class={`fixed top-5 left-1/2 transform -translate-x-1/2 z-[100] flex items-center w-full max-w-sm p-4 mb-4 text-gray-500 bg-white rounded-lg shadow-xl border-l-4 transition-all duration-500 ease-in-out opacity-0 -translate-y-full ${currentConfig.bg}`}
role="alert"
data-duration={duration}
>
<div class={`inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${currentConfig.iconColor} bg-opacity-10`}>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d={currentConfig.iconPath} clip-rule="evenodd"></path>
</svg>
</div>
<div class={`ml-3 text-sm font-medium ${currentConfig.text}`}>
{message}
</div>
<button
type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 hover:cursor-pointer rounded-full focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 transition-colors"
aria-label="Schließen"
id="close-toast-btn"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<script>
// Логика анимации
const toast = document.getElementById('notification-toast');
const closeBtn = document.getElementById('close-toast-btn');
if (toast) {
// Получаем длительность из data-атрибута (так как Astro пропсы не доступны напрямую в скрипте)
const duration = parseInt(toast.dataset.duration || '5000', 10);
// 1. Анимация появления (ждем небольшой тик, чтобы браузер отрисовал начальное состояние)
requestAnimationFrame(() => {
// Убираем классы скрытия и добавляем классы видимости
toast.classList.remove('opacity-0', '-translate-y-full');
toast.classList.add('opacity-100', 'translate-y-0');
});
// Функция скрытия
const hideToast = () => {
toast.classList.remove('opacity-100', 'translate-y-0');
toast.classList.add('opacity-0', '-translate-y-full');
// Удаляем из DOM после завершения анимации (500мс совпадает с duration-500 в CSS)
setTimeout(() => {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 500);
};
// 2. Таймер автоскрытия
const timer = setTimeout(hideToast, duration);
// 3. Обработка клика по крестику
if (closeBtn) {
closeBtn.addEventListener('click', () => {
clearTimeout(timer); // Отменяем таймер, чтобы не сработало дважды
hideToast();
});
}
}
</script>

View file

@ -0,0 +1,51 @@
---
interface Props {
page: {
url: {
prev?: string;
next?: string;
};
currentPage: number;
lastPage: number;
};
}
const { page } = Astro.props;
const { prev, next } = page.url;
---
{page.lastPage > 1 && (
<nav class="flex justify-center items-center gap-6 mt-12" aria-label="Pagination">
{prev ? (
<a href={prev} class="inline-flex items-center justify-center w-12 h-12 bg-white rounded-full border border-gray-200 text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:shadow-md transition-all duration-300 group" aria-label="Vorherige Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
) : (
<span class="inline-flex items-center justify-center w-12 h-12 bg-white rounded-full border border-gray-200 text-gray-300 cursor-not-allowed" aria-label="Vorherige Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</span>
)}
<span class="text-sm text-gray-700">
Seite <span class="font-medium">{page.currentPage}</span> von <span class="font-medium">{page.lastPage}</span>
</span>
{next ? (
<a href={next} class="inline-flex items-center justify-center w-12 h-12 bg-white rounded-full border border-gray-200 text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:shadow-md transition-all duration-300 group" aria-label="Nächste Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
) : (
<span class="inline-flex items-center justify-center w-12 h-12 bg-white rounded-full border border-gray-200 text-gray-300 cursor-not-allowed" aria-label="Nächste Seite">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</span>
)}
</nav>
)}

View file

@ -0,0 +1,103 @@
import { type ParentProps, createEffect, onCleanup, onMount } from 'solid-js';
import { isServer } from 'solid-js/web';
import { Portal } from 'solid-js/web'; // Добавлен Portal
import { FaSolidX } from 'solid-icons/fa';
interface ModalProps extends ParentProps {
isOpen: boolean;
onClose: () => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
export const SolidModal = (props: ModalProps) => {
const sizeClass = sizeClasses[props.size ?? 'md'];
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
props.onClose();
}
};
const handleOverlayClick = () => {
props.onClose();
};
onMount(() => {
if (!isServer) {
document.addEventListener('keydown', handleEscKey);
}
});
onCleanup(() => {
if (!isServer) {
document.removeEventListener('keydown', handleEscKey);
}
});
createEffect(() => {
if (!isServer) {
if (props.isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
});
return (
<Portal>
<div
class={`fixed inset-0 z-[9999] flex items-center justify-center transition-opacity duration-300 ${
props.isOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}
>
{/* Оверлей с обработчиком клика */}
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={handleOverlayClick}
></div>
<div
class={`relative bg-white rounded-xl shadow-2xl w-full ${sizeClass} max-h-[90vh] overflow-hidden transition-transform duration-300 ${
props.isOpen ? 'scale-100' : 'scale-95'
}`}
onClick={(e) => e.stopPropagation()} // Предотвращает закрытие при клике на модалку
>
{props.title && (
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">{props.title}</h3>
<button
onClick={props.onClose}
class="flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 transition-colors bg-gray-200 hover:bg-gray-300 rounded-full hover:cursor-pointer"
aria-label="Close modal"
>
<FaSolidX size={16} />
</button>
</div>
)}
{!props.title && (
<div class="absolute top-3 right-3">
<button
onClick={props.onClose}
class="flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 transition-colors bg-gray-200 hover:bg-gray-300 rounded-full hover:cursor-pointer"
aria-label="Close modal"
>
<FaSolidX size={16} />
</button>
</div>
)}
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
{props.children}
</div>
</div>
</div>
</Portal>
);
};

View file

@ -0,0 +1,74 @@
---
// Массив партнеров с инлайновым SVG кодом
const partners = [
{
id: 1,
name: 'Google',
url: 'https://google.com',
// SVG код Google
svg: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>`
},
{
id: 2,
name: 'Microsoft',
url: 'https://microsoft.com',
// SVG код Microsoft
svg: `<svg viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg"><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg>`
},
{
id: 3,
name: 'Amazon',
url: 'https://amazon.com',
// SVG код Amazon (иконка)
svg: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M13.676 12.834c.486 1.83 2.62 2.637 4.148 1.583-.178.697-.665 1.574-1.636 1.85-1.428.397-2.736-.574-2.512-3.433zm4.555-5.32c-.179-1.996-.75-2.64-1.848-2.64-1.296 0-2.062 1.34-2.062 3.612 0 2.593.738 3.86 1.933 3.86 1.254 0 1.92-1.272 1.977-4.832zm-1.076 8.94c1.116-.367 2.016-1.528 2.016-1.528s.16 1.055.228 1.487c.062.403.4.526.702.392.298-.13.314-.492.21-.905-.333-1.332-1.226-5.83-1.226-5.83-.55-2.83-2.318-3.92-4.32-3.92-3.407 0-4.665 2.502-4.665 5.253 0 3.737 2.096 4.965 2.096 4.965s-.222-.843-.302-1.37c-.08-.528.275-.82.68-.94.405-.12.822.13.91.564.088.435.67 1.832 1.67 1.832zM2.872 17.065c-.477.387-.27.854.168.98 4.295 1.205 9.423.868 13.064-1.096.39-.21.284-.716-.134-.69-1.88.11-5.186.206-8.204-.32-1.78-.31-3.64-.707-4.894 1.126zm14.372-2.19c.174.12.44.02.484-.216.208-1.085.98-2.327 2.053-2.15.54.09.76.626.544 1.25-.33 1.066-1.157 2.45-2.924 2.515-.368.013-.485-.27-.478-.47.007-.2.146-.81.32-1.03z"/></svg>`
},
{
id: 4,
name: 'Apple',
url: 'https://apple.com',
// SVG код Apple
svg: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.68-.83 1.14-1.99 1.01-3.15-1.09.06-2.4.74-3.18 1.67-.62.72-1.15 1.9-1 3.03 1.21.09 2.47-.7 3.17-1.55z"/></svg>`
}
];
---
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-gray-800">Unsere Partner</h3>
<!-- Декоративная точка -->
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
</div>
<!-- Сетка партнеров -->
<div class="grid grid-cols-2 gap-3">
{partners.map((partner) => (
<a
href={partner.url}
target="_blank"
rel="noopener noreferrer nofollow"
class="group flex flex-col items-center justify-center p-4 bg-gray-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-gray-100 transition-all duration-300 ease-in-out"
title={partner.name}
>
<!--
Используем set:html для вставки SVG из строки.
Классы стилизации применяются к обертке div, а [&>svg] стилизует сам SVG внутри.
-->
<div
class="w-8 h-8 mb-2 filter grayscale opacity-70 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-300 transform group-hover:scale-110 [&>svg]:w-full [&>svg]:h-full"
set:html={partner.svg}
/>
<span class="text-xs font-medium text-gray-500 group-hover:text-gray-800 transition-colors">
{partner.name}
</span>
</a>
))}
</div>
<!-- Подпись -->
<div class="mt-5 pt-4 border-t border-gray-100 text-center">
<p class="text-[10px] uppercase tracking-wider text-gray-400 font-semibold">
Trusted by Leaders
</p>
</div>
</div>

View file

@ -0,0 +1,35 @@
---
interface Props {
posts: any[]; // Массив статей из PocketBase
}
const { posts } = Astro.props;
if (!posts || posts.length === 0) return null;
---
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-gray-800">Meistgelesen</h3>
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
</div>
<div class="space-y-4">
{posts.slice(0, 3).map((post, index) => (
<a href={`/blog/${post.slug}`} class="group flex gap-4 items-start">
<!-- Номер (или миниатюра) -->
<div class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-gray-50 text-gray-400 font-bold text-sm group-hover:bg-blue-50 group-hover:text-blue-600 transition-colors">
{index + 1}
</div>
<div>
<h4 class="text-sm font-semibold text-gray-700 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2">
{post.title}
</h4>
<p class="text-xs text-gray-400 mt-1">
{new Date(post.date).toLocaleDateString('de-DE')}
</p>
</div>
</a>
))}
</div>
</div>

View file

@ -0,0 +1,85 @@
---
import PostTags from '@components/blog/PostTags.astro';
interface Props {
post: any; // Объект статьи из PocketBase
compact?: boolean; // Опция для компактного вида (опционально)
}
const { post, compact = false } = Astro.props;
// Форматирование даты
const formattedDate = new Date(post.date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
---
<a
href={`/blog/${post.slug}`}
class="group flex flex-col h-full bg-white rounded-2xl overflow-hidden border border-gray-100 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out"
aria-label={`Artikel lesen: ${post.title}`}
>
<!-- Контейнер изображения -->
<div class="relative w-full aspect-[16/9] overflow-hidden bg-gray-100">
{post.image ? (
<img
src={`${import.meta.env.POCKETBASE_URL || import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/${post.collectionId}/${post.id}/${post.image}`}
alt={post.title}
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
width="800"
height="450"
/>
) : (
// Заглушка, если нет картинки
<div class="w-full h-full flex items-center justify-center bg-gray-50 text-gray-300">
<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<!-- Оверлей даты поверх картинки (стильный бейдж) -->
<div class="absolute top-3 right-3 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-bold text-gray-700 shadow-sm border border-white/20">
{formattedDate}
</div>
</div>
<!-- Контент -->
<div class="flex flex-col grow p-6">
<!-- Автор (если есть) -->
{post.author && (
<div class="text-xs text-gray-400 font-medium mb-2 uppercase tracking-wide">
{post.author}
</div>
)}
<h3 class="text-xl font-bold text-gray-900 mb-3 line-clamp-2 leading-tight group-hover:text-blue-600 transition-colors">
{post.title}
</h3>
{!compact && (
<p class="text-gray-600 text-sm leading-relaxed mb-4 line-clamp-3 grow">
{post.description}
</p>
)}
<!-- Футер карточки с тегами -->
<div class="mt-auto pt-4 border-t border-gray-50 flex items-center justify-between">
<PostTags
tags={post.tags || []}
size="sm"
isLink={false}
maxVisible={2}
/>
<!-- Иконка стрелки -->
<span class="text-blue-500 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
</div>
</div>
</a>

View file

@ -0,0 +1,50 @@
---
import Tag from '@components/blog/Tag.astro';
interface Props {
tags: string[];
maxVisible?: number;
size?: 'sm' | 'md';
className?: string;
isLink?: boolean;
}
const {
tags = [],
maxVisible = 3, // Увеличил дефолт до 3, так красивее
size = 'md',
className = '',
isLink = true,
} = Astro.props;
if (!tags || (Array.isArray(tags) && tags.length === 0) || (typeof tags === 'string' && tags.length === 0)) return null;
let processedTags = [];
if (Array.isArray(tags)) {
processedTags = tags;
} else if (typeof tags === 'string') {
try {
processedTags = JSON.parse(tags);
} catch (e) {
console.error('Error parsing tags:', e);
processedTags = [];
}
} else {
processedTags = [];
}
const visibleTags = processedTags.slice(0, maxVisible);
const hiddenTagsCount = processedTags.length - visibleTags.length;
---
<div class={`flex flex-wrap items-center gap-2 ${className}`}>
{visibleTags.map((tag) => (
<Tag tag={tag} size={size} isLink={isLink} />
))}
{hiddenTagsCount > 0 && (
<span class={`font-medium text-gray-400 select-none ${size === 'sm' ? 'text-xs' : 'text-sm'}`}>
+{hiddenTagsCount}
</span>
)}
</div>

View file

@ -0,0 +1,62 @@
---
const socialLinks = [
{
name: 'GitHub',
href: '#',
iconPath: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z",
colorClass: 'hover:bg-gray-900 hover:text-white',
},
{
name: 'Telegram',
href: '#',
iconPath: "M9.028 20.837c-.714 0-.593-.271-.839-.949l-2.103-6.92L22.263 3.37",
iconPath2: "M9.028 20.837c.552 0 .795-.252 1.105-.553l2.941-2.857-3.671-2.214",
iconPath3: "M9.403 15.213l8.89 6.568c1.015.56 1.748.271 2.004-.942l3.62-17.053c.372-1.483-.564-2.155-1.534-1.72L1.125 10.263c-1.45.582-1.443 1.392-.266 1.753l5.455 1.729 12.653-7.982c.596-.399 1.143-.185.693.213",
colorClass: 'hover:bg-blue-500 hover:text-white',
viewBox: "0 0 24 24"
},
{
name: 'YouTube',
href: '#',
iconPath: "M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z",
colorClass: 'hover:bg-red-600 hover:text-white',
},
];
---
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-800">Folgen Sie uns</h3>
<!-- Иконка share или network -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<p class="text-xs text-gray-500 mb-5 leading-relaxed">
Bleiben Sie auf dem Laufenden und treten Sie unserer Community bei.
</p>
<div class="flex justify-between gap-2">
{socialLinks.map((link) => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Unsere ${link.name}-Seite`}
class={`flex-1 py-3 flex items-center justify-center rounded-xl bg-gray-50 text-gray-400 transition-all duration-300 ${link.colorClass}`}
title={link.name}
>
<svg
class="w-6 h-6 fill-current"
viewBox={link.viewBox || "0 0 24 24"}
xmlns="http://www.w3.org/2000/svg"
>
<path d={link.iconPath} />
{link.iconPath2 && <path d={link.iconPath2} />}
{link.iconPath3 && <path d={link.iconPath3} />}
</svg>
</a>
))}
</div>
</div>

View file

@ -0,0 +1,52 @@
---
interface Props {
tag: string;
count?: number;
size?: 'sm' | 'md';
isLink?: boolean;
}
const { tag, count, size = 'md', isLink = true } = Astro.props;
// Настройка размеров
const sizeClasses = {
sm: 'px-2.5 py-0.5 text-xs',
md: 'px-3 py-1.5 text-sm',
};
// Базовые стили: используем slate гамму
const baseClass = `
${sizeClasses[size]}
font-medium rounded-full
inline-flex items-center
bg-slate-100 text-slate-600 border border-transparent
transition-all duration-200 ease-in-out
`;
// Стили при наведении (только если это ссылка) - используем indigo (синий) для hover
const hoverClass = isLink
? "hover:bg-indigo-50 hover:text-indigo-600 hover:border-indigo-100 hover:shadow-sm cursor-pointer active:scale-95"
: "cursor-default";
---
<div class="inline-block">
{isLink ? (
<a href={`/blog/tags/${encodeURIComponent(tag)}`} class={`${baseClass} ${hoverClass}`}>
<span class="opacity-50 mr-1 font-bold">#</span>
{tag}
{count !== undefined && (
<span class="ml-1.5 opacity-60 text-[0.9em] bg-white/50 px-1.5 rounded-full">
{count}
</span>
)}
</a>
) : (
<span class={`${baseClass} ${hoverClass}`}>
<span class="opacity-50 mr-1 font-bold">#</span>
{tag}
{count !== undefined && (
<span class="ml-1.5 opacity-60">({count})</span>
)}
</span>
)}
</div>

View file

@ -0,0 +1,124 @@
---
import Tag from '@components/blog/Tag.astro';
// Если у вас нет компонента Icon, можно использовать SVG напрямую, как показано ниже
import { Icon } from 'astro-icon/components';
interface Props {
tags: Record<string, number>;
className?: string;
initialVisibleCount?: number;
incrementCount?: number;
title?: string;
}
const {
tags,
className = '',
initialVisibleCount = 10, // Чуть увеличил дефолтное значение для компактности
incrementCount = 10,
title = 'Beliebte Themen'
} = Astro.props;
if (!tags) return null;
// Проверяем, что tags - это объект, а не что-то другое
if (!tags || typeof tags !== 'object') {
console.error('Tags is not an object:', tags);
return null;
}
// Сортируем теги по популярности (количеству), чтобы самые важные были первыми
const allTags = Object.entries(tags).sort(([, countA], [, countB]) => countB - countA);
if (allTags.length === 0) return null;
// Генерируем уникальные ID для изоляции логики этого конкретного компонента
const uniqueId = Math.random().toString(36).substr(2, 9);
const containerId = `tag-container-${uniqueId}`;
const btnId = `show-more-btn-${uniqueId}`;
---
<div class={`bg-white rounded-2xl p-6 shadow-sm border border-gray-100 ${className}`}>
<!-- Заголовок в стиле компонента партнеров -->
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-gray-800">{title}</h3>
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
</div>
<!-- Контейнер тегов -->
<div id={containerId} class="flex flex-wrap gap-2 mb-2">
{allTags.map(([tag, count], index) => (
<div
class={`transition-all duration-500 ease-out ${index >= initialVisibleCount ? 'hidden opacity-0 translate-y-2 tag-hidden' : 'opacity-100 translate-y-0'}`}
>
{/* Обертка для Tag, чтобы передать классы или стили, если сам Tag их не принимает */}
<Tag tag={tag} count={count} />
</div>
))}
</div>
<!-- Кнопка "Показать еще" -->
{allTags.length > initialVisibleCount && (
<div class="mt-4 pt-2 border-t border-gray-50 flex justify-center">
<button
id={btnId}
data-increment={incrementCount}
class="group flex items-center gap-1 text-xs font-semibold text-gray-500 hover:text-blue-600 transition-colors uppercase tracking-wide py-2 px-4 rounded-lg hover:bg-blue-50"
>
<span>Mehr anzeigen</span>
<!-- SVG Chevron Down -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 transition-transform duration-300 group-hover:translate-y-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
)}
</div>
<script define:vars={{ containerId, btnId }}>
const container = document.getElementById(containerId);
const btn = document.getElementById(btnId);
if (container && btn) {
btn.addEventListener('click', () => {
// Находим все скрытые теги внутри ЭТОГО контейнера
const hiddenTags = container.querySelectorAll('.tag-hidden');
const increment = parseInt(btn.dataset.increment || '6');
let count = 0;
// Используем Array.from, чтобы можно было прервать цикл или использовать обычный for
for (const tagWrapper of hiddenTags) {
if (count >= increment) break;
// Убираем класс hidden (display: none)
tagWrapper.classList.remove('hidden');
// Небольшая задержка перед снятием прозрачности для запуска CSS transition
// requestAnimationFrame гарантирует, что браузер отрисовал удаление hidden
requestAnimationFrame(() => {
tagWrapper.classList.remove('opacity-0', 'translate-y-2', 'tag-hidden');
tagWrapper.classList.add('opacity-100', 'translate-y-0');
});
count++;
}
// Если скрытых тегов больше нет, прячем кнопку
const remainingHidden = container.querySelectorAll('.tag-hidden').length;
if (remainingHidden === 0) {
btn.style.display = 'none';
// Опционально: можно убрать отступы родительского контейнера кнопки
btn.parentElement.style.display = 'none';
}
});
}
</script>

View file

@ -0,0 +1,31 @@
import { type Component, Show } from 'solid-js';
import { FaSolidUsers, FaSolidTag } from 'solid-icons/fa';
import type { Car } from '../home/slider/SliderContent';
export const BookingCarSummary: Component<{ car?: Car }> = (props) => (
<Show when={props.car}>
{(car) => (
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200 transition-all hover:shadow-md mb-6">
<p class="text-sm font-semibold text-gray-600 mb-2">Ihre Auswahl:</p>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<img src={car().src} alt={`${car().make} ${car().model}`} class="w-24 h-16 object-cover rounded-md shadow-sm" />
<div class="flex-grow">
<h4 class="font-bold text-gray-900">{car().make} {car().model} ({car().year})</h4>
</div>
</div>
{/* Блок с информацией о вместимости и цене под изображением */}
<div class="mt-3 flex flex-wrap gap-4 text-sm text-gray-600">
<div class="flex items-center gap-1.5">
<FaSolidUsers />
<span>{car().seats[0]}</span>
</div>
<div class="flex items-center gap-1.5">
<FaSolidTag />
<span>{car().price}</span>
</div>
</div>
</div>
)}
</Show>
);

View file

@ -0,0 +1,73 @@
import { type Component, type JSXElement, Show, mergeProps } from 'solid-js';
import { FaSolidCircleExclamation } from 'solid-icons/fa';
// Типы пропсов
interface InputGroupProps {
id: string; // ID для связки label с input
label: string; // Текст заголовка
icon: JSXElement; // Иконка слева
children: JSXElement; // Сам input/select/textarea
error?: string; // Текст ошибки (если есть)
accentColor?: 'amber' | 'red' | 'blue'; // Цвет фокуса
}
// Карта цветов для фокуса (можно вынести в отдельный конфиг, если используется везде)
const colorMap = {
amber: 'focus-within:border-amber-500 focus-within:ring-1 focus-within:ring-amber-500',
red: 'focus-within:border-red-500 focus-within:ring-1 focus-within:ring-red-500',
blue: 'focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500'
};
const InputGroup: Component<InputGroupProps> = (rawProps) => {
// Устанавливаем значение по умолчанию для цвета, если не передано
const props = mergeProps({ accentColor: 'amber' as const }, rawProps);
// Вычисляем класс для цвета фокуса
const focusColorClass = () => colorMap[props.accentColor];
return (
<div class="relative">
{/* Плавающий лейбл сверху */}
<label
for={props.id}
class="absolute -top-2 left-2 inline-block bg-white px-1 text-xs font-medium z-10 text-gray-700"
>
{props.label}
</label>
{/* Контейнер для иконки и инпута */}
<div
class={`
flex items-center border rounded-md shadow-sm transition-colors duration-200
${props.error
? 'border-red-500 bg-red-50/10' // Стиль при ошибке
: `border-gray-300 bg-white ${focusColorClass()}` // Обычный стиль + цвет фокуса
}
`}
>
{/* Иконка слева */}
<div
class={`mx-3 flex-shrink-0 transition-colors ${props.error ? 'text-red-400' : 'text-gray-400'}`}
>
{props.icon}
</div>
{/* Сюда вставляется input, select или phone-input */}
{props.children}
</div>
{/* Блок вывода ошибки снизу */}
<Show when={props.error}>
<p
id={`${props.id}-error`}
class="mt-1 text-xs text-red-600 flex items-center gap-1 animate-fadeIn"
role="alert"
>
<FaSolidCircleExclamation /> {props.error}
</p>
</Show>
</div>
);
};
export default InputGroup;

View file

@ -0,0 +1,136 @@
import { type Component, onMount, onCleanup, Show } from 'solid-js';
import { FaSolidLocationDot, FaSolidCalendarDays, FaSolidClock, FaSolidCircleExclamation } from 'solid-icons/fa';
import { colorMap } from './styles';
import InputGroup from './InputGroup';
import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.min.css";
import { German } from "flatpickr/dist/l10n/de.js";
interface LocationFieldsetProps {
title: string;
accentColor: 'amber' | 'red' | 'blue';
locationId: string;
dateId: string;
timeId: string;
locationLabel: string;
locationPlaceholder: string;
dateLabel: string;
timeLabel: string;
locationValue: string;
dateValue: string;
timeValue: string;
onLocationChange: (val: string) => void;
onDateChange: (val: string) => void;
onTimeChange: (val: string) => void;
locationError?: string;
dateError?: string;
timeError?: string;
minDate?: string;
}
export const LocationFieldset: Component<LocationFieldsetProps> = (props) => {
const focusColorClass = () => colorMap.focus[props.accentColor];
let dateInputRef: HTMLInputElement | undefined;
let datePickerInstance: any;
onMount(() => {
// 1. Инициализация календаря ТОЛЬКО для Даты
if (dateInputRef) {
datePickerInstance = flatpickr(dateInputRef, {
locale: German,
dateFormat: "Y-m-d",
altInput: true,
altFormat: "d.m.Y",
minDate: props.minDate || "today",
disableMobile: true,
defaultDate: props.dateValue,
onChange: (selectedDates, dateStr) => {
props.onDateChange(dateStr);
}
});
}
});
onCleanup(() => {
if (datePickerInstance && typeof datePickerInstance.destroy === 'function') {
datePickerInstance.destroy();
}
});
return (
<fieldset class="relative space-y-4 rounded-md border border-gray-200 p-4 pt-6 bg-white/50 mb-6">
<legend class="absolute -top-2.5 left-2 bg-white px-1 text-sm font-medium text-gray-600">
{props.title}
</legend>
<InputGroup
id={props.locationId}
label={props.locationLabel}
icon={<FaSolidLocationDot />}
error={props.locationError}
accentColor={props.accentColor}
>
<input
id={props.locationId}
type="text"
value={props.locationValue}
onInput={(e) => props.onLocationChange(e.currentTarget.value)}
placeholder={props.locationPlaceholder}
class="block w-full border-0 p-2.5 placeholder-gray-400 focus:ring-0 sm:text-sm bg-transparent"
aria-invalid={!!props.locationError}
/>
</InputGroup>
<div class="grid grid-cols-2 gap-4">
{/* Поле ДАТЫ (Flatpickr) */}
<div class="relative">
<label for={props.dateId} class="absolute -top-2 left-2 inline-block bg-white px-1 text-xs font-medium z-10">
{props.dateLabel}
</label>
<div class={`relative flex items-center border rounded-md shadow-sm transition-colors ${props.dateError ? 'border-red-500' : `border-gray-300 ${focusColorClass()}`}`}>
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 z-10 text-gray-400 pointer-events-none">
<FaSolidCalendarDays />
</div>
<input
ref={dateInputRef}
id={props.dateId}
type="text"
placeholder="TT.MM.JJJJ"
class="block w-full border-0 p-2.5 pl-10 focus:ring-0 sm:text-sm bg-transparent cursor-pointer"
aria-invalid={!!props.dateError}
autocomplete="off"
/>
</div>
<Show when={props.dateError}>
<p class="mt-1 text-xs text-red-600 flex items-center gap-1"><FaSolidCircleExclamation /> {props.dateError}</p>
</Show>
</div>
{/* Поле ВРЕМЕНИ (Нативное) */}
<div class="relative">
<label for={props.timeId} class="absolute -top-2 left-2 inline-block bg-white px-1 text-xs font-medium z-10">
{props.timeLabel}
</label>
<div class={`relative flex items-center border rounded-md shadow-sm transition-colors ${props.timeError ? 'border-red-500' : `border-gray-300 ${focusColorClass()}`}`}>
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 z-10 text-gray-400 pointer-events-none">
<FaSolidClock />
</div>
<input
id={props.timeId}
type="time"
value={props.timeValue}
onInput={(e) => props.onTimeChange(e.currentTarget.value)}
class="block w-full border-0 p-2.5 pl-10 focus:ring-0 sm:text-sm bg-transparent [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60 hover:[&::-webkit-calendar-picker-indicator]:opacity-100"
aria-invalid={!!props.timeError}
/>
</div>
<Show when={props.timeError}>
<p class="mt-1 text-xs text-red-600 flex items-center gap-1"><FaSolidCircleExclamation /> {props.timeError}</p>
</Show>
</div>
</div>
</fieldset>
);
};

View file

@ -0,0 +1,135 @@
import { type Component, createSignal } from 'solid-js';
import { FaSolidUsers, FaSolidUser, FaSolidPhone, FaSolidChevronDown, FaSolidCircleInfo } from 'solid-icons/fa';
import InputGroup from './InputGroup';
import PhoneInput from './PhoneInput';
import { colorMap } from './styles';
interface PersonalDetailsProps {
accentColor: 'amber' | 'red' | 'blue';
content: any; // Tip: Используйте HeroContent
// IDs
passengersId: string;
nameId: string;
phoneId: string;
infoId: string;
// Values
passengersValue: string;
nameValue: string;
phoneValue: string;
infoValue: string;
// Setters
onPassengersChange: (val: string) => void;
onNameChange: (val: string) => void;
onPhoneChange: (val: string) => void;
onInfoChange: (val: string) => void;
// Errors
passengersError?: string;
nameError?: string;
phoneError?: string;
}
export const PersonalDetails: Component<PersonalDetailsProps> = (props) => {
const [menuOpen, setMenuOpen] = createSignal(false);
const maxChars = 200;
return (
<div class="space-y-4 mb-2">
<InputGroup
id={props.passengersId}
label={props.content.passengers}
icon={<FaSolidUsers />}
error={props.passengersError}
accentColor={props.accentColor}
>
<div class="relative w-full">
<select
id={props.passengersId}
value={props.passengersValue}
onFocus={() => setMenuOpen(true)}
onBlur={() => setMenuOpen(false)}
onChange={(e) => {
props.onPassengersChange(e.currentTarget.value);
setMenuOpen(false);
e.currentTarget.blur();
}}
class="block w-full border-0 p-2.5 pr-10 appearance-none focus:ring-0 sm:text-sm bg-transparent cursor-pointer"
>
<option value="">{props.content.selectOption}</option>
<option value="1-3">1-3</option>
<option value="4-6">4-6</option>
<option value="7-8">7-8</option>
<option value="9+">{props.content.multipleVans}</option>
</select>
<div class={`absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none text-gray-400 transition-transform duration-200 ${menuOpen() ? 'rotate-180' : ''}`}>
<FaSolidChevronDown />
</div>
</div>
</InputGroup>
<InputGroup
id={props.nameId}
label={props.content.name}
icon={<FaSolidUser />}
error={props.nameError}
accentColor={props.accentColor}
>
<input
id={props.nameId}
type="text"
value={props.nameValue}
onInput={(e) => props.onNameChange(e.currentTarget.value)}
placeholder={props.content.name}
autocomplete="name"
class="block w-full border-0 p-2.5 placeholder-gray-400 focus:ring-0 sm:text-sm bg-transparent"
/>
</InputGroup>
<InputGroup
id={props.phoneId}
label={props.content.phone}
icon={<FaSolidPhone />}
error={props.phoneError}
accentColor={props.accentColor}
>
<PhoneInput
id={props.phoneId}
value={props.phoneValue}
onInput={props.onPhoneChange}
placeholder="+49 000 0000000"
class="block w-full border-0 p-2.5 placeholder-gray-400 focus:ring-0 sm:text-sm bg-transparent"
aria-invalid={!!props.phoneError}
aria-describedby={props.phoneError ? `${props.phoneId}-error` : undefined}
/>
</InputGroup>
<div class="relative pb-2">
<label for={props.infoId} class="absolute -top-2 left-2 inline-block bg-white px-1 text-xs font-medium z-10">
{props.content.additionalInfo}
</label>
<div class={`relative flex items-start border rounded-md shadow-sm transition-colors border-gray-300 ${colorMap.focus[props.accentColor]} bg-white`}>
<div class="absolute left-3 top-3 z-10 text-gray-400">
<FaSolidCircleInfo />
</div>
<div class="w-full pl-10">
<textarea
id={props.infoId}
rows={2}
value={props.infoValue}
onInput={(e) => props.onInfoChange(e.currentTarget.value.slice(0, maxChars))}
placeholder={props.content.additionalInfoPlaceholder}
class="block w-full border-0 p-2.5 placeholder-gray-400 focus:ring-0 sm:text-sm resize-none bg-transparent"
/>
<div class="flex justify-end px-3 py-1 text-xs select-none">
<span classList={{
'text-red-600 font-bold': props.infoValue.length >= maxChars,
'text-gray-400': props.infoValue.length < maxChars
}}>
{props.infoValue.length}/{maxChars}
</span>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,76 @@
import { type Component, onMount, onCleanup, createEffect } from 'solid-js';
import IMask from 'imask';
interface PhoneInputProps {
value: string;
onInput: (value: string) => void;
placeholder?: string;
class?: string;
id?: string;
'aria-invalid'?: boolean | "false" | "true" | "grammar" | "spelling";
'aria-describedby'?: string;
}
const PhoneInput: Component<PhoneInputProps> = (props) => {
let inputRef: HTMLInputElement | undefined;
// Используем ReturnType для правильной типизации инстанса маски
let maskInstance: ReturnType<typeof IMask> | null = null;
onMount(() => {
if (!inputRef) return;
try {
// Инициализируем маску
maskInstance = IMask(inputRef, {
mask: '+{49} 000 000000[00000]',
lazy: false,
placeholderChar: '_'
});
// Устанавливаем начальное значение
if (props.value) {
maskInstance.value = props.value;
}
// Слушаем ввод
maskInstance.on('accept', () => {
if (maskInstance) {
props.onInput(maskInstance.value);
}
});
} catch (e) {
console.error("IMask initialization failed", e);
}
});
onCleanup(() => {
if (maskInstance) {
maskInstance.destroy();
maskInstance = null;
}
});
// Реакция на изменение props.value извне (например, очистка формы)
createEffect(() => {
const val = props.value || '';
if (maskInstance && maskInstance.value !== val) {
maskInstance.value = val;
}
});
return (
<input
ref={inputRef}
type="tel"
placeholder={props.placeholder}
class={props.class}
id={props.id}
aria-invalid={props['aria-invalid']}
aria-describedby={props['aria-describedby']}
autocomplete="tel"
/>
);
};
export default PhoneInput;

View file

@ -0,0 +1,200 @@
import { type Component, Show, createEffect } from 'solid-js'; // Добавили createEffect
import { FaSolidCircleExclamation, FaSolidCheck, FaSolidXmark, FaSolidClock } from 'solid-icons/fa';
import type { HeroContent } from '../home/hero/HeroContent';
import type { Car } from '../home/slider/SliderContent';
import { Button } from '@/components/base/Buttons';
import { createBookingForm } from './useBookingForm';
import { BookingCarSummary } from './BookingCarSummary';
import { LocationFieldset } from './LocationFieldset';
import { PersonalDetails } from './PersonalDetails';
import { colorMap } from './styles';
interface UniversalBookingFormProps {
content: HeroContent;
car?: Car;
showDiscount?: boolean;
formType?: 'main' | 'slider' | 'action';
accentColor?: 'amber' | 'red' | 'blue';
}
export const UniversalBookingForm: Component<UniversalBookingFormProps> = (props) => {
if (!props.content) return null;
const accentColor = () => props.accentColor || 'amber';
const form = createBookingForm({
content: props.content,
car: props.car, // Это начальное значение
showDiscount: props.showDiscount,
formType: props.formType,
});
// ВАЖНО: Следим за изменением props.car и обновляем хук
createEffect(() => {
// Когда props.car меняется (при клике на карточку),
// обновляем внутреннее состояние хука
form.setSelectedCar(props.car);
});
return (
<div class="relative w-full h-full min-h-[400px]">
{/* Блок Успеха */}
<Show when={form.successMessage()}>
<div class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white rounded-xl p-8 animate-fadeIn">
<div class={`w-20 h-20 rounded-full bg-green-50 flex items-center justify-center mb-6 shadow-sm border border-green-100`}>
<FaSolidCheck class="w-10 h-10 text-green-500" />
</div>
<h3 class="text-3xl font-bold text-gray-900 mb-3 tracking-tight">
Vielen Dank!
</h3>
<p class="text-gray-600 text-lg text-center leading-relaxed mb-8 max-w-xs mx-auto">
{form.successMessage()}
</p>
<div class="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-full text-sm font-medium text-gray-400 border border-gray-100">
<FaSolidClock class="w-4 h-4" />
<span>Schließt automatisch in 5 Sek.</span>
</div>
</div>
</Show>
{/* Блок Ошибки */}
<Show when={form.errorMessage()}>
<div class="mb-6 p-4 rounded-lg bg-red-50 border border-red-200 animate-fadeIn shadow-sm">
<div class="flex items-start">
<div class="flex-shrink-0 mt-0.5">
<FaSolidCircleExclamation class="h-5 w-5 text-red-500" />
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-red-800">
Ein Fehler ist aufgetreten
</h3>
<p class="text-sm text-red-700 mt-1">
{form.errorMessage()}
</p>
</div>
</div>
</div>
</Show>
{/* Основная Форма */}
<Show when={!form.successMessage()}>
<form onSubmit={form.submitForm} class="space-y-6 text-gray-900" noValidate>
<BookingCarSummary car={props.car} />
<div class="sr-only" aria-hidden="true">
<input
type="text"
name="confirm_email"
tabIndex={-1}
autocomplete="off"
value={form.honeypot()}
onInput={(e) => form.setHoneypot(e.currentTarget.value)}
/>
</div>
<LocationFieldset
title={props.content.pickupDetailsTitle}
accentColor={accentColor()}
locationId={form.ids.pickup}
dateId={form.ids.pickupDate}
timeId={form.ids.pickupTime}
locationLabel={props.content.pickup}
locationPlaceholder={props.content.pickupPlaceholder}
dateLabel={props.content.date}
timeLabel={props.content.time}
locationValue={form.pickup()}
dateValue={form.pickupDate()}
timeValue={form.pickupTime()}
onLocationChange={form.setPickup}
onDateChange={form.setPickupDate}
onTimeChange={form.setPickupTime}
locationError={form.errors().pickup}
dateError={form.errors().pickupDate}
timeError={form.errors().pickupTime}
minDate={new Date().toISOString().split('T')[0]}
/>
<LocationFieldset
title={props.content.dropoffDetailsTitle}
accentColor={accentColor()}
locationId={form.ids.dropoff}
dateId={form.ids.dropoffDate}
timeId={form.ids.dropoffTime}
locationLabel={props.content.dropoff}
locationPlaceholder={props.content.dropoffPlaceholder}
dateLabel={props.content.date}
timeLabel={props.content.time}
locationValue={form.dropoff()}
dateValue={form.dropoffDate()}
timeValue={form.dropoffTime()}
onLocationChange={form.setDropoff}
onDateChange={form.setDropoffDate}
onTimeChange={form.setDropoffTime}
locationError={form.errors().dropoff}
dateError={form.errors().dropoffDate}
timeError={form.errors().dropoffTime}
minDate={form.pickupDate() || new Date().toISOString().split('T')[0]}
/>
<PersonalDetails
accentColor={accentColor()}
content={props.content}
passengersId={form.ids.passengers}
nameId={form.ids.name}
phoneId={form.ids.phone}
infoId={form.ids.additionalInfo}
passengersValue={form.passengers()}
nameValue={form.name()}
phoneValue={form.phone()}
infoValue={form.additionalInfo()}
onPassengersChange={form.setPassengers}
onNameChange={form.setName}
onPhoneChange={form.setPhone}
onInfoChange={form.setAdditionalInfo}
passengersError={form.errors().passengers}
nameError={form.errors().name}
phoneError={form.errors().phone}
/>
<Show when={props.showDiscount}>
<div class={`relative flex items-center gap-3 p-3 border rounded-md ${colorMap.discountBg[accentColor()]} mt-4 mb-10 transition-colors`}>
<input
id={form.ids.applyDiscount}
name="apply-discount"
type="checkbox"
checked={form.applyDiscount()}
onChange={(e) => form.setApplyDiscount(e.currentTarget.checked)}
class={`h-5 w-5 rounded border-gray-300 cursor-pointer ${colorMap.discountCheckbox[accentColor()]}`}
/>
<label for={form.ids.applyDiscount} class="font-medium text-gray-800 select-none text-sm cursor-pointer">
{props.content.applyDiscountLabel}
</label>
</div>
</Show>
<div class="sticky bottom-0 -mx-6 -mb-8 sm:-mx-8 bg-white/90 backdrop-blur-md p-4 border-t border-gray-200 rounded-b-xl z-20">
<Button
type="submit"
isLoading={form.loading()}
fullWidth
variant={accentColor() === 'red' ? 'danger' : 'primary'}
size="lg"
>
{props.car ? 'Dieses Auto buchen' : props.content.checkAvailability}
</Button>
</div>
</form>
</Show>
</div>
);
};
export default UniversalBookingForm;

View file

@ -0,0 +1,28 @@
// Общие стили и константы для компонентов бронирования
export const colorMap = {
focus: {
amber: 'focus-within:border-amber-500 focus-within:ring-1 focus-within:ring-amber-500',
red: 'focus-within:border-red-500 focus-within:ring-1 focus-within:ring-red-500',
blue: 'focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500'
},
discountBg: {
amber: 'bg-amber-50 border-amber-200',
red: 'bg-red-50 border-red-200',
blue: 'bg-blue-50 border-blue-200'
},
discountCheckbox: {
amber: 'text-amber-600 focus:ring-amber-500',
red: 'text-red-600 focus:ring-red-500',
blue: 'text-blue-600 focus:ring-blue-500'
},
successBg: {
amber: 'bg-green-50',
red: 'bg-green-50',
blue: 'bg-green-50'
},
successBorder: {
amber: 'border-green-200',
red: 'border-green-200',
blue: 'border-green-200'
},
};

View file

@ -0,0 +1,193 @@
import { createSignal, createUniqueId } from 'solid-js';
import type { HeroContent } from '../home/hero/HeroContent';
import type { Car } from '../home/slider/SliderContent';
export const sanitizeInput = (str: string) => str.replace(/[<>]/g, '').trim();
type FormType = 'main' | 'slider' | 'action';
interface UseBookingFormProps {
content: HeroContent;
// car убираем из обязательных пропсов инициализации, будем задавать его реактивно
car?: Car;
showDiscount?: boolean;
formType?: FormType;
}
export function createBookingForm(props: UseBookingFormProps) {
// 1. Создаем сигнал для машины, инициализируем тем, что пришло сразу (или undefined)
const [selectedCar, setSelectedCar] = createSignal<Car | undefined>(props.car);
const [pickup, setPickup] = createSignal('');
const [pickupDate, setPickupDate] = createSignal('');
const [pickupTime, setPickupTime] = createSignal('');
const [dropoff, setDropoff] = createSignal('');
const [dropoffDate, setDropoffDate] = createSignal('');
const [dropoffTime, setDropoffTime] = createSignal('');
const [passengers, setPassengers] = createSignal('');
const [phone, setPhone] = createSignal('');
const [name, setName] = createSignal('');
const [additionalInfo, setAdditionalInfo] = createSignal('');
const [applyDiscount, setApplyDiscount] = createSignal(props.showDiscount || false);
const [honeypot, setHoneypot] = createSignal('');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [loading, setLoading] = createSignal(false);
const [successMessage, setSuccessMessage] = createSignal<string | null>(null);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
const ids = {
pickup: createUniqueId(),
pickupDate: createUniqueId(),
pickupTime: createUniqueId(),
dropoff: createUniqueId(),
dropoffDate: createUniqueId(),
dropoffTime: createUniqueId(),
passengers: createUniqueId(),
name: createUniqueId(),
phone: createUniqueId(),
additionalInfo: createUniqueId(),
applyDiscount: createUniqueId(),
};
const resetForm = () => {
setPickup(''); setPickupDate(''); setPickupTime('');
setDropoff(''); setDropoffDate(''); setDropoffTime('');
setPassengers(''); setPhone(''); setName(''); setAdditionalInfo('');
setApplyDiscount(props.showDiscount || false);
setErrors({});
// Машину не сбрасываем, так как она выбрана пользователем
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (!pickup().trim()) newErrors.pickup = props.content.fieldRequired;
else if (sanitizeInput(pickup()).length < 3) newErrors.pickup = "Adresse ist zu kurz";
const pDate = pickupDate() ? new Date(pickupDate()) : null;
if (!pickupDate()) newErrors.pickupDate = props.content.fieldRequired;
else if (pDate && pDate < todayStart) newErrors.pickupDate = props.content.dateMustBeFuture;
if (!pickupTime()) newErrors.pickupTime = props.content.fieldRequired;
if (!dropoff().trim()) newErrors.dropoff = props.content.fieldRequired;
else if (sanitizeInput(dropoff()).length < 3) newErrors.dropoff = "Adresse ist zu kurz";
const dDate = dropoffDate() ? new Date(dropoffDate()) : null;
if (!dropoffDate()) newErrors.dropoffDate = props.content.fieldRequired;
if (!dropoffTime()) newErrors.dropoffTime = props.content.fieldRequired;
if (!passengers()) newErrors.passengers = props.content.fieldRequired;
if (!name().trim()) newErrors.name = props.content.invalidName;
const phoneDigits = phone().replace(/\D/g, '');
if (phoneDigits.length < 11) newErrors.phone = props.content.invalidPhone || "Nummer ist zu kurz";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
const firstErrorId = Object.keys(newErrors)[0];
const elementId = ids[firstErrorId as keyof typeof ids];
document.getElementById(elementId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById(elementId)?.focus();
}
return Object.keys(newErrors).length === 0;
};
const closeSuccess = () => {
setSuccessMessage(null);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('booking-close-modal'));
}
};
const submitForm = async (e: Event) => {
e.preventDefault();
if (honeypot() || !validate()) return;
setLoading(true);
// 2. Получаем текущее значение машины из сигнала
const currentCar = selectedCar();
try {
const requestBody = {
// 3. Используем currentCar вместо props.car
...(currentCar && {
carDetails: {
make: currentCar.make,
model: currentCar.model,
year: currentCar.year,
price: currentCar.price,
}
}),
discount: (props.showDiscount && applyDiscount()) ? '20% Aktion' : undefined,
formType: props.formType || 'main',
pickup: sanitizeInput(pickup()),
pickupDate: pickupDate(),
pickupTime: pickupTime(),
dropoff: sanitizeInput(dropoff()),
dropoffDate: dropoffDate(),
dropoffTime: dropoffTime(),
passengers: passengers(),
phone: phone(),
name: sanitizeInput(name()),
additionalInfo: sanitizeInput(additionalInfo()),
};
const response = await fetch('/api/send-booking', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (response.ok) {
const msg = props.content.bookingSubmitted || "Buchung erfolgreich!";
setSuccessMessage(msg);
setErrorMessage(null);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('booking-success', { detail: { message: msg } }));
}
resetForm();
setTimeout(() => {
closeSuccess();
}, 5000);
} else {
const errorData = await response.json();
const errorMsg = errorData.error || props.content.somethingWentWrong;
setErrorMessage(errorMsg);
setSuccessMessage(null);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('booking-error', { detail: { message: errorMsg } }));
}
}
} catch (err) {
console.error(err);
setErrorMessage(props.content.somethingWentWrong);
} finally {
setLoading(false);
}
};
return {
pickup, setPickup, pickupDate, setPickupDate, pickupTime, setPickupTime,
dropoff, setDropoff, dropoffDate, setDropoffDate, dropoffTime, setDropoffTime,
passengers, setPassengers, phone, setPhone, name, setName,
additionalInfo, setAdditionalInfo, applyDiscount, setApplyDiscount,
honeypot, setHoneypot,
errors, loading, ids,
successMessage, errorMessage,
submitForm,
closeSuccess,
setSelectedCar
};
}

View file

@ -0,0 +1,141 @@
---
import { authService } from '@/lib/authService';
import Button from '@components/base/Button.astro';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных из PocketBase
const appProcessData = await pb.collection('application_process').getFirstListItem('');
---
<!-- Application Process Component -->
<section class="py-20 bg-gradient-to-r from-slate-800 to-slate-900 text-white">
<div class="max-w-4xl mx-auto px-6 text-center">
<div>
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500 text-slate-900 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h2 class="text-3xl md:text-4xl font-bold mb-6">
{appProcessData.title}
</h2>
<div class="w-24 h-1 bg-amber-500 mx-auto mb-8"></div>
<p class="mt-6 text-lg text-slate-300 max-w-2xl mx-auto mb-10">
{appProcessData.subtitle}
</p>
<a
href={`mailto:${appProcessData.email}?subject=Bewerbung als Chauffeur&body=Sehr geehrtes Team,%0D%0A%0D%0Ahiermit möchte ich mich als Chauffeur bei Ihnen bewerben.%0D%0A%0D%0AMit freundlichen Grüßen`}
class="inline-flex items-center gap-3 bg-amber-500 text-slate-900 font-bold py-4 px-10 rounded-lg text-xl transition-all duration-300 shadow-lg hover:shadow-amber-500/40 hover:bg-amber-400 hover:scale-105 active:scale-95"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{appProcessData.email}
</a>
<div class="mt-12 text-slate-400">
<p>
{appProcessData.confirmation_text}
</p>
</div>
</div>
</div>
</section>
<!-- Application Modal -->
<div id="applicationModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 hidden">
<div class="relative w-full max-w-2xl bg-white rounded-xl shadow-2xl m-4 max-h-[90vh] flex flex-col">
<header class="flex items-center justify-between p-5 border-b sticky top-0 bg-white rounded-t-xl z-10">
<h2 class="text-xl font-bold text-gray-800">Bewerbung als Chauffeur</h2>
<button
onclick="closeApplicationModal()"
class="p-2 hover:bg-gray-100 rounded-full transition-colors"
aria-label="Schließen"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<main class="p-6 overflow-y-auto">
<!-- Simple Application Form -->
<form class="space-y-6 text-gray-900" onsubmit="handleApplicationSubmit(event)">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ihr Name</label>
<input type="text" placeholder="Max Mustermann" class="w-full border border-gray-300 rounded-md p-2.5 focus:ring-2 focus:ring-amber-500 focus:border-amber-500" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" placeholder="you@example.com" class="w-full border border-gray-300 rounded-md p-2.5 focus:ring-2 focus:ring-amber-500 focus:border-amber-500" required />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input type="tel" placeholder="+49 123 4567890" class="w-full border border-gray-300 rounded-md p-2.5 focus:ring-2 focus:ring-amber-500 focus:border-amber-500" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Anschreiben (optional)</label>
<textarea rows="4" placeholder="Erzählen Sie uns etwas über sich..." class="w-full border border-gray-300 rounded-md p-2.5 focus:ring-2 focus:ring-amber-500 focus:border-amber-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Lebenslauf (PDF, DOC, DOCX, bis zu 5MB)</label>
<input type="file" accept=".pdf,.doc,.docx" class="w-full border border-gray-300 rounded-md p-2.5 focus:ring-2 focus:ring-amber-500 focus:border-amber-500" required />
</div>
<footer class="flex justify-end items-center gap-4 pt-4">
<button type="button" onclick="closeApplicationModal()" class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
Abbrechen
</button>
<Button type="submit" variant="primary">
Bewerbung senden
</Button>
</footer>
</form>
</main>
</div>
</div>
<script>
function openApplicationModal() {
document.getElementById('applicationModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeApplicationModal() {
document.getElementById('applicationModal').classList.add('hidden');
document.body.style.overflow = 'auto';
}
function handleApplicationSubmit(event) {
event.preventDefault();
// Здесь можно добавить логику отправки формы
alert('Bewerbung erfolgreich gesendet!');
closeApplicationModal();
}
// Закрытие модального окна по клику вне его
document.addEventListener('click', function(event) {
const modal = document.getElementById('applicationModal');
if (event.target === modal) {
closeApplicationModal();
}
});
// Закрытие модального окна по ESC
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeApplicationModal();
}
});
</script>

View file

@ -0,0 +1,58 @@
---
import { authService } from '@/lib/authService';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных из PocketBase
const requirementsData = await pb.collection('candidate_requirements').getFirstListItem('');
const requirementItems = await pb.collection('candidate_requirement_items').getList(1, 50, {
filter: `parent_requirement = "${requirementsData.id}"`,
sort: 'order'
});
---
<!-- Candidate Requirements Component -->
<section class="py-20 bg-gradient-to-br from-slate-50 to-white">
<div class="max-w-4xl mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
{requirementsData.title}
</h2>
<div class="w-24 h-1 bg-amber-500 mx-auto mb-6"></div>
<p class="text-gray-600 max-w-2xl mx-auto">
{requirementsData.subtitle}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{requirementItems.items.map((req) => (
<div class="flex items-start p-6 bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow duration-300 group">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center group-hover:bg-amber-500 transition-colors duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-600 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-xl font-semibold text-gray-800 mb-2">{req.requirement_title}</h3>
<p class="text-gray-600">{req.requirement_description}</p>
</div>
</div>
))}
</div>
<div class="mt-16 bg-gradient-to-r from-amber-50 to-slate-50 rounded-2xl p-8 border border-amber-100">
<div class="max-w-3xl mx-auto text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-100 text-amber-600 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-800 mb-4">Unser Versprechen</h3>
<p class="text-gray-700 text-lg italic">
&ldquo;{requirementsData.quote}&rdquo;
</p>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,49 @@
---
import { authService } from '@/lib/authService';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных из PocketBase
const valuesData = await pb.collection('company_values').getFirstListItem('');
const valueItems = await pb.collection('company_value_items').getList(1, 50, {
filter: `parent_value = "${valuesData.id}"`,
sort: 'order'
});
---
<!-- Company Values Component -->
<section class="py-20 bg-gradient-to-br from-slate-50 to-white">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
{valuesData.title}
</h2>
<div class="w-24 h-1 bg-amber-500 mx-auto mb-6"></div>
<p class="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
{valuesData.subtitle}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{valueItems.items.map((value) => (
<div class="bg-white rounded-xl shadow-lg p-8 border border-slate-100 hover:shadow-xl transition-all duration-300 flex flex-col items-center text-center group h-full hover:-translate-y-2">
<div class="flex items-center justify-center h-20 w-20 rounded-full bg-amber-100 text-amber-600 mb-6 group-hover:bg-amber-500 group-hover:text-white transition-all duration-300 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={value.icon} />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-3">{value.value_title}</h3>
<p class="text-gray-600 flex-grow">{value.value_description}</p>
<div class="mt-4 w-12 h-1 bg-amber-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 group-hover:w-10"></div>
</div>
))}
</div>
<div class="mt-16 text-center">
<p class="text-gray-600 italic max-w-2xl mx-auto">
&ldquo;{valuesData.quote}&rdquo;
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,59 @@
---
import { authService } from '@/lib/authService';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных из PocketBase
const heroData = await pb.collection('career_hero').getFirstListItem('');
// Генерация прямой ссылки на изображение с помощью метода PocketBase
const backgroundImageUrl = pb.files.getURL(heroData, heroData.background_image);
---
<!-- Career Hero Component -->
<section class="relative h-[70vh] min-h-[600px] w-full flex items-center justify-center text-center text-white overflow-hidden">
<!-- Background Image -->
<div
class="absolute inset-0 bg-cover bg-center brightness-50"
style={`background-image: url('${backgroundImageUrl}')`}
></div>
<div class="absolute inset-0 bg-gradient-to-b from-black/70 to-black/40"></div>
<!-- Content -->
<div class="relative z-10 p-6 max-w-5xl mx-auto">
<h1 class="text-4xl md:text-6xl font-extrabold tracking-tight mb-6">
<span class="block">{heroData.title}</span>
</h1>
<p class="mt-6 text-lg md:text-xl max-w-3xl mx-auto text-slate-200">
{heroData.subtitle}
</p>
<div class="mt-10">
<a
href="#offene-stellen"
class="inline-block bg-amber-500 text-slate-900 font-bold py-3 px-8 rounded-lg text-lg transition-all duration-300 shadow-lg hover:shadow-amber-500/40 hover:bg-amber-400 hover:scale-105 active:scale-95"
>
{heroData.btn_text}
</a>
</div>
</div>
<!-- Scroll Indicator -->
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<div class="w-8 h-12 rounded-full border-2 border-white flex justify-center p-1">
<div class="w-2 h-2 bg-white rounded-full"></div>
</div>
</div>
</section>
<style>
.animate-bounce {
animation: bounce 1.5s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
</style>

View file

@ -0,0 +1,51 @@
---
import { authService } from '@/lib/authService';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных из PocketBase
const positionData = await pb.collection('open_positions').getFirstListItem('');
---
<!-- Open Positions Component -->
<section id="offene-stellen" class="py-20 bg-gradient-to-br from-slate-50 to-slate-100 scroll-mt-28">
<div class="max-w-4xl mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
{positionData.section_title}
</h2>
<div class="w-24 h-1 bg-amber-500 mx-auto mb-6"></div>
</div>
<div class="space-y-8">
<div class="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200 hover:shadow-xl transition-all duration-300 hover:-translate-y-2">
<div class="p-8">
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
<div>
<h3 class="text-2xl font-bold text-amber-600">
{positionData.position_title}
</h3>
<p class="text-gray-500 mt-2">{positionData.employment_type}</p>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
{positionData.status || 'Aktuell'}
</span>
</div>
<p class="mt-6 text-gray-700">
{positionData.description}
</p>
<div class="mt-8">
<a
href="#bewerbung"
class="inline-block bg-amber-500 text-slate-900 font-bold py-3 px-6 rounded-lg transition-all duration-300 hover:bg-amber-400 hover:scale-105 active:scale-95"
onclick="openApplicationModal()"
>
{positionData.btn_text}
</a>
</div>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,374 @@
---
import Button from '@components/base/Button.astro';
interface Props {
isAuthenticated: boolean;
}
const { isAuthenticated } = Astro.props;
const MAX_MESSAGE_LENGTH = 500;
---
<div class="contact-form bg-white p-8 sm:p-10 rounded-2xl shadow-xl border border-gray-100 w-full opacity-0 animate-fadeInUp">
{!isAuthenticated ? (
<div class="text-center py-6">
<div class="bg-gray-100 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<p class="text-gray-600 text-sm mb-4">Für das Kontaktformular müssen Sie sich anmelden.</p>
<Button href="/auth/login?callbackUrl=/kontakt" variant="blue" size="md" fullWidth={true}>
Anmelden
</Button>
</div>
) : (
<form id="contactForm" class="space-y-6" novalidate>
<!-- 1. Имя -->
<div class="form-item opacity-0 animate-fadeInUp delay-100">
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Ihr Name
</label>
<input
type="text"
id="name"
name="name"
placeholder="Max Mustermann"
autocomplete="name"
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition"
/>
<p class="error-message mt-1 text-xs text-red-600 hidden items-center gap-1">
<span class="icon">⚠️</span> <span class="error-text"></span>
</p>
</div>
<!-- 2. Email -->
<div class="form-item opacity-0 animate-fadeInUp delay-200">
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail
</label>
<input
type="email"
id="email"
name="email"
placeholder="ihre@email.de"
autocomplete="email"
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition"
/>
<p class="error-message mt-1 text-xs text-red-600 hidden items-center gap-1">
<span class="icon">⚠️</span> <span class="error-text"></span>
</p>
</div>
<!--
3. HONEYPOT (Ловушка для ботов)
Мы используем имя 'website' или 'confirm_email', так как боты любят их заполнять.
Скрываем через CSS, чтобы пользователь не видел.
-->
<div class="absolute opacity-0 -z-50 w-0 h-0 overflow-hidden" aria-hidden="true">
<label for="website_honeypot">Bitte lassen Sie dieses Feld leer</label>
<input
type="text"
id="website_honeypot"
name="website_honeypot"
tabindex="-1"
autocomplete="off"
/>
</div>
<!-- 4. Сообщение -->
<div class="form-item opacity-0 animate-fadeInUp delay-300">
<label for="message" class="block text-sm font-medium text-gray-700 mb-2">
Nachricht
</label>
<textarea
id="message"
name="message"
rows="5"
maxlength={MAX_MESSAGE_LENGTH}
placeholder="Wie können wir Ihnen helfen?"
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition resize-none"
></textarea>
<div class="mt-1 flex justify-between items-center">
<p class="error-message text-xs text-red-600 hidden items-center gap-1">
<span class="icon">⚠️</span> <span class="error-text"></span>
</p>
<p class="character-count text-xs text-gray-500 ml-auto">
<span id="charCount">0</span> / {MAX_MESSAGE_LENGTH}
</p>
</div>
</div>
<!-- Кнопка -->
<div class="form-item opacity-0 animate-fadeInUp delay-400">
<Button
type="submit"
variant="primary"
fullWidth
class="submit-btn"
>
<span class="btn-text">Nachricht senden</span>
<!-- Спиннер загрузки -->
<span class="loading-spinner hidden ml-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
</Button>
</div>
<!-- Блок статуса -->
<div id="formStatus" class="hidden p-4 rounded-lg text-center text-sm font-medium transition-all duration-300"></div>
</form>
)}
</div>
<style>
/* Анимации определены локально */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.5s ease-out forwards;
}
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-400 { animation-delay: 0.4s; }
.error-message.show {
display: flex;
}
/* Подсветка ошибок */
:global(input.error), :global(textarea.error) {
border-color: #ef4444 !important;
background-color: #fef2f2 !important;
}
.status-message-success {
background-color: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.status-message-error {
background-color: #fee2e2;
color: #dc2626;
border: 1px solid #fecaca;
}
/* Состояния кнопки */
:global(.submit-btn.loading) {
opacity: 0.7;
cursor: not-allowed;
}
.character-count.warning {
color: #f59e0b; /* Amber */
}
.character-count.limit {
color: #ef4444; /* Red */
}
</style>
<script define:vars={{ MAX_MESSAGE_LENGTH, isAuthenticated }}>
document.addEventListener('DOMContentLoaded', () => {
// Если пользователь не аутентифицирован, не инициализируем форму
if (!isAuthenticated) return;
const form = document.getElementById('contactForm');
if (!form) return;
const inputs = {
name: document.getElementById('name'),
email: document.getElementById('email'),
message: document.getElementById('message'),
honeypot: document.getElementById('website_honeypot')
};
const charCountEl = document.getElementById('charCount');
const submitBtn = form.querySelector('.submit-btn');
const btnText = submitBtn?.querySelector('.btn-text');
const spinner = submitBtn?.querySelector('.loading-spinner');
const statusDiv = document.getElementById('formStatus');
// --- 1. ОГРАНИЧЕНИЕ ВВОДА СИМВОЛОВ (Input Restriction) ---
// Для имени: запрещаем цифры и спецсимволы, кроме дефиса и пробела
inputs.name?.addEventListener('keydown', (e) => {
// Разрешаем управляющие клавиши (Backspace, Tab, стрелки, Ctrl+C/V)
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) {
return;
}
// Regex: Разрешаем буквы (латиница + немецкие), пробелы, дефис
const allowedPattern = /^[a-zA-ZäöüÄÖÜß\s-]$/;
if (!allowedPattern.test(e.key)) {
e.preventDefault(); // Блокируем ввод
// Опционально: можно мигнуть полем красным, чтобы показать запрет
inputs.name.classList.add('error');
setTimeout(() => inputs.name.classList.remove('error'), 200);
}
});
// Для сообщения: счетчик символов
inputs.message?.addEventListener('input', function() {
const count = this.value.length;
charCountEl.textContent = count;
const parent = charCountEl.parentElement;
parent.classList.remove('warning', 'limit');
if (count >= MAX_MESSAGE_LENGTH) {
parent.classList.add('limit');
} else if (count >= MAX_MESSAGE_LENGTH * 0.9) {
parent.classList.add('warning');
}
});
// --- 2. ВАЛИДАЦИЯ ---
const validateForm = () => {
let isValid = true;
clearErrors();
// Имя
const nameVal = inputs.name.value.trim();
if (!nameVal) {
showError('name', 'Bitte geben Sie Ihren Namen ein.');
isValid = false;
} else if (nameVal.length < 2) {
showError('name', 'Der Name ist zu kurz.');
isValid = false;
}
// Email
const emailVal = inputs.email.value.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailVal) {
showError('email', 'Bitte geben Sie Ihre E-Mail ein.');
isValid = false;
} else if (!emailRegex.test(emailVal)) {
showError('email', 'Ungültiges E-Mail-Format.');
isValid = false;
}
// Сообщение
const msgVal = inputs.message.value.trim();
if (!msgVal) {
showError('message', 'Bitte schreiben Sie eine Nachricht.');
isValid = false;
} else if (msgVal.length < 10) {
showError('message', 'Ihre Nachricht ist zu kurz (min. 10 Zeichen).');
isValid = false;
}
// XSS Check (базовая защита)
if (/<script|onload|onclick/i.test(msgVal) || /<script|onload|onclick/i.test(nameVal)) {
showStatus('Sicherheitswarnung: Ungültige Zeichen erkannt.', 'error');
isValid = false;
}
return isValid;
};
const showError = (fieldId, msg) => {
const field = inputs[fieldId];
const errorP = field.parentElement.querySelector('.error-message');
const errorSpan = errorP.querySelector('.error-text');
field.classList.add('error');
errorSpan.textContent = msg;
errorP.classList.add('show');
};
const clearErrors = () => {
document.querySelectorAll('.error-message').forEach(el => el.classList.remove('show'));
document.querySelectorAll('.error').forEach(el => el.classList.remove('error'));
};
// --- 3. ОТПРАВКА ---
form.addEventListener('submit', async (e) => {
e.preventDefault();
// A. Honeypot Check (Ловушка)
if (inputs.honeypot.value !== '') {
console.warn('Bot detected via honeypot.');
// Имитируем успех, чтобы бот не пытался снова
showStatus('Nachricht gesendet!', 'success');
form.reset();
return;
}
// B. Валидация
if (!validateForm()) return;
// C. UI Loading
setLoading(true);
try {
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// Удаляем honeypot из отправляемых данных
delete data.website_honeypot;
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
showStatus('Vielen Dank! Ihre Nachricht wurde gesendet.', 'success');
form.reset();
charCountEl.textContent = '0';
} else {
showStatus(result.error || 'Fehler beim Senden.', 'error');
}
} catch (error) {
console.error(error);
showStatus('Verbindungsfehler. Bitte versuchen Sie es später.', 'error');
} finally {
setLoading(false);
// Скрываем сообщение через 5 сек
setTimeout(() => {
statusDiv.classList.add('hidden');
}, 5000);
}
});
function setLoading(isLoading) {
if (isLoading) {
submitBtn.classList.add('loading');
submitBtn.disabled = true;
btnText.classList.add('hidden');
spinner.classList.remove('hidden');
statusDiv.classList.add('hidden');
} else {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
btnText.classList.remove('hidden');
spinner.classList.add('hidden');
}
}
function showStatus(msg, type) {
statusDiv.textContent = msg;
statusDiv.className = `p-4 rounded-lg text-center font-medium status-message-${type}`;
statusDiv.classList.remove('hidden');
}
});
</script>

View file

@ -0,0 +1,66 @@
---
interface Props {
page: unknown;
}
const { page } = Astro.props;
const contactBlock = page?.layout?.find(
(block: unknown) => block && typeof block === 'object' && block.blockType === "contact"
);
const title =
(contactBlock?.blockType === "contact" && contactBlock.title) ??
"Kontaktieren Sie uns";
const subtitle =
(contactBlock?.blockType === "contact" && contactBlock.subtitle) ??
"Haben Sie Fragen oder möchten Sie eine Fahrt buchen? Wir sind hier, um zu helfen. Füllen Sie das Formular aus oder nutzen Sie die untenstehenden Kontaktinformationen.";
---
<div class="text-center opacity-0 animate-staggerFadeIn">
<div class="text-4xl md:text-5xl font-bold text-gray-900 mb-4 tracking-tight opacity-0 animate-fadeInUp delay-100">
{title}
</div>
<p class="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto opacity-0 animate-fadeInUp delay-300">
{subtitle}
</p>
</div>
<style is:global>
@keyframes staggerFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-staggerFadeIn {
animation: staggerFadeIn 0.6s ease-out forwards;
}
.animate-fadeInUp {
animation: fadeInUp 0.6s ease-out forwards;
}
.delay-100 {
animation-delay: 0.1s;
}
.delay-300 {
animation-delay: 0.3s;
}
</style>

Some files were not shown because too many files have changed in this diff Show more