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

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>

View file

@ -0,0 +1,94 @@
---
interface Props {
siteSettings: unknown;
}
const { siteSettings } = Astro.props;
const contactDetails = [
{
icon: "📞",
title: 'Telefon',
value: siteSettings?.contact?.phoneNumber || siteSettings?.contact?.phone,
href: siteSettings?.contact?.phoneHref || `tel:${siteSettings?.contact?.phoneNumber || siteSettings?.contact?.phone || ''}`,
},
{
icon: "✉️",
title: 'E-Mail',
value: siteSettings?.contact?.emailAddress || siteSettings?.contact?.email,
href: siteSettings?.contact?.emailHref || `mailto:${siteSettings?.contact?.emailAddress || siteSettings?.contact?.email || ''}`,
},
{
icon: "📍",
title: 'Standort',
value: siteSettings?.contact?.address || 'Berlin, Deutschland',
href: siteSettings?.contact?.mapUrl || '#',
},
{
icon: "🕒",
title: 'Öffnungszeiten',
value: siteSettings?.contact?.workingHours || '24/7 erreichbar',
href: '#',
},
];
---
<div class="space-y-6 opacity-0 animate-staggerFadeIn">
{contactDetails.map((detail, index) => {
const Wrapper = detail.href === '#' ? 'div' : 'a';
return (
<Wrapper
href={detail.href === '#' ? undefined : detail.href}
class="contact-item flex items-center p-4 rounded-xl group opacity-0 animate-fadeInUp"
style={`animation-delay: ${0.2 + (index * 0.15)}s`}
>
<div class="flex-shrink-0 flex items-center justify-center h-14 w-14 rounded-full bg-amber-100 text-amber-600 mr-5 transition-colors duration-300 group-hover:bg-amber-500 group-hover:text-white text-xl">
{detail.icon}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">
{detail.title}
</h2>
<p class="text-md text-gray-600 transition-colors duration-300 group-hover:text-amber-600">
{detail.value}
</p>
</div>
</Wrapper>
);
})}
</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.5s ease-out forwards;
}
.contact-item:hover {
transform: scale(1.02);
transition: transform 0.2s ease;
}
</style>

View file

@ -0,0 +1,60 @@
---
interface Props {
siteSettings: {
address: string;
[key: string]: any;
};
}
const { siteSettings } = Astro.props;
// Формируем URL для Google Maps, используя поле address из вашей коллекции
const encodedAddress = encodeURIComponent(siteSettings.address);
const mapUrl = `https://maps.google.com/maps?q=${encodedAddress}&t=&z=14&ie=UTF8&iwloc=&output=embed`;
---
<div class="contact-map-wrapper opacity-0 animate-fadeInUp">
<div class="relative w-full h-80 md:h-[450px] rounded-2xl overflow-hidden shadow-2xl border-4 border-white">
<!-- Интерактивная карта Google -->
<iframe
class="absolute inset-0 w-full h-full border-0"
title="Google Map Location"
src={mapUrl}
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
></iframe>
<!-- Невидимый слой поверх для плавности скролла (опционально) -->
<div class="pointer-events-none absolute inset-0 ring-1 ring-inset ring-black/10 rounded-2xl"></div>
</div>
<!-- Подпись под картой (опционально) -->
<p class="mt-4 text-center text-gray-500 font-light italic">
📍 {siteSettings.address}
</p>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (max-width: 768px) {
iframe {
pointer-events: auto;
}
}
</style>

View file

@ -0,0 +1,62 @@
---
import ContactForm from './ContactForm.astro';
import ContactHeader from './ContactHeader.astro';
import ContactInfo from './ContactInfo.astro';
import ContactMap from './ContactMap.astro';
interface SiteSettings {
address: string;
contact_email?: string;
contact_phone?: string;
[key: string]: any;
}
interface Props {
siteSettings: SiteSettings;
page: any;
}
const { siteSettings, page } = Astro.props;
const locals = Astro.locals as any;
const isAuthenticated = locals.user ? true : false;
---
<section class="relative bg-gray-50 py-12 sm:py-16 overflow-hidden">
<!-- Декоративные фоновые элементы -->
<div class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2">
<div class="w-[40rem] h-[40rem] bg-amber-400/10 rounded-full blur-3xl" />
</div>
<div class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2">
<div class="w-[40rem] h-[40rem] bg-amber-400/15 rounded-full blur-3xl" />
</div>
<div class="max-w-7xl mx-auto px-6 lg:px-8 relative z-10">
<ContactHeader page={page} />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-16 gap-y-12 mt-16 opacity-0 animate-fadeInUp delay-200">
<div class="flex flex-col justify-center">
<ContactInfo siteSettings={siteSettings} />
</div>
<div>
<ContactForm isAuthenticated={isAuthenticated} />
</div>
</div>
<div class="mt-20 lg:mt-24 opacity-0 animate-fadeInUp delay-400">
<ContactMap siteSettings={siteSettings} />
</div>
</div>
</section>
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.7s ease-out forwards; }
.delay-200 { animation-delay: 0.2s; }
.delay-400 { animation-delay: 0.4s; }
</style>

View file

@ -0,0 +1,58 @@
import { createSignal, Show } from 'solid-js';
import { FaSolidChevronDown } from 'solid-icons/fa';
import type { FaqItem, FaqProps } from '@/types/globalInterfaces';
const Faq = (props: FaqProps) => {
const [openIndex, setOpenIndex] = createSignal<number | null>(null);
const toggleAccordion = (index: number) => {
setOpenIndex(openIndex() === index ? null : index);
};
return (
<section class="bg-white py-20 md:py-28">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900">
Häufig gestellte Fragen
</h2>
<p class="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
Wir haben die Antworten auf die häufigsten Fragen zu unserem Service
hier für Sie zusammengestellt.
</p>
</div>
<div class="space-y-3">
{props.faqItems.map((item, index) => (
<div class="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
class="w-full flex justify-between items-center p-4 bg-gray-50 hover:bg-gray-100 text-left cursor-pointer"
onClick={() => toggleAccordion(index)}
>
<span class="font-medium text-gray-900 pr-4">{item.question}</span>
<FaSolidChevronDown
class={`flex-shrink-0 transform transition-transform duration-300 ${
openIndex() === index ? 'rotate-180 text-blue-600' : 'text-blue-600'
}`}
/>
</button>
<div
class={`transition-all duration-300 ease-in-out overflow-hidden ${
openIndex() === index ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-600">{item.answer}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default Faq;

View file

@ -0,0 +1,30 @@
import type { FaqContactCtaContent } from './faqContent';
import { Button } from '../base/Buttons.tsx';
interface FaqContactCtaProps {
content: FaqContactCtaContent;
}
const FaqContactCta = ({ content }: FaqContactCtaProps) => {
return (
<section class="py-20 bg-gradient-to-r from-gray-900 to-gray-800 text-white">
<div class="max-w-4xl mx-auto px-6 text-center">
<div>
<h2 class="text-3xl md:text-4xl font-bold mb-4">
{content.title}
</h2>
<p class="mt-4 text-lg text-gray-300 max-w-2xl mx-auto">
{content.description}
</p>
<div class="mt-10">
<Button href="/kontakt" size="lg">
{content.buttonText}
</Button>
</div>
</div>
</div>
</section>
);
};
export default FaqContactCta;

View file

@ -0,0 +1,31 @@
// FaqHero.tsx
import type { FaqHeroContent } from './faqContent';
interface FaqHeroProps {
content: FaqHeroContent;
}
const FaqHero = ({ content }: FaqHeroProps) => {
return (
<section class="relative h-[50vh] min-h-[400px] w-full flex items-center justify-center overflow-hidden">
<div class="absolute inset-0">
<img
src="/images/faq/faq_hero.avif"
alt="Hilfsbereiter Chauffeur Service"
class="absolute inset-0 w-full h-full object-cover object-top brightness-50"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div class="relative z-10 text-center p-6">
<h1 class="text-4xl md:text-6xl font-extrabold tracking-tight text-white drop-shadow-lg">
{content.title}
</h1>
<p class="mt-4 text-lg md:text-xl text-gray-200 max-w-2xl mx-auto">
{content.subtitle}
</p>
</div>
</section>
);
};
export default FaqHero;

View file

@ -0,0 +1,58 @@
import { createSignal, Show } from 'solid-js';
import { FaSolidChevronDown } from 'solid-icons/fa';
import type { FaqItem, FaqHomepageProps } from '@/types/globalInterfaces';
const FaqHomepage = (props: FaqHomepageProps) => {
const [openIndex, setOpenIndex] = createSignal<number | null>(null);
const toggleAccordion = (index: number) => {
setOpenIndex(openIndex() === index ? null : index);
};
return (
<section class="bg-white py-20 md:py-28">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900">
Häufig gestellte Fragen
</h2>
<p class="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
Wir haben die Antworten auf die häufigsten Fragen zu unserem Service
hier für Sie zusammengestellt.
</p>
</div>
<div class="space-y-3">
{props.faqItems.map((item, index) => (
<div class="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
class="w-full flex justify-between items-center p-4 bg-gray-50 hover:bg-gray-100 text-left cursor-pointer"
onClick={() => toggleAccordion(index)}
>
<span class="font-medium text-gray-900 pr-4">{item.question}</span>
<FaSolidChevronDown
class={`flex-shrink-0 transform transition-transform duration-300 ${
openIndex() === index ? 'rotate-180 text-blue-600' : 'text-blue-600'
}`}
/>
</button>
<div
class={`transition-all duration-300 ease-in-out overflow-hidden ${
openIndex() === index ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-600">{item.answer}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default FaqHomepage;

View file

@ -0,0 +1,126 @@
/**
* @description Тип для одного элемента (вопрос-ответ) в аккордеоне.
*/
export interface FaqItem {
question: string;
answer: string;
showOnHomepage?: boolean;
}
/**
* @description Универсальный тип для компонента <FAQ />.
*/
export interface FaqContent {
mainTitle?: string;
subtitle?: string;
items: readonly FaqItem[];
}
/**
* @description Тип для контента "геройской" секции.
*/
export interface FaqHeroContent {
title: string;
subtitle: string;
}
/**
* @description Тип для контента секции с аккордеоном на странице FAQ.
*/
export interface FaqAccordionSectionContent {
title: string;
faqItems: readonly FaqItem[];
}
/**
* @description Тип для контента секции с призывом к действию.
*/
export interface FaqContactCtaContent {
title: string;
description: string;
buttonText: string;
}
// --- Данные для "геройской" секции ---
export const faqHeroContent: FaqHeroContent = {
title: 'Antworten auf Ihre Fragen',
subtitle:
'Hier finden Sie alles, was Sie über unseren Service wissen müssen.',
};
// --- Данные для секции с аккордеоном ---
// ПРИМЕЧАНИЕ: Только у 6 вопросов флаг 'showOnHomepage' установлен в 'true'.
export const faqAccordionSectionContent: FaqAccordionSectionContent = {
title: 'Häufig gestellte Fragen',
faqItems: [
{
question: 'Wie kann ich einen Transfer buchen?',
answer:
'Am einfachsten buchen Sie über unser Online-Buchungsformular. Geben Sie einfach Abholort, Ziel, Datum, Uhrzeit und die Anzahl der Fahrgäste an. Alternativ können Sie uns auch telefonisch oder per E-Mail kontaktieren. Sie erhalten in jedem Fall eine schriftliche Buchungsbestätigung.',
showOnHomepage: true,
},
{
question: 'Bieten Sie auch Transfers vom/zum Flughafen BER an?',
answer:
'Ja, Flughafentransfers sind unsere Spezialität. Wir überwachen Ihren Flug in Echtzeit und passen die Abholzeit bei Verspätungen automatisch an. Ihr Chauffeur erwartet Sie in der Ankunftshalle mit einem Namensschild, um einen reibungslosen Ablauf zu garantieren.',
showOnHomepage: true,
},
{
question: 'Wie viele Personen und Gepäckstücke passen in einen Minivan?',
answer:
'Unsere Minivans bieten Platz für bis zu 8 Fahrgäste. In der Regel können wir 7-8 mittelgroße Koffer sowie Handgepäck transportieren. Bei besonders viel oder sperrigem Gepäck bitten wir Sie, dies bei der Buchung anzugeben, damit wir das passende Fahrzeug bereitstellen können.',
showOnHomepage: true,
},
{
question: 'Wie wird der Preis für eine Fahrt berechnet?',
answer:
'Wir bieten transparente Festpreise. Der Preis, den Sie bei der Buchung erhalten, ist der Endpreis. Er beinhaltet alle Steuern, Maut- und Parkgebühren sowie eine angemessene Wartezeit. Es gibt keine versteckten Kosten.',
},
{
question: 'Stellen Sie Kindersitze zur Verfügung?',
answer:
'Selbstverständlich. Die Sicherheit unserer jüngsten Fahrgäste hat oberste Priorität. Bitte geben Sie bei der Buchung das Alter und Gewicht Ihrer Kinder an. Wir stellen die passenden und gesetzlich vorgeschriebenen Kindersitze oder Sitzerhöhungen kostenlos zur Verfügung.',
showOnHomepage: true,
},
{
question: 'Was passiert, wenn mein Flug Verspätung hat?',
answer:
'Keine Sorge. Wir verfolgen die Ankunftszeit Ihres Fluges und Ihr Fahrer wird zur tatsächlichen Landezeit vor Ort sein. Bei Abholungen am Flughafen ist eine kostenlose Wartezeit von 60 Minuten nach der Landung inklusive.',
showOnHomepage: true,
},
{
question: 'Welche Zahlungsmethoden werden akzeptiert?',
answer:
'Sie können bequem online bei der Buchung mit Kreditkarte bezahlen. Alternativ ist auch eine Zahlung per Kreditkarte oder in bar direkt beim Fahrer möglich. Für Firmenkunden bieten wir nach Absprache auch die Zahlung auf Rechnung an.',
},
{
question: 'Wie lauten Ihre Stornierungsbedingungen?',
answer:
'Sie können Ihre Buchung bis zu 24 Stunden vor der geplanten Abholzeit kostenfrei stornieren. Bei späteren Stornierungen kann eine Gebühr anfallen. Detaillierte Informationen finden Sie in unseren Allgemeinen Geschäftsbedingungen (AGB).',
showOnHomepage: true,
},
{
question: 'Kann ich auch eine Stadtrundfahrt buchen?',
answer:
'Ja, sehr gerne. Wir bieten individuelle Stadtrundfahrten und Ausflüge, z.B. nach Potsdam, an. Kontaktieren Sie uns einfach mit Ihren Wünschen, und wir erstellen Ihnen ein maßgeschneidertes Angebot für eine unvergessliche Tour.',
},
{
question: 'Kann ich ein bestimmtes Fahrzeugmodell anfordern?',
answer:
'Sie können bei der Buchung gerne einen Wunsch für ein bestimmtes Modell (z.B. Mercedes V-Klasse) äußern. Wir bemühen uns, Ihren Wunsch je nach Verfügbarkeit zu erfüllen. Wir garantieren Ihnen jedoch immer ein Fahrzeug der gebuchten Premium-Kategorie.',
},
],
};
// --- Данные для секции с призывом к действию ---
export const faqContactCtaContent: FaqContactCtaContent = {
title: 'Ihre Frage war nicht dabei?',
description: 'Kein Problem. Unser Team ist persönlich für Sie da...',
buttonText: 'Jetzt Kontakt aufnehmen',
};
// --- Данные для секции FAQ на главной странице ---
export const faqHomepageContent: FaqAccordionSectionContent = {
title: 'Häufig gestellte Fragen',
faqItems: faqAccordionSectionContent.faqItems.filter(item => item.showOnHomepage === true),
};

View file

@ -0,0 +1,55 @@
---
const { value } = Astro.props;
---
<p class="text-3xl font-bold text-amber-400">
<span class="animated-counter" data-value={value}>0</span>
</p>
<script>
function setupAnimatedCounters() {
const counters = document.querySelectorAll('.animated-counter');
if (!counters.length) return;
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCounter(entry.target);
obs.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
counters.forEach(c => observer.observe(c));
}
function animateCounter(counter) {
const rawValue = counter.getAttribute('data-value') || '';
const match = rawValue.match(/^(\d+)/);
if (!match) {
counter.textContent = rawValue;
return;
}
const target = parseInt(match[0], 10);
const suffix = rawValue.slice(match[0].length);
let start = 0;
const duration = 2000;
const step = 20;
const increment = target / (duration / step);
const timer = setInterval(() => {
start += increment;
if (start >= target) {
counter.textContent = target.toLocaleString('de-DE') + suffix;
clearInterval(timer);
} else {
counter.textContent = Math.round(start).toLocaleString('de-DE') + suffix;
}
}, step);
}
document.addEventListener('DOMContentLoaded', setupAnimatedCounters);
</script>

View file

@ -0,0 +1,166 @@
---
import { COLOR_CLASSES } from '@constants/colors';
import LetterIcon from '@icons/letterIcon.svg';
import WhatsAppIcon from '@icons/whatsApp.svg';
import StatsBlock from './StatsBlock.astro';
import Modal from '@components/base/Modal.astro';
import UniversalBookingForm from '../../booking/UniversalBookingForm.tsx';
import Toast from './Toast.astro';
import Button from '@components/base/Button.astro';
import { authService } from '@/lib/authService';
// 1. Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// 2. Параллельный запрос данных (Promise.all)
// Это быстрее, чем ждать сначала одну запись, потом другую.
const [heroRecord, settingsRecord] = await Promise.all([
pb.collection('hero_content').getFirstListItem(''),
pb.collection('site_settings').getFirstListItem('')
]);
// 3. Генерация прямой ссылки на изображение героя
const heroImageUrl = pb.files.getURL(heroRecord, heroRecord.hero_image);
// 4. Подготовка объекта контента для шаблона
const content = {
title: heroRecord.title,
description: heroRecord.description,
bookByEmail: heroRecord.book_btn_text,
// Текст на кнопке (из hero_content)
contactWhatsApp: heroRecord.whatsapp_btn_text?.trim(),
// Текст сообщения (из hero_content)
whatsappDirectMessage: heroRecord.whatsapp_direct_msg,
stats: heroRecord.stats,
...heroRecord.form_texts
};
// 5. Получаем номер WhatsApp из глобальных настроек (из site_settings)
const whatsappNumber = settingsRecord.whatsapp_number;
---
<section class="relative text-white overflow-hidden min-h-screen flex flex-col">
<!-- Фоновое изображение -->
<div class="absolute inset-0 z-0">
<img
src={heroImageUrl}
alt="Hero Background"
width="1920"
height="1080"
class="object-cover object-center h-full w-full"
/>
<div class="absolute inset-0 bg-black/60"></div>
</div>
<!-- Основной контент -->
<div class="relative z-10 max-w-7xl mx-auto w-full px-6 flex-grow flex flex-col justify-center">
<div class="max-w-3xl mx-auto text-center py-16">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl animate-fade-in-up">
{content.title}
</h1>
<p class="mt-6 text-lg leading-8 text-gray-200 animate-fade-in-up" style="--animation-delay: 200ms;">
{content.description}
</p>
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 animate-fade-in-up" style="--animation-delay: 400ms;">
<!-- Кнопка Email -->
<Button
id="open-email-modal-btn"
variant="primary"
size="md"
fullWidth={false}
class="w-full sm:w-auto"
>
<span class="flex items-center gap-2">
<LetterIcon width="20" height="20" class="h-5 w-5" />
{content.bookByEmail}
</span>
</Button>
<!-- Кнопка WhatsApp -->
<!-- Ссылка формируется динамически: wa.me/НОМЕРЗАЗЫ?text=ТЕКСТЗАЗЫ -->
<Button
href={`https://wa.me/${whatsappNumber}?text=${encodeURIComponent(content.whatsappDirectMessage)}`}
variant="success"
size="md"
fullWidth={false}
class="w-full sm:w-auto"
target="_blank"
rel="noopener noreferrer"
>
<span class="flex items-center gap-2">
<WhatsAppIcon width="20" height="20" class="h-5 w-5" />
{content.contactWhatsApp}
</span>
</Button>
</div>
</div>
</div>
<!-- Блок статистики -->
<div class="absolute bottom-0 left-0 right-0 z-10">
<StatsBlock stats={content.stats} />
</div>
</section>
<!-- Модальное окно -->
<Modal
modalId="email-modal"
triggerId="open-email-modal-btn"
title={content.emailModalTitle}
maxWidth="md"
modalBgClass={COLOR_CLASSES.cardBackground}
>
<div class="pb-8">
<UniversalBookingForm
content={content}
formType="main"
client:only="solid"
/>
</div>
</Modal>
<Toast />
<script>
// Закрытие модального окна
window.addEventListener('booking-close-modal', () => {
// @ts-ignore
if (typeof window.closeModal === 'function') {
// @ts-ignore
window.closeModal('email-modal');
} else {
const modal = document.getElementById('email-modal');
if (modal) {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
}
});
// Обработка ошибок (Toast)
window.addEventListener('booking-error', (e) => {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: {
// @ts-ignore
message: e.detail.message,
type: 'error'
}
}));
});
</script>
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out forwards;
opacity: 0;
animation-delay: var(--animation-delay, 0s);
}
</style>

View file

@ -0,0 +1,134 @@
/**
* Определяет структуру объекта автомобиля для трансфера.
*/
export interface HeroContent {
title: string;
description: string;
bookByEmail: string;
contactWhatsApp: string;
emailModalTitle: string;
whatsappModalTitle: string;
whatsappDirectMessage: string;
// Заголовки секций формы
pickupDetailsTitle: string;
dropoffDetailsTitle: string;
// Поля формы
pickup: string;
pickupPlaceholder: string;
dropoff: string;
dropoffPlaceholder: string;
date: string;
time: string;
passengers: string;
phone: string;
name: string;
message: string;
messagePlaceholder?: string;
additionalInfo: string;
additionalInfoPlaceholder: string;
multipleVans: string;
checkAvailability: string;
sendMessage: string;
applyDiscountLabel: string;
// Строки для валидации
fieldRequired: string;
dateMustBeFuture: string;
dropoffDateBeforePickup: string;
invalidPhone: string;
bookingSubmitted: string;
selectOption: string;
addressCannotBeOnlyNumbers: string;
addressMustContainLetters: string;
invalidName: string;
// Общая ошибка отправки
somethingWentWrong: string;
// Сообщение об успехе для WhatsApp
messageSentSuccess: string;
// Новое поле — ошибка отправки (для валидации длины сообщения)
messageTooLong: string;
// Статистика
stats: readonly { value: string; label: string }[];
}
export const heroContent: HeroContent = {
title: 'Minivan & Privat-Transfers in Berlin',
description:
'Bequeme und zuverlässige Transfers zum Flughafen, Stadtrundfahrten und Reisen durch Deutschland. Buchen Sie Ihre Fahrt mit nur wenigen Klicks!',
bookByEmail: 'Per E-Mail buchen',
contactWhatsApp: 'Kontakt per WhatsApp',
emailModalTitle: 'Vollständiges Buchungsformular',
whatsappModalTitle: 'Schneller Kontakt',
// Предзаготовленный текст для прямого перехода в WhatsApp
whatsappDirectMessage:
'Hallo, ich interessiere mich für Ihre Dienstleistungen und habe eine Frage.',
// Заголовки секций
pickupDetailsTitle: 'Abholdetails',
dropoffDetailsTitle: 'Zielortdetails',
// Поля формы
pickup: 'Abholort',
pickupPlaceholder:
'z.B. Flughafen Berlin Brandenburg, Hotel Adlon oder Müllerstraße 123',
dropoff: 'Zielort',
dropoffPlaceholder:
'z.B. Hotel Adlon, Berlin Hauptbahnhof oder Potsdamer Platz',
date: 'Datum',
time: 'Uhrzeit',
passengers: 'Passagiere',
phone: 'Telefonnummer',
name: 'Ihr Name',
message: 'Ihre Nachricht',
messagePlaceholder: 'Hallo, ich möchte einen Transfer buchen...',
additionalInfo: 'Zusätzliche Informationen',
additionalInfoPlaceholder: 'z.B. Flugnummer, Kindersitz, Gepäck, Treffpunkt',
multipleVans: 'mehrere Vans',
checkAvailability: 'Verfügbarkeit prüfen',
sendMessage: 'Nachricht senden',
applyDiscountLabel: 'Ich möchte den 20% Aktions-Rabatt anwenden',
// === СТРОКИ ВАЛИДАЦИИ ===
fieldRequired: 'Dieses Feld ist erforderlich',
dateMustBeFuture: 'Das Datum muss in der Zukunft liegen',
dropoffDateBeforePickup:
'Das Rückgabedatum darf nicht vor dem Abholdatum liegen',
invalidPhone:
'Bitte geben Sie eine gültige Telefonnummer ein (z.B. +49 123 4567890)',
bookingSubmitted:
'Vielen Dank! Ihre Anfrage wurde gesendet. Wir melden uns bald bei Ihnen.',
selectOption: 'Auswählen...',
addressCannotBeOnlyNumbers:
'Die Adresse darf nicht nur aus Zahlen bestehen. Bitte geben Sie einen Ortsnamen, Straßennamen oder eine bekannte Landmarke ein.',
addressMustContainLetters:
'Die Adresse muss Buchstaben enthalten (z.B. Straße, Stadt oder Ort).',
invalidName: 'Nur Buchstaben und Leerzeichen erlaubt (z.B. Max Mustermann).',
// Отправка данных формы
somethingWentWrong:
'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
// Сообщение об успехе для WhatsApp
messageSentSuccess: 'Ihre Nachricht wurde erfolgreich gesendet!',
// Статистика
stats: [
{ value: '24/7', label: 'Service' },
{ value: '8+', label: 'Jahre Erfahrung' },
{ value: '500+', label: 'Zufriedene Kunden' },
{ value: '100k+', label: 'Gefahrene Kilometer' },
],
// Сообщение об ошибке при превышении лимита символов
messageTooLong:
'Die Nachricht darf die maximale Länge von {maxChars} Zeichen nicht überschreiten.',
};

View file

@ -0,0 +1,28 @@
---
import AnimatedCounter from './AnimatedCounter.astro';
// Получаем массив stats напрямую из пропсов Hero
const { stats } = Astro.props;
---
<div class="relative z-10 bg-black/20 backdrop-blur-sm py-8">
<div class="max-w-7xl mx-auto px-6 grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<div class="text-center animate-fade-in-up" style={`--animation-delay: ${index * 100 + 800}ms;`}>
<AnimatedCounter value={stat.value} />
<p class="text-sm text-gray-200">{stat.label}</p>
</div>
))}
</div>
</div>
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out forwards;
opacity: 0;
}
</style>

View file

@ -0,0 +1,50 @@
<div id="toast" class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium max-w-xs z-[999] transition-all duration-300 opacity-0 -translate-y-5 pointer-events-none bg-gray-800">
<div id="toast-icon"></div>
<span id="toast-message"></span>
</div>
<style>
#toast.is-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
</style>
<script>
const toast = document.getElementById('toast');
const toastIcon = document.getElementById('toast-icon');
const toastMessage = document.getElementById('toast-message');
let toastTimer;
// SVG иконки
const ICONS = {
success: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-200" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>`,
error: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-200" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /></svg>`
};
const showToast = (message, type) => {
clearTimeout(toastTimer);
toastMessage.textContent = message;
// Чистим старые классы цвета и добавляем новый
toast.classList.remove('bg-green-600', 'bg-red-600', 'bg-gray-800');
if (type === 'success') {
toast.classList.add('bg-green-600');
toastIcon.innerHTML = ICONS.success;
} else {
toast.classList.add('bg-red-600');
toastIcon.innerHTML = ICONS.error;
}
toast.classList.add('is-visible');
toastTimer = setTimeout(() => toast.classList.remove('is-visible'), 4000);
};
document.addEventListener('show-toast', (event) => {
// @ts-ignore
const { message, type } = event.detail;
showToast(message, type);
});
</script>

View file

@ -0,0 +1,115 @@
---
import { COLOR_CLASSES } from '@constants/colors';
import { authService } from '@/lib/authService';
import { Step } from '@/types/globalInterfaces';
// 1. Словарь иконок
// Ключи (FaPencilAlt, FaChild и т.д.) должны совпадать с полем "icon" в JSON вашего PocketBase
const modernIcons = {
FaPencilAlt: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>`,
FaChild: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.5v15m7.5-7.5h-15M19.5 12a7.5 7.5 0 11-15 0 7.5 7.5 0 0115 0z" /></svg>`,
FaUsers: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></svg>`,
FaRegCalendarCheck: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" /></svg>`,
FaPlaneDeparture: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /></svg>`,
FaCarSide: `<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" /></svg>`
};
// 2. Получение данных из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Запрашиваем запись из коллекции 'section_how_it_works' (имя коллекции, которое вы создали)
const record = await pb.collection('how_it_works').getFirstListItem('');
// Формируем объект контента
const content = {
mainTitle: record.title,
subtitle: record.subtitle,
// JSON массив шагов
steps: record.steps || []
};
---
<section class="bg-gray-50">
<div class="max-w-7xl mx-auto px-6 lg:px-8">
<div class="text-center section-header">
<h2 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl md:text-6xl animate-title">
{content.mainTitle}
</h2>
<p class="mt-6 text-lg leading-8 text-gray-600 max-w-3xl mx-auto animate-subtitle">
{content.subtitle}
</p>
</div>
<div class="mt-24 grid grid-cols-1 gap-y-16 md:grid-cols-2 lg:grid-cols-3 md:gap-x-12 steps-container">
{content.steps.map((step: Step, index: number) => {
// Выбираем SVG по имени из базы. Если иконка не найдена, используем заглушку (или первую иконку).
const iconSvg = modernIcons[step.icon as keyof typeof modernIcons] || modernIcons['FaCarSide'];
return (
<div
class={`step-item relative p-8 bg-white rounded-2xl shadow-lg transition-all duration-300 flex flex-col items-center text-center animate-card step-${index + 1}`}
>
<div class="absolute -top-6">
<div
class={`flex h-16 w-16 items-center justify-center rounded-full ${COLOR_CLASSES.primaryBg} shadow-md`}
set:html={iconSvg}
>
</div>
</div>
<h3 class="mt-10 text-2xl font-bold leading-8 text-gray-900">
{step.title}
</h3>
<p class="mt-4 text-base leading-7 text-gray-600">
{step.description}
</p>
</div>
);
})}
</div>
</div>
</section>
<style>
.animate-title {
opacity: 0;
transform: translateY(-20px);
animation: fadeInDown 0.7s ease-out forwards;
}
.animate-subtitle {
opacity: 0;
transform: translateY(10px);
animation: fadeInUp 0.7s ease-out 0.2s forwards;
}
.animate-card {
opacity: 0;
transform: translateY(50px);
animation: staggerFadeIn 0.6s cubic-bezier(0.6, 0.05, 0, 0.9) forwards;
}
.step-item:hover {
transform: translateY(-10px) scale(1.05);
box-shadow: 0 25px 50px -12px rgba(251, 191, 36, 0.25);
}
@keyframes fadeInDown {
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
to { opacity: 1; transform: translateY(0); }
}
@keyframes staggerFadeIn {
to { opacity: 1; transform: translateY(0); }
}
/* Анимация задержки для карточек (до 6 элементов) */
.step-1 { animation-delay: 0.2s; }
.step-2 { animation-delay: 0.4s; }
.step-3 { animation-delay: 0.6s; }
.step-4 { animation-delay: 0.8s; }
.step-5 { animation-delay: 1.0s; }
.step-6 { animation-delay: 1.2s; }
</style>

View file

@ -0,0 +1,98 @@
import { type JSX } from 'solid-js';
import { FiUsers, FiBriefcase, FiZap, FiGrid } from 'solid-icons/fi';
import type { Car } from '@/components/home/slider/SliderContent';
import { Button } from '@/components/base/Buttons';
// Вспомогательный компонент для "таблеток" с характеристиками
const FeaturePill = (props: { icon: () => JSX.Element; label: string }) => (
<div class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700">
<div class="h-4 w-4 text-gray-500">{props.icon()}</div>
<span>{props.label}</span>
</div>
);
interface CarDetailsCardProps {
car: Car;
onBookingClick: () => void;
}
const CarDetailsCard = (props: CarDetailsCardProps) => {
const car = () => props.car;
if (!car()) {
return null;
}
return (
<div class="bg-white rounded-xl overflow-hidden">
<div class="grid grid-cols-1 md:grid-cols-2">
{/* --- Левая колонка: Изображение --- */}
<div class="relative h-64 md:h-full min-h-[300px]">
<img
src={car().src}
alt={car().alt}
width="400"
height="300"
class="object-cover w-full h-full"
/>
</div>
{/* --- Правая колонка: Информация --- */}
<div class="flex flex-col p-6 pt-2 sm:p-8 sm:pt-4">
{/* Заголовок */}
<div>
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">
{car().make} {car().model}
</h2>
<div class="mt-2 flex justify-between items-center">
<p class="text-lg font-medium text-gray-500">{car().year}</p>
<div class="bg-yellow-500 text-white px-2 py-1 rounded text-sm font-bold shadow-md">
{car().price}
</div>
</div>
</div>
{/* Описание */}
<div class="mt-6">
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider">
Beschreibung
</h3>
<p class="mt-2 text-gray-600 leading-relaxed text-sm">
{car().description}
</p>
</div>
{/* Характеристики */}
<div class="mt-8">
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider">
Ausstattung
</h3>
<div class="mt-3 flex flex-wrap gap-3">
<FeaturePill icon={() => <FiUsers />} label={car().seats[0] || 'N/A'} />
<FeaturePill icon={() => <FiBriefcase />} label={car().trunk} />
<FeaturePill icon={() => <FiGrid />} label={car().doors} />
<FeaturePill icon={() => <FiZap />} label={car().horsepower} />
</div>
</div>
{/* Футер с ценой и кнопкой */}
<div class="mt-auto pt-8">
<div class="border-t border-gray-200 pt-6">
<Button
variant="primary"
onClick={props.onBookingClick}
size="lg"
fullWidth
class="!bg-blue-600 hover:!bg-blue-700"
>
Jetzt Buchen
</Button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CarDetailsCard;

View file

@ -0,0 +1,83 @@
import { FiUsers, FiBriefcase, FiZap, FiGrid } from 'solid-icons/fi';
import type { Car } from './SliderContent';
import { Button } from '@/components/base/Buttons';
import { For } from 'solid-js';
import type { JSX } from 'solid-js';
interface CarSliderCardProps {
car: Car;
onDetailsClick: () => void;
}
const CarSliderCard = (props: CarSliderCardProps) => {
const car = () => props.car;
const allFeatures = () => [
{ icon: <FiUsers class="text-blue-500" />, label: car().seats.join(' / ') },
{ icon: <FiGrid class="text-blue-500" />, label: car().doors },
{ icon: <FiBriefcase class="text-blue-500" />, label: car().trunk },
{ icon: <FiZap class="text-blue-500" />, label: car().horsepower },
];
return (
<div
class="bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col group transition-all duration-500 hover:shadow-2xl border border-gray-100 h-full w-full"
>
<div class="relative h-64 w-full overflow-hidden">
<img
src={car().src}
alt={car().alt}
width="400"
height="256"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-linear-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div class="absolute bottom-4 left-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<h3 class="text-xl font-bold text-white drop-shadow-lg">
{car().make} {car().model}
</h3>
</div>
<div class="absolute top-2 right-2 bg-yellow-500 text-white px-2 py-1 rounded text-sm font-bold shadow-md">
{car().price}
</div>
</div>
<div class="p-6 grow flex flex-col bg-linear-to-b from-white to-gray-50">
<div class="flex justify-between items-start mb-3">
<h3 class="text-xl font-bold text-gray-800">{car().make} {car().model}</h3>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{car().year}
</span>
</div>
<p class="text-gray-600 text-sm mb-4 grow">
{car().description}
</p>
<ul class="grid grid-cols-2 gap-x-4 gap-y-3 mb-6">
<For each={allFeatures()}>
{(feature: { icon: JSX.Element, label: string }) => (
<li class="flex items-center text-sm">
<div class="mr-2 shrink-0 flex items-center justify-center w-5 h-5">{feature.icon}</div>
<span class="text-gray-700">{feature.label}</span>
</li>
)}
</For>
</ul>
<div class="mt-auto">
<Button
variant="primary"
fullWidth
onClick={props.onDetailsClick}
size="lg"
class="bg-blue-600! hover:bg-blue-700!"
>
Details Ansehen
</Button>
</div>
</div>
</div>
);
};
export default CarSliderCard;

View file

@ -0,0 +1,252 @@
import { createSignal, createMemo, Show, For } from 'solid-js';
import { FaSolidChevronLeft, FaSolidChevronRight } from 'solid-icons/fa';
import { UniversalBookingForm } from '@/components/booking/UniversalBookingForm';
import { heroContent } from '@/components/home/hero/HeroContent';
import Notification from '@/components/home/slider/Notification';
import {
type Car,
germanCarsSliderContent as contentData,
transformSliderDataToCar
} from '@/components/home/slider/SliderContent';
import { sliderService } from '@/lib/sliderService';
import { authService } from '@/lib/authService';
import type { CarsSliderProps, CarsSliderText } from '@/types/globalInterfaces';
import { SolidModal } from '@/components/base/Buttons';
import CarSliderCard from './CarSliderCard';
import CarDetailsCard from './CarDetailsCard';
const componentText: CarsSliderText = {
mainTitle: 'Verfügbare Fahrzeuge',
subtitle: 'Wählen Sie die perfekte Option für Ihren Transfer',
previousButtonAlt: 'Vorherige Karten',
nextButtonAlt: 'Nächste Karten',
selectButton: 'Details Ansehen',
goToSlide: 'Gehe zu Folie',
};
const CarsSlider = (props: CarsSliderProps) => {
const text = componentText;
const [currentSlide, setCurrentSlide] = createSignal(0);
const [direction, setDirection] = createSignal<'left' | 'right'>('right');
const [isModalOpen, setIsModalOpen] = createSignal(false);
const [selectedCar, setSelectedCar] = createSignal<Car | null>(null);
const [notification, setNotification] = createSignal<{
message: string;
type: 'success' | 'error';
} | null>(null);
const [modalView, setModalView] = createSignal<'details' | 'form'>('details');
const [isMobile, setIsMobile] = createSignal<boolean>(false);
// Используем данные из пропсов с проверкой на определенность
const cars = () => props.cars || [];
const cardsPerPage = createMemo(() => (isMobile() ? 1 : 3));
const totalSlides = createMemo(() => Math.ceil(cars().length / cardsPerPage()));
const visibleCars = createMemo(() => {
const startIndex = currentSlide() * cardsPerPage();
const visible: Car[] = [];
for (let i = 0; i < cardsPerPage(); i++) {
const carIndex = startIndex + i;
if (carIndex < cars().length) {
visible.push(cars()[carIndex]);
}
}
return visible;
});
// handleSuccess и handleError больше не используются, так как
// используется событийная модель через booking-success и booking-error
const goToNext = () => {
setDirection('right');
setCurrentSlide((prev) => (prev + 1) % totalSlides());
};
const goToPrevious = () => {
setDirection('left');
setCurrentSlide((prev) => (prev - 1 + totalSlides()) % totalSlides());
};
const goToSlide = (index: number) => {
setDirection(index > currentSlide() ? 'right' : 'left');
setCurrentSlide(index);
};
const handleOpenDetails = (car: Car) => {
setSelectedCar(car);
setModalView('details');
setIsModalOpen(true);
};
const handleShowBookingForm = () => {
setModalView('form');
};
const handleCloseModal = () => {
setIsModalOpen(false);
setNotification(null);
setTimeout(() => {
setSelectedCar(null);
setModalView('details');
}, 300);
};
return (
<>
<section class="bg-white py-12 sm:py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-8">
<h2 class="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-gray-900">
{text.mainTitle}
</h2>
<p class="mt-4 sm:mt-6 text-base sm:text-lg leading-7 text-gray-600 max-w-3xl mx-auto">
{text.subtitle}
</p>
</div>
<div class="max-w-xl mx-auto mb-8 h-4 flex items-center justify-center">
<Show when={notification()}>
{(notification) => (
<Notification
message={notification().message}
type={notification().type}
onClose={() => setNotification(null)}
/>
)}
</Show>
</div>
{/*
Основной контейнер слайдера
Проверяем, есть ли данные для отображения
*/}
{props.cars && props.cars.length > 0 ? (
<div class="relative w-full max-w-7xl mx-auto">
<div class="relative">
{/* Контейнер навигации и карточек */}
<div class="flex items-center justify-center">
{/* Кнопка "Назад" (Custom HTML Button) */}
<button
onClick={goToPrevious}
aria-label={text.previousButtonAlt}
class={`
absolute z-20 top-1/2 -translate-y-1/2
w-12 h-12 rounded-full
flex items-center justify-center
bg-white text-gray-700
shadow-[0_4px_12px_rgba(0,0,0,0.12)] border border-gray-100
transition-all duration-300 ease-out
hover:bg-gray-50 hover:text-blue-600 hover:shadow-[0_8px_16px_rgba(0,0,0,0.15)] hover:scale-110 hover:border-gray-200
active:scale-95 active:shadow-sm
cursor-pointer
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isMobile() ? '-left-2' : '-left-6'}
`}
>
<FaSolidChevronLeft size={18} />
</button>
{/* Контейнер карточек */}
<div class="flex justify-center items-stretch gap-4 sm:gap-6 w-full px-4 sm:px-12 overflow-hidden">
<For each={visibleCars()}>
{(car) => (
<div class="flex-1 max-w-lg transition-all duration-500 ease-in-out animate-fade-in">
<CarSliderCard
car={car}
onDetailsClick={() => handleOpenDetails(car)}
/>
</div>
)}
</For>
</div>
{/* Кнопка "Вперед" (Custom HTML Button) */}
<button
onClick={goToNext}
aria-label={text.nextButtonAlt}
class={`
absolute z-20 top-1/2 -translate-y-1/2
w-12 h-12 rounded-full
flex items-center justify-center
bg-white text-gray-700
shadow-[0_4px_12px_rgba(0,0,0,0.12)] border border-gray-100
transition-all duration-300 ease-out
hover:bg-gray-50 hover:text-blue-600 hover:shadow-[0_8px_16px_rgba(0,0,0,0.15)] hover:scale-110 hover:border-gray-200
active:scale-95 active:shadow-sm
cursor-pointer
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isMobile() ? '-right-2' : '-right-6'}
`}
>
<FaSolidChevronRight size={18} />
</button>
</div>
{/* Индикаторы (точки) */}
<div class="flex mt-8 space-x-2.5 justify-center">
<For each={Array.from({ length: totalSlides() })}>
{(_, index) => (
<button
onClick={() => goToSlide(index())}
class={`
h-2.5 rounded-full transition-all duration-300 ease-out cursor-pointer
focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1
${
currentSlide() === index()
? 'bg-blue-600 w-8 shadow-md' // Активная точка
: 'bg-gray-300 w-2.5 hover:bg-gray-400 hover:scale-110'
}
`}
aria-label={`${text.goToSlide} ${index() + 1}`}
/>
)}
</For>
</div>
</div>
</div>
) : (
<div class="text-center py-12">
<p class="text-gray-600">Keine Fahrzeuge verfügbar</p>
</div>
)}
</div>
</section>
<SolidModal
isOpen={isModalOpen()}
onClose={handleCloseModal}
title={modalView() === 'details' ? 'Fahrzeugdetails' : 'Ihre Anfrage'}
size="lg"
>
<Show when={selectedCar()}>
{(car) => (
<Show
when={modalView() === 'details'}
fallback={
<div class="p-6">
<UniversalBookingForm
car={car()}
content={heroContent}
formType="slider"
accentColor="blue"
/>
</div>
}
>
<CarDetailsCard
car={car()}
onBookingClick={handleShowBookingForm}
/>
</Show>
)}
</Show>
</SolidModal>
</>
);
};
export default CarsSlider;

View file

@ -0,0 +1,41 @@
import { FaSolidCheck, FaSolidExclamation, FaSolidXmark } from 'solid-icons/fa';
import { Button } from '@/components/base/Buttons';
import { Dynamic } from 'solid-js/web';
interface NotificationProps {
message: string;
type: 'success' | 'error';
onClose: () => void;
}
const Notification = (props: NotificationProps) => {
const isSuccess = () => props.type === 'success';
const bgColor = () => isSuccess()
? 'bg-green-100 border-green-400'
: 'bg-red-100 border-red-400';
const textColor = () => isSuccess() ? 'text-green-800' : 'text-red-800';
const Icon = () => isSuccess() ? FaSolidCheck : FaSolidExclamation;
return (
<div
class={`relative flex items-center p-4 border rounded-md ${bgColor()}`}
>
<span class={`mr-3 ${textColor()}`} style={{ width: '20px', height: '20px' }}>
<Dynamic component={Icon()} />
</span>
<p class={`text-sm font-medium ${textColor()}`}>{props.message}</p>
<Button
variant="link"
onClick={props.onClose}
class="absolute top-1 right-1 !p-1 !text-gray-500 !shadow-none hover:!text-gray-700"
>
<span class={textColor()} style={{ width: '16px', height: '16px' }}>
<FaSolidXmark />
</span>
</Button>
</div>
);
};
export default Notification;

View file

@ -0,0 +1,54 @@
/**
* Определяет структуру объекта автомобиля для трансфера из PocketBase.
*/
export interface Car {
id: string;
src: string; // image URL from PocketBase
alt: string; // alt_text from PocketBase
make: string; // brand from PocketBase
model: string;
year: number;
price: string;
seats: string[]; // seats_count from PocketBase (преобразуется в массив)
doors: string; // doors_count from PocketBase
trunk: string; // trunk_capacity from PocketBase
horsepower: string;
description: string;
order: number;
is_active: boolean;
max_passengers: number;
collectionId: string;
collectionName: string;
created: string;
updated: string;
}
// Заглушка для совместимости - реальные данные будут загружаться из PocketBase
export const germanCarsSliderContent: { cars: Car[] } = {
cars: [],
};
// Функция для преобразования данных из API в структуру Car
export const transformSliderDataToCar = (apiData: any): Car => {
return {
id: apiData.id,
src: `${import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090'}/api/files/${apiData.collectionName}/${apiData.id}/${apiData.image}`,
alt: apiData.alt_text,
make: apiData.brand,
model: apiData.model,
year: apiData.year,
price: apiData.price,
seats: [apiData.seats_count],
doors: apiData.doors_count,
trunk: apiData.trunk_capacity,
horsepower: apiData.horsepower,
description: apiData.description,
order: apiData.order,
is_active: apiData.is_active,
max_passengers: apiData.max_passengers,
collectionId: apiData.collectionId,
collectionName: apiData.collectionName,
created: apiData.created,
updated: apiData.updated
};
};

View file

@ -0,0 +1,141 @@
---
import FooterCopyright from './FooterCopyright.astro';
import FooterInfo from './FooterInfo.astro';
import FooterInfoColumn from './FooterInfoColumn.astro';
import FooterLinkColumn from './FooterLinkColumn.astro';
import { authService } from '@/lib/authService';
// Получаем элементы из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получаем элементы для футера
const records = await pb.collection('b_navbar').getList(1, 100, {
sort: 'order',
filter: 'is_active = true'
});
// Группировка элементов по колонкам на основе order
const exclusiveItems = records.items.filter(item => item.order >= 1 && item.order <= 3);
const legalItems = records.items.filter(item => item.order >= 4 && item.order <= 6);
const companyItems = records.items.filter(item => item.order >= 7 && item.order <= 10);
const serviceItems = records.items.filter(item => item.order >= 11 && item.order <= 13);
// Подготовка колонок ссылок
const linkColumns = [
{
title: 'Exklusiv-Service',
items: exclusiveItems.map(item => ({
label: item.label,
href: item.href,
isCta: item.is_cta || false
}))
},
{
title: 'Rechtliches',
items: legalItems.map(item => ({
label: item.label,
href: item.href,
isCta: item.is_cta || false
}))
},
{
title: 'Unternehmen',
items: companyItems.map(item => ({
label: item.label,
href: item.href,
isCta: item.is_cta || false
}))
},
{
title: 'Service',
items: serviceItems.map(item => ({
label: item.label,
href: item.href,
isCta: item.is_cta || false
}))
}
];
// Поля для контактной информации и социальных сетей пока оставим статичными, но можно также вынести в site_settings
const tagline = 'Ihr zuverlässiger Partner für exklusive Chauffeurdienste in Berlin.';
const socialIcons = [
{
name: 'Instagram',
url: '#',
hoverColorClass: 'hover:text-[#E4405F]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>'
},
{
name: 'LinkedIn',
url: '#',
hoverColorClass: 'hover:text-[#0A66C2]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
},
{
name: 'WhatsApp',
url: '#',
hoverColorClass: 'hover:text-[#25D366]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>'
}
];
// Получаем настройки сайта
const siteSettings = await pb.collection('site_settings').getFirstListItem('id != ""').catch(() => null);
const companyName = siteSettings?.company_name || 'Berlin Minivan Transfers';
const copyright = siteSettings?.copyright_text || 'Alle Rechte vorbehalten.';
// Подготовка контактной информации (пока статичная, но можно также получить из site_settings)
const infoColumns = [
{
title: 'Kontakt',
items: [
'+49 157 12345678',
'info@minv.berlin',
'Berlin Mitte, Friedrichstraße 123',
],
},
{
title: 'Sicherheit',
items: [
'TÜV-zertifiziert',
'SSL-Verschlüsselung',
],
},
];
---
<footer class="bg-[#ECF2F5] border-t border-gray-200 pt-16 pb-8">
<div class="max-w-7xl mx-auto px-6">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-x-6 gap-y-10">
<FooterInfo {tagline} {socialIcons} />
{linkColumns[0].items.length > 0 && (
<FooterLinkColumn column={linkColumns[0]} />
)}
{linkColumns[2].items.length > 0 && (
<FooterLinkColumn column={linkColumns[2]} />
)}
<div>
{linkColumns[3].items.length > 0 && (
<FooterLinkColumn column={linkColumns[3]} />
)}
<div class="mt-8">
{linkColumns[1].items.length > 0 && (
<FooterLinkColumn column={linkColumns[1]} />
)}
</div>
</div>
<div>
<FooterInfoColumn column={infoColumns[0]} />
<div class="mt-8">
<FooterInfoColumn column={infoColumns[1]} />
</div>
</div>
</div>
<FooterCopyright {companyName} copyrightText={copyright} />
</div>
</footer>

View file

@ -0,0 +1,12 @@
---
const { companyName, copyrightText } = Astro.props;
const currentYear = new Date().getFullYear();
---
{(companyName || copyrightText) && (
<div class="mt-16 pt-8 border-t border-gray-200 text-center">
<p class="text-gray-500 text-xs md:text-sm">
© {currentYear} {companyName || 'Unsere Firma'}. {copyrightText || ''}
</p>
</div>
)}

View file

@ -0,0 +1,76 @@
---
import { authService } from '@/lib/authService';
const { tagline, socialIcons } = Astro.props;
// Получаем данные из коллекции site_settings
const pb = authService.createClientFromRequest(Astro.request);
const records = await pb.collection('site_settings').getList(1, 1, {
sort: '-created' // Берём самую последнюю запись
});
const siteSettings = records.items[0] || {};
const logoPath = siteSettings.site_logo
? `${pb.baseURL}/api/files/site_settings/${siteSettings.id}/${siteSettings.site_logo}`
: null;
---
{tagline && (
<div class="col-span-2 md:col-span-3 lg:col-span-1 space-y-5">
{logoPath ? (
<a href="/" aria-label="Zur Startseite wechseln">
<div class="relative flex items-center justify-start">
<img
src={logoPath}
alt="MinivanBerlin Logo"
width="50"
height="20"
class="object-contain object-left max-w-full"
/>
</div>
</a>
) : (
// Можно добавить placeholder элемент, если логотип не установлен
<div class="relative flex items-center justify-start">
<div class="text-gray-400 italic" style="width: 50px; height: 20px; display: flex; align-items: center; justify-content: center;">
Logo
</div>
</div>
)}
<p class="text-gray-600 text-sm leading-relaxed">{tagline}</p>
{socialIcons && socialIcons.length > 0 && (
<div class="space-y-4 pt-2">
<h3 class="text-base font-semibold text-gray-900 mb-2">
Folgen Sie uns
</h3>
<div class="flex space-x-4">
{socialIcons.map((social) => (
<a
href={social.url}
class={`text-gray-500 ${social.hoverColorClass} text-xl transition-all duration-300 hover:scale-110 social-icon`}
aria-label={`Folgen Sie uns auf ${social.name}`}
target="_blank"
rel="noopener noreferrer"
set:html={social.svg}
></a>
))}
</div>
</div>
)}
</div>
)}
<style>
.social-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.social-icon svg {
width: 100%;
height: 100%;
}
</style>

View file

@ -0,0 +1,18 @@
---
const { column } = Astro.props;
---
{column.items && column.items.length > 0 && (
<div class="space-y-4">
<h3 class="text-base font-semibold text-gray-900 mb-3">
{column.title}
</h3>
<ul class="space-y-2.5">
{column.items.map((item) => (
<li class="text-sm text-gray-600">
{item}
</li>
))}
</ul>
</div>
)}

View file

@ -0,0 +1,41 @@
---
const { column } = Astro.props;
---
{column.items && column.items.length > 0 && (
<nav aria-label={column.title} class="space-y-4">
<h3 class="text-base font-semibold text-gray-900 mb-3">
{column.title}
</h3>
<ul class="space-y-2.5">
{column.items.map((item) => (
<li>
<a
href={item.href}
data-astro-prefetch
class={`text-sm flex items-center group transition-colors duration-200 ${
item.isCta
? 'text-blue-600 font-semibold hover:text-blue-700'
: 'text-gray-600 hover:text-blue-600'
}`}
>
{!item.isCta && (
<span class="w-1.5 h-1.5 bg-transparent group-hover:bg-blue-600 rounded-full mr-2.5 transition-colors duration-200"></span>
)}
{item.label}
</a>
</li>
))}
</ul>
</nav>
)}
<style>
a {
transition: all 0.2s ease-in-out;
}
a:hover:not([class*="text-blue-"]) {
transform: translateX(4px);
}
</style>

View file

@ -0,0 +1,78 @@
export const companyName = 'Berlin Minivan Transfers';
export const tagline = 'Ihr zuverlässiger Partner für exklusive Chauffeurdienste in Berlin.';
export const copyright = 'Alle Rechte vorbehalten.';
export const socialIcons = [
{
name: 'Instagram',
url: '#',
hoverColorClass: 'hover:text-[#E4405F]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>'
},
{
name: 'LinkedIn',
url: '#',
hoverColorClass: 'hover:text-[#0A66C2]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
},
{
name: 'WhatsApp',
url: '#',
hoverColorClass: 'hover:text-[#25D366]',
svg: '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>'
}
];
export const linkColumns = [
{
title: 'Exklusiv-Service',
items: [
{ label: '24h Chauffeur Berlin', href: '/vip-services#24h-service' },
{ label: 'Diskretionsgarantie', href: '/vip-services#diskretion' },
{ label: 'Persönlicher Concierge', href: '/vip-services#concierge' },
],
},
{
title: 'Rechtliches',
items: [
{ label: 'Impressum', href: '/impressum' },
{ label: 'Datenschutz', href: '/datenschutz' },
{ label: 'AGB', href: '/agb' },
],
},
{
title: 'Unternehmen',
items: [
{ label: 'Über Uns', href: '/ueber-uns' },
{ label: 'Karriere', href: '/karriere' },
{ label: 'Partner', href: '/partner' },
{ label: 'Bewertungen', href: '/bewertungen' },
],
},
{
title: 'Service',
items: [
{ label: 'Häufige Fragen', href: '/faq' },
{ label: 'Zahlungsarten', href: '/zahlungsarten' },
{ label: 'Stornierung', href: '/stornierung' },
],
},
];
export const infoColumns = [
{
title: 'Kontakt',
items: [
'+49 157 12345678',
'info@minv.berlin',
'Berlin Mitte, Friedrichstraße 123',
],
},
{
title: 'Sicherheit',
items: [
'TÜV-zertifiziert',
'SSL-Verschlüsselung',
],
},
];

View file

@ -0,0 +1,20 @@
<a
href="/action"
class="flex items-center group"
aria-label="Zur Aktionsseite"
>
<div class="relative flex items-center justify-center mr-2">
<span class="absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75 animate-ping"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</div>
<span class="
relative pb-0.5 text-sm font-semibold text-red-600 group-hover:text-red-700 transition-colors
after:content-[''] after:absolute after:bottom-0 after:left-0
after:h-[1.5px] after:w-full after:bg-red-600
after:origin-center after:scale-x-0 after:transition-transform after:duration-500 after:ease-in-out
group-hover:after:scale-x-100
">
Action: 20% Rabatt
</span>
</a>

View file

@ -0,0 +1,127 @@
---
interface Props {
session?: any;
status?: 'authenticated' | 'unauthenticated' | 'loading';
size?: 'sm' | 'md';
variant?: 'primary' | 'danger' | 'ghost' | 'outline';
hideText?: boolean;
class?: string;
}
// Деструктуризация пропсов с дефолтными значениями
const {
session: propSession,
status: propStatus,
size = 'md',
variant = 'primary',
hideText = false,
class: className = ''
} = Astro.props;
const user = Astro.locals.user;
const session = propSession !== undefined ? propSession : (user ? { user } : null);
const status = propStatus !== undefined ? propStatus : (user ? 'authenticated' : 'unauthenticated');
// Конфигурация стилей
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base'
};
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 border border-transparent shadow-sm',
danger: 'bg-red-600 text-white hover:bg-red-700 border border-transparent shadow-sm',
ghost: 'bg-transparent text-gray-700 hover:text-gray-900',
outline: 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50'
};
const baseClasses = `inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all duration-200 cursor-pointer ${sizeClasses[size]} ${variantClasses[variant]} ${className}`;
---
{status === 'authenticated' ? (
// === КНОПКА ВЫХОДА (LOGOUT) ===
<button
type="button"
class={`logout-button ${baseClasses}`}
aria-label="Abmelden"
title="Abmelden"
>
{/* Иконка Logout */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
{!hideText && <span>Abmelden</span>}
</button>
) : (
// === КНОПКА ВХОДА (LOGIN) ===
<a
href="/auth/login"
class={baseClasses}
aria-label="Anmelden"
title="Anmelden"
>
{/* Иконка Login */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/>
</svg>
{!hideText && <span>Anmelden</span>}
</a>
)}
<script>
function setupLogoutButtons() {
// Ищем все кнопки выхода
const buttons = document.querySelectorAll('.logout-button');
buttons.forEach((btn) => {
// Клонируем кнопку, чтобы удалить старые обработчики событий (защита от дублирования)
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
btn.parentNode?.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async (e) => {
e.preventDefault();
// Сохраняем исходный контент
const originalHTML = newBtn.innerHTML;
// Блокируем кнопку и показываем спиннер
newBtn.disabled = true;
newBtn.innerHTML = `
<svg class="animate-spin h-5 w-5 text-current" 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>
${!newBtn.innerText.includes('Abmelden') ? '' : '<span class="ml-2">Lädt...</span>'}
`;
try {
const response = await fetch('/api/auth/sign-out', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
// Успех: перезагружаем страницу
window.location.href = window.location.href;
} else {
throw new Error('Logout failed');
}
} catch (error) {
console.error('Logout error:', error);
alert('Fehler beim Abmelden.');
// Восстанавливаем кнопку при ошибке
newBtn.innerHTML = originalHTML;
newBtn.disabled = false;
}
});
});
}
// Инициализация при загрузке
document.addEventListener('DOMContentLoaded', setupLogoutButtons);
// Поддержка Astro View Transitions (если используются)
document.addEventListener('astro:page-load', setupLogoutButtons);
</script>

View file

@ -0,0 +1,332 @@
---
import { Icon } from 'astro-icon/components';
import Logo from './Logo.astro';
import TopHeader from './TopHeader.astro';
import UserAvatar from './UserAvatar.astro';
import AuthButton from './AuthButton.astro';
import Link from '@components/base/Link.astro';
import { authService } from '@/lib/authService';
// Получение пропсов компонента
const {
isActionActive = false,
session = null,
status = 'unauthenticated',
currentPath = '/'
} = Astro.props;
// Получаем элементы навигации из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
let navItems = [];
try {
const records = await pb.collection('t_navbar').getList(1, 100, {
sort: 'order',
filter: 'is_active = true'
});
navItems = records.items.map(item => ({
id: item.id,
label: item.label,
href: item.href,
order: item.order,
targetBlank: item.target_blank || false,
cssClass: item.css_class || ''
}));
} catch (e) {
console.error("Error loading navbar:", e);
// Фолбэк на случай ошибки базы данных
navItems = [
{ label: 'Startseite', href: '/', order: 1, targetBlank: false, cssClass: '' }
];
}
// Фильтрация элемента "Startseite" на главной странице
const filteredNavItems = currentPath === '/'
? navItems.filter(item => item.label !== 'Startseite')
: navItems;
const userName = session?.user?.name || session?.user?.email?.split('@')[0] || 'Gast';
// Определение страниц авторизации для правильного отображения элементов
const isAuthPage = currentPath.startsWith('/auth/login') || currentPath.startsWith('/auth/register');
// Определение страниц, на которых должна отображаться иконка аутентификации
const showAuthIcon = currentPath.startsWith('/bewertungen') || currentPath.startsWith('/kontakt');
---
<!-- Верхний заголовок сайта -->
<div id="top-header-wrapper" class="transition-all duration-300 ease-in-out z-60 relative bg-white">
<TopHeader isActionActive={isActionActive} />
</div>
<!-- Основной заголовок (шапка) сайта -->
<header id="main-header" class="border-b border-gray-200 bg-[#ECF2F5] transition-all duration-300 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-6 py-3">
<div class="flex justify-between items-center h-12">
<!-- Логотип сайта -->
<Logo width={50} height={30} className="shrink-0" />
<!-- Десктопное меню навигации -->
<nav class="hidden lg:flex items-center space-x-6 xl:space-x-8">
{filteredNavItems.map((item) => {
const target = item.targetBlank ? '_blank' : '_self';
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
return (
<Link
href={item.href}
target={target}
rel={rel}
variant="default"
class={`text-base font-medium transition-colors hover:text-blue-600 ${currentPath === item.href ? 'text-blue-600 font-bold' : 'text-gray-700'} ${item.cssClass}`}
>
{item.label}
</Link>
);
})}
</nav>
<!-- Правый блок (аутентификация и профиль) -->
<div class="hidden lg:flex items-center space-x-4">
{status === 'authenticated' && session?.user ? (
<div class="flex items-center gap-4 relative">
<div class="flex items-center gap-3 relative">
<UserAvatar user={session.user} />
<div class="absolute top-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-white blinking-dot-online"></div>
</div>
<!-- Кнопка выхода из системы -->
<AuthButton
status={status}
session={session}
size="md"
variant="ghost"
hideText={true}
class="w-12 h-12 p-0 rounded-full !flex items-center justify-center text-red-600 hover:text-red-700 hover:bg-red-100 transition-all duration-200"
/>
</div>
) : (
!isAuthPage && showAuthIcon && (
<div class="flex items-center gap-2 relative">
<!-- Кнопка входа в систему -->
<AuthButton
status={status}
session={session}
size="md"
variant="ghost"
hideText={true}
class="w-12 h-12 p-0 rounded-full !flex items-center justify-center text-gray-500 hover:text-blue-600 hover:bg-blue-100 transition-all duration-200"
/>
</div>
)
)}
</div>
<!-- Мобильная кнопка меню (Гамбургер) -->
<div class="lg:hidden relative z-[102]">
<button
id="mobile-menu-button"
class="w-10 h-10 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-all shadow-sm cursor-pointer"
aria-label="Menü öffnen"
>
<Icon name="burgerIcon" id="burger-icon" width="28" height="28" class="transition-opacity duration-300" />
</button>
</div>
</div>
</div>
<!-- Индикатор прокрутки страницы -->
<div id="scroll-progress-line" class="absolute bottom-0 left-0 h-[3px] bg-blue-600 z-50 transition-all duration-100 ease-linear" style="width: 0; opacity: 0;"></div>
</header>
<!-- Overlay для мобильного меню -->
<div id="mobile-menu-overlay" class="fixed inset-0 bg-black/50 backdrop-blur-[2px] z-90 opacity-0 pointer-events-none transition-opacity duration-300"></div>
<!-- Мобильное меню (СЛЕВА) -->
<div
id="mobile-menu"
class="fixed top-0 left-0 bottom-0 w-[85%] max-w-[320px] z-[100] bg-white shadow-2xl flex flex-col transform -translate-x-full transition-transform duration-300 ease-in-out"
>
<!-- Заголовок мобильного меню -->
<!-- ИЗМЕНЕНИЯ:
1. px-6 py-3 (как на десктопе)
2. bg-[#ECF2F5] (фон как на десктопе)
3. border-gray-200 (как на десктопе)
-->
<div class="px-6 py-3 flex items-center justify-between border-b border-gray-200 bg-[#ECF2F5]">
<!-- Обертка h-12 для точного совпадения высоты с десктопом -->
<div class="h-12 flex items-center">
<Logo width={50} height={30} className="shrink-0" />
</div>
<button id="mobile-menu-close-button" class="p-2 -mr-2 rounded-full hover:bg-black/5 text-gray-500 transition-colors cursor-pointer">
<Icon name="closeIcon" width="28" height="28" />
</button>
</div>
{status === 'authenticated' && session?.user && (
<!-- Информация о пользователе в мобильном меню -->
<div class="px-5 py-6 bg-blue-50 border-b border-blue-100 flex flex-col items-center text-center">
<div class="mb-3 transform scale-125 relative">
<UserAvatar user={session.user} />
<div class="absolute top-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-white blinking-dot-online"></div>
</div>
</div>
)}
<div class="grow overflow-y-auto p-5">
<!-- Мобильное меню навигации -->
<nav class="flex flex-col space-y-1">
{filteredNavItems.map((item) => {
const target = item.targetBlank ? '_blank' : '_self';
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
return (
<Link
href={item.href}
target={target}
rel={rel}
variant="default"
class={`block px-4 py-3 rounded-lg text-lg font-medium transition-colors ${
currentPath === item.href
? 'bg-blue-50 text-blue-600 font-bold'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
} ${item.cssClass}`}
>
{item.label}
</Link>
);
})}
</nav>
<!-- Разделитель в мобильном меню -->
<div class="my-6 border-t border-gray-100"></div>
<!-- Кнопка аутентификации в мобильном меню -->
<div class="space-y-3">
{showAuthIcon && (
<AuthButton
status={status}
session={session}
size="md"
variant={status === 'authenticated' ? 'danger' : 'primary'}
hideText={false}
class="w-full justify-center"
/>
)}
</div>
</div>
</div>
<style>
/* Стили для скрытия верхнего заголовка при прокрутке */
.top-header-hidden {
margin-top: -42px;
opacity: 0;
pointer-events: none;
}
/* Стили для анимации точки онлайн-статуса пользователя */
.blinking-dot-online {
animation: blink-online 2s ease-in-out infinite;
}
@keyframes blink-online {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
}
70% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
</style>
<script is:inline>
// Функция инициализации всех обработчиков событий
function initHeaderLogic() {
// --- Логика скролла ---
const topHeaderWrapper = document.getElementById('top-header-wrapper');
const progressLine = document.getElementById('scroll-progress-line');
let lastScrollY = window.scrollY;
const handleScroll = () => {
const currentScrollY = window.scrollY;
const winHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
const maxScroll = docHeight - winHeight;
if (progressLine && maxScroll > 0) {
const percent = Math.min((currentScrollY / maxScroll) * 100, 100);
progressLine.style.width = `${percent}%`;
progressLine.style.opacity = currentScrollY > 5 ? '1' : '0';
}
if (topHeaderWrapper) {
if (currentScrollY > 50 && currentScrollY > lastScrollY) {
topHeaderWrapper.classList.add('top-header-hidden');
} else {
topHeaderWrapper.classList.remove('top-header-hidden');
}
}
lastScrollY = currentScrollY;
};
window.removeEventListener('scroll', handleScroll);
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
// --- Логика мобильного меню ---
const menuBtn = document.getElementById('mobile-menu-button');
const closeBtn = document.getElementById('mobile-menu-close-button');
const menu = document.getElementById('mobile-menu');
const overlay = document.getElementById('mobile-menu-overlay');
if (!menuBtn || !menu || !overlay) return;
const toggleMenu = (show) => {
if (show) {
menu.classList.remove('-translate-x-full');
overlay.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
} else {
menu.classList.add('-translate-x-full');
overlay.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
};
menuBtn.onclick = (e) => {
e.preventDefault();
const isClosed = menu.classList.contains('-translate-x-full');
toggleMenu(isClosed);
};
if (closeBtn) {
closeBtn.onclick = (e) => {
e.preventDefault();
toggleMenu(false);
};
}
overlay.onclick = () => {
toggleMenu(false);
};
const menuLinks = menu.querySelectorAll('a');
menuLinks.forEach(link => {
link.addEventListener('click', () => toggleMenu(false));
});
}
document.addEventListener('DOMContentLoaded', initHeaderLogic);
document.addEventListener('astro:page-load', initHeaderLogic);
</script>

View file

@ -0,0 +1,37 @@
---
import { createClient } from '@/lib/pocketbase';
const { width = 50, height = 30, className = '' } = Astro.props;
// Получаем данные из коллекции site_settings
const pb = createClient(Astro);
const records = await pb.collection('site_settings').getList(1, 1, {
sort: '-created' // Берём самую последнюю запись
});
const siteSettings = records.items[0] || {};
const logoPath = siteSettings.site_logo
? `${pb.baseURL}/api/files/site_settings/${siteSettings.id}/${siteSettings.site_logo}`
: null;
---
{logoPath ? (
<a href="/" aria-label="Zur Startseite wechseln">
<div class={`relative flex items-center justify-start ${className}`}>
<img
src={logoPath}
alt="MinivanBerlin Logo"
width={width}
height={height}
class="object-contain object-left max-w-full"
/>
</div>
</a>
) : (
// Можно добавить placeholder элемент, если логотип не установлен
<div class={`relative flex items-center justify-start ${className}`}>
<div class="text-gray-400 italic" style={`width: ${width}px; height: ${height}px; display: flex; align-items: center; justify-content: center;`}>
Logo
</div>
</div>
)}

View file

@ -0,0 +1,44 @@
import type { NavItem } from '@/types/types';
export const navItems: readonly NavItem[] = [
{
// главная страница
label: 'Startseite',
href: '/',
},
{
// премиум автопарк
label: 'Premium-Flotte',
href: '/premium-flotte',
},
{
// vip услуги
label: 'VIP-Services',
href: '/vip-services',
},
{
// Цены
label: 'Preise',
href: '/preise',
},
{
// Часто задаваемые вопросы
label: 'FAQ',
href: '/faq',
},
{
// Блог
label: 'Blog',
href: '/blog',
},
{
// О нас
label: 'Über Uns',
href: '/ueber-uns',
},
{
// Контакты
label: 'Kontakt',
href: '/kontakt',
},
] as const;

View file

@ -0,0 +1,41 @@
---
import Header from './Header.astro';
import TopHeader from './TopHeader.astro';
const { session = null, status = 'unauthenticated' } = Astro.props;
// Логика для акции
const dayOfMonth = new Date().getDate();
const isActionActive = dayOfMonth <= 7 || dayOfMonth >= 24;
---
<div id="site-header" class="sticky top-0 z-50 bg-[#ECF2F5]">
<TopHeader isActionActive={isActionActive} />
<Header {isActionActive} {session} {status} />
<div id="progress-bar" class="h-[3px] bg-blue-500 origin-left rounded-full scale-x-0"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const header = document.getElementById('site-header');
const progressBar = document.getElementById('progress-bar');
// Логика скролла
const handleScroll = () => {
const scrolled = window.scrollY > 10;
header.classList.toggle('shadow-sm', scrolled);
// Прогресс бар
const winHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY;
const scrollPercent = scrollTop / (docHeight - winHeight);
progressBar.style.transform = `scaleX(${scrollPercent})`;
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Инициализация
});
</script>

View file

@ -0,0 +1,83 @@
---
import Link from '@components/base/Link.astro';
import { authService } from '@/lib/authService';
const pb = authService.createClientFromRequest(Astro.request);
const [site, banner] = await Promise.all([
pb.collection('site_settings').getFirstListItem('').catch(() => null),
pb.collection('t_top_header').getFirstListItem('is_active = true').catch(() => null)
]) as [any, any];
---
<div id="top-header" class="border-b border-gray-200 bg-white">
<div class="max-w-7xl mx-auto px-6 py-1">
<div class="hidden md:flex items-center justify-between py-1.5">
<!-- Левая часть: Action Banner -->
<!-- Добавлены знаки '?' (banner?.show_action) для безопасности -->
<div class="flex items-center min-h-6">
{banner?.show_action && (
<div class="flex items-center animate-fade-in">
<div class="flex items-center justify-center w-3 h-3 rounded-full bg-red-600 mr-2 blinking-dot"></div>
<Link href={banner?.action_href} variant="action" class="text-sm">
{banner?.action_text}
</Link>
</div>
)}
</div>
<!-- Правая часть: Контакты -->
<div class="ml-auto flex items-center space-x-6">
{site?.contact_phone && (
<a
href={site?.contact_phone_href}
class="flex items-center space-x-2 text-sm text-gray-600 font-semibold hover:text-blue-700 transition-colors phone-link"
>
<span class="phone-icon">📞</span>
<span>{site?.contact_phone}</span>
</a>
)}
{site?.contact_email && (
<a
href={site?.contact_email_href}
class="flex items-center space-x-2 text-sm text-gray-600 font-semibold hover:text-blue-700 transition-colors email-link"
>
<span class="email-icon">✉️</span>
<span>{site?.contact_email}</span>
</a>
)}
</div>
</div>
</div>
</div>
<style>
.phone-link:hover .phone-icon { animation: shake 0.4s ease-in-out; }
.email-link:hover .email-icon { animation: bounce 0.4s ease-in-out; }
.blinking-dot { animation: blink 2s ease-in-out infinite; }
.animate-fade-in { animation: fadeIn 0.5s ease-in-out; }
@keyframes shake {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-12deg); }
75% { transform: rotate(12deg); }
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes blink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); }
50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(220, 38, 38, 0); }
70% { opacity: 1; box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View file

@ -0,0 +1,37 @@
---
const { user, size = 'md' } = Astro.props;
// Размеры аватара
const dimensions = size === 'lg' ? 64 : size === 'sm' ? 32 : 40; // md по умолчанию 40px
const textSize = size === 'lg' ? 'text-xl' : size === 'sm' ? 'text-xs' : 'text-sm';
const getInitials = (name, email) => {
if (name) {
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
}
if (email) {
return email.substring(0, 2).toUpperCase();
}
return 'UN';
};
const avatarUrl = user?.avatar
? `${import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090'}/api/files/${user.collectionId}/${user.id}/${user.avatar}?thumb=100x100`
: null;
const initials = getInitials(user?.name, user?.email);
---
<div class={`relative flex-shrink-0 rounded-full overflow-hidden border border-gray-200 shadow-sm bg-white flex items-center justify-center text-gray-600 font-bold ${textSize}`} style={`width: ${dimensions}px; height: ${dimensions}px;`}>
{avatarUrl ? (
<img
src={avatarUrl}
alt={user?.name || 'User'}
width={dimensions}
height={dimensions}
class="w-full h-full object-cover"
/>
) : (
<span>{initials}</span>
)}
</div>

View file

@ -0,0 +1,69 @@
---
import { authService } from '@/lib/authService';
// Типы данных
type BenefitItem = {
title: string;
description: string;
iconPath: string;
};
// Тип записи из PocketBase
type BenefitsRecord = {
headline_start: string;
headline_highlight: string;
description: string;
benefits_list: BenefitItem[];
};
// Инициализация клиента
const pb = authService.createClientFromRequest(Astro.request);
// Прямой запрос без проверок (предполагаем, что запись существует)
const record = await pb.collection('partner_benefits').getFirstListItem('') as unknown as BenefitsRecord;
// Достаем массив карточек
const benefits = record.benefits_list;
---
<section class="py-16 md:py-24 lg:py-32 bg-slate-900">
<div class="max-w-7xl mx-auto px-6 sm:px-8">
<!-- Section Header -->
<div class="text-center mb-16 md:mb-24">
<h2 class="text-4xl md:text-5xl font-bold text-white leading-tight">
{record.headline_start}
<span class="text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500">
{record.headline_highlight}
</span>
</h2>
<p class="mt-6 text-lg md:text-xl text-slate-400 max-w-3xl mx-auto leading-relaxed">
{record.description}
</p>
</div>
<!-- Benefits Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{benefits.map((item) => (
<div class="group h-full bg-slate-800/50 border border-slate-700 rounded-2xl p-8 text-center flex flex-col items-center transition-all duration-300 hover:border-amber-500/50 hover:bg-slate-800 hover:-translate-y-2 hover:shadow-[0_20px_40px_-15px_rgba(245,158,11,0.15)]">
<!-- Icon Container -->
<div class="mb-6 flex-shrink-0 w-16 h-16 rounded-full flex items-center justify-center bg-gradient-to-br from-amber-500 to-orange-600 shadow-lg shadow-orange-500/20 group-hover:scale-110 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.iconPath} />
</svg>
</div>
<!-- Content -->
<h3 class="text-xl font-bold text-white mb-3 group-hover:text-amber-400 transition-colors">
{item.title}
</h3>
<p class="text-slate-400 text-sm leading-relaxed flex-grow">
{item.description}
</p>
</div>
))}
</div>
</div>
</section>

View file

@ -0,0 +1,53 @@
---
import Button from '@components/base/Button.astro';
---
<!-- Glass CTA Section -->
<section
id="kontakt"
class="relative min-h-[80vh] md:min-h-screen flex items-center justify-center py-12 md:py-20 px-4 sm:px-6 scroll-mt-16"
>
<!-- Background Image -->
<div class="absolute inset-0 z-0">
<img
src="/images/partner/partner-cta-bg.avif"
alt="Partner werden"
class="object-cover w-full h-full"
/>
<div class="absolute inset-0 bg-black/60" />
</div>
<!-- Form Container -->
<div class="relative z-10 w-full max-w-md md:max-w-2xl mx-4">
<div class="bg-white/10 backdrop-blur-xl rounded-2xl p-6 md:p-12 border border-white/20 shadow-2xl">
<h2 class="text-3xl md:text-4xl font-bold text-white text-center mb-2">
Bereit, neue Maßstäbe zu setzen?
</h2>
<p class="text-slate-300 text-center mb-6 md:mb-8 text-sm md:text-base">
Kontaktieren Sie uns. Wir freuen uns auf eine starke Partnerschaft.
</p>
<form class="space-y-4">
<input
type="text"
placeholder="Name des Unternehmens"
class="w-full bg-white/10 border-white/20 border rounded-lg p-3 text-white placeholder-slate-400 focus:ring-2 focus:ring-amber-500 focus:outline-none transition-all text-sm md:text-base"
/>
<input
type="email"
placeholder="Ihre E-Mail-Adresse"
class="w-full bg-white/10 border-white/20 border rounded-lg p-3 text-white placeholder-slate-400 focus:ring-2 focus:ring-amber-500 focus:outline-none transition-all text-sm md:text-base"
/>
<textarea
rows={4}
placeholder="Ihre Nachricht"
class="w-full bg-white/10 border-white/20 border rounded-lg p-3 text-white placeholder-slate-400 focus:ring-2 focus:ring-amber-500 focus:outline-none transition-all text-sm md:text-base"
></textarea>
<Button type="submit" variant="primary" fullWidth size="md">
Anfrage senden
</Button>
</form>
</div>
</div>
</section>

View file

@ -0,0 +1,9 @@
---
---
<div class="bg-white rounded-xl shadow-md p-8">
<h2 class="text-2xl font-semibold mb-6">Interesse an einer Partnerschaft?</h2>
<p>
Wenn Sie Interesse haben, eine Partnerschaft mit uns einzugehen, kontaktieren Sie uns gerne. Wir freuen uns darauf, mit Ihnen zusammenzuarbeiten.
</p>
</div>

View file

@ -0,0 +1,134 @@
---
import { authService } from '@/lib/authService';
// Инициализация клиента PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// Получение данных для героя из коллекции partner_hero
const heroRecord = await pb.collection('partner_hero').getFirstListItem('is_active = true');
// Генерация прямой ссылки на изображение героя
const heroImageUrl = pb.files.getURL(heroRecord, heroRecord.image);
---
<section class="relative w-full bg-slate-900 overflow-hidden py-12 md:py-20 lg:py-24">
<!-- Декоративный фоновый элемент (опционально) -->
<div class="absolute top-0 right-0 w-1/3 h-full bg-amber-500/5 skew-x-12 translate-x-1/2 pointer-events-none"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<!-- Левая колонка: Контент -->
<div class="order-2 lg:order-1 text-center lg:text-left">
<div class="space-y-6 md:space-y-8 max-w-2xl mx-auto lg:mx-0">
<!-- Заголовок -->
<div class="overflow-hidden">
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight text-white leading-[1.1]">
<span class="block opacity-0 animate-slideInUp">{heroRecord?.title || 'Gemeinsam auf der'}</span>
<span class="block text-amber-400 opacity-0 animate-slideInUp delay-300">{heroRecord?.subtitle || 'Überholspur.'}</span>
</h1>
</div>
<!-- Разделитель -->
<div class="h-1.5 bg-amber-500 w-20 mx-auto lg:mx-0 rounded-full opacity-0 animate-scaleIn delay-500 origin-left"></div>
<!-- Описание -->
<div class="overflow-hidden">
<p class="text-base sm:text-lg md:text-xl text-slate-300 font-light leading-relaxed opacity-0 animate-slideInUp delay-700">
{heroRecord?.description || 'Schaffen Sie mit uns einen unvergesslichen Mehrwert für Ihre Kunden. Eine Partnerschaft, die Exzellenz neu definiert.'}
</p>
</div>
<!-- Кнопка -->
<div class="pt-4 overflow-hidden">
<a href="{heroRecord?.cta_link || '#kontakt'}"
class="group inline-flex items-center gap-3 bg-amber-500 text-slate-900 font-bold py-4 px-8 rounded-xl text-base md:text-lg transition-all duration-300 shadow-xl shadow-amber-500/10 hover:shadow-amber-500/30 opacity-0 animate-slideInUp delay-900 hover:-translate-y-1 active:scale-95">
{heroRecord?.cta_text || 'Partnerschaft starten'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
</div>
</div>
<!-- Правая колонка: Картинка -->
<div class="order-1 lg:order-2">
<div class="relative group mx-auto max-w-[550px] lg:max-w-none">
<!-- Декоративное свечение вокруг картинки -->
<div class="absolute -inset-4 bg-amber-500/10 rounded-[2.5rem] blur-2xl opacity-0 animate-fadeIn delay-1000 group-hover:bg-amber-500/20 transition-duration-500"></div>
<!-- Контейнер картинки -->
<div class="relative aspect-video lg:aspect-square overflow-hidden rounded-[2rem] shadow-2xl border border-white/5">
<img
src={heroImageUrl}
alt={heroRecord?.img_alt || 'Luxuriöser Fahrzeuginnenraum'}
class="absolute inset-0 w-full h-full object-cover object-center scale-105 group-hover:scale-110 transition-transform duration-700 opacity-0 animate-imageReveal delay-500"
/>
<!-- Оверлей для глубины -->
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/60 via-transparent to-transparent opacity-60"></div>
</div>
<!-- Маленький декоративный элемент "бейджик" (опционально) -->
<div class="absolute -bottom-6 -left-6 bg-slate-800 border border-slate-700 p-4 rounded-2xl shadow-xl hidden md:block opacity-0 animate-slideInUp delay-1000">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-amber-500/20 rounded-lg flex items-center justify-center">
<span class="text-amber-500 font-bold">5★</span>
</div>
<div class="text-sm">
<p class="text-white font-semibold">Premium Service</p>
<p class="text-slate-400 text-xs">Exzellenz garantiert</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
@keyframes slideInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scaleX(0); }
to { opacity: 1; transform: scaleX(1); }
}
@keyframes imageReveal {
0% { opacity: 0; transform: scale(1.15); }
100% { opacity: 1; transform: scale(1.05); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-slideInUp { animation: slideInUp 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; }
.animate-scaleIn { animation: scaleIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; }
.animate-imageReveal { animation: imageReveal 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; }
.animate-fadeIn { animation: fadeIn 0.8s ease-out forwards; }
.delay-300 { animation-delay: 0.3s; }
.delay-500 { animation-delay: 0.5s; }
.delay-600 { animation-delay: 0.6s; }
.delay-700 { animation-delay: 0.7s; }
.delay-800 { animation-delay: 0.8s; }
.delay-900 { animation-delay: 0.9s; }
.delay-1000 { animation-delay: 1s; }
a:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgba(245, 158, 11, 0.4), 0 10px 10px -5px rgba(245, 158, 11, 0.1); }
@media (prefers-reduced-motion: reduce) {
.animate-slideInUp, .animate-scaleIn, .animate-imageReveal, .animate-fadeIn {
animation: none; opacity: 1; transform: none;
}
.delay-300, .delay-500, .delay-600, .delay-700, .delay-800, .delay-900, .delay-1000 {
animation-delay: 0s;
}
}
</style>

View file

@ -0,0 +1,192 @@
<section class="py-16 md:py-32 bg-slate-900 text-white overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6">
<div class="text-center mb-12 md:mb-16">
<h2 class="text-3xl md:text-5xl font-bold">
Der Weg zur Win-Win-Situation.
</h2>
<div class="w-16 h-1 bg-amber-500 mx-auto mt-4 md:mt-6"></div>
</div>
<div class="flex flex-col lg:grid lg:grid-cols-2 gap-8 md:gap-12 items-center">
<!-- Steps Selection -->
<div class="flex flex-col gap-4 md:gap-6 w-full max-w-md mx-auto lg:max-w-none">
<!-- Step 1 -->
<div
class="step-item relative p-5 md:p-6 rounded-xl border-2 cursor-pointer transition-all duration-300 w-full border-slate-700 bg-slate-800/50 hover:border-amber-500 hover:bg-slate-800 hover:shadow-lg"
data-step="1"
>
<h3 class="text-xl md:text-2xl font-bold">
1. Anfragen
</h3>
<div class="step-underline h-1 w-10 md:w-12 bg-amber-500 mt-2 opacity-0 transition-opacity duration-300"></div>
</div>
<!-- Step 2 -->
<div
class="step-item relative p-5 md:p-6 rounded-xl border-2 cursor-pointer transition-all duration-300 w-full border-slate-700 bg-slate-800/50 hover:border-amber-500 hover:bg-slate-800 hover:shadow-lg"
data-step="2"
>
<h3 class="text-xl md:text-2xl font-bold">
2. Abstimmen
</h3>
<div class="step-underline h-1 w-10 md:w-12 bg-amber-500 mt-2 opacity-0 transition-opacity duration-300"></div>
</div>
<!-- Step 3 -->
<div
class="step-item relative p-5 md:p-6 rounded-xl border-2 cursor-pointer transition-all duration-300 w-full border-slate-700 bg-slate-800/50 hover:border-amber-500 hover:bg-slate-800 hover:shadow-lg"
data-step="3"
>
<h3 class="text-xl md:text-2xl font-bold">
3. Profitieren
</h3>
<div class="step-underline h-1 w-10 md:w-12 bg-amber-500 mt-2 opacity-0 transition-opacity duration-300"></div>
</div>
</div>
<!-- Content Display -->
<div class="relative h-64 md:h-96 w-full rounded-2xl overflow-hidden bg-slate-800 mt-6 md:mt-0">
<img
src="/images/partner/step01.avif"
alt="Anfragen Schritt"
class="step-content absolute inset-0 w-full h-full object-cover transition-opacity duration-500"
data-step="1"
/>
<img
src="/images/partner/step02.avif"
alt="Abstimmen Schritt"
class="step-content absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-0"
data-step="2"
/>
<img
src="/images/partner/step03.avif"
alt="Profitieren Schritt"
class="step-content absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-0"
data-step="3"
/>
<div class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/70 via-black/50 to-transparent p-6 md:p-8">
<p class="step-text text-base md:text-xl leading-relaxed text-slate-100 transition-opacity duration-300" data-step="1">
Ein kurzes Gespräch, um Ihre Wünsche und die Bedürfnisse Ihrer Kunden zu verstehen.
</p>
<p class="step-text text-base md:text-xl leading-relaxed text-slate-100 transition-opacity duration-300 opacity-0 absolute" data-step="2">
Wir erstellen ein individuelles Partnerpaket mit maßgeschneiderten Konditionen und Prozessen.
</p>
<p class="step-text text-base md:text-xl leading-relaxed text-slate-100 transition-opacity duration-300 opacity-0 absolute" data-step="3">
Bieten Sie Ihren Kunden unseren Service an und profitieren Sie von zufriedenen Kunden und attraktiven Provisionen.
</p>
</div>
</div>
</div>
</div>
</section>
<style>
.step-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.step-item:hover {
transform: translateY(-2px);
}
.step-content {
transition: opacity 0.5s ease-in-out;
}
.step-text {
transition: opacity 0.3s ease-in-out;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const stepItems = document.querySelectorAll('.step-item');
const stepContents = document.querySelectorAll('.step-content');
const stepTexts = document.querySelectorAll('.step-text');
let activeStep = null;
// Функция для сброса всех активных состояний
function resetActiveStates() {
stepItems.forEach(item => {
item.classList.remove('border-amber-500', 'bg-slate-800');
item.classList.add('border-slate-700', 'bg-slate-800/50');
});
stepContents.forEach(content => {
content.classList.add('opacity-0');
});
stepTexts.forEach(text => {
text.classList.add('opacity-0');
});
// Скрываем все подчеркивания
document.querySelectorAll('.step-underline').forEach(underline => {
underline.style.opacity = '0';
});
}
// Функция для активации шага
function activateStep(stepNumber) {
resetActiveStates();
// Активируем выбранный шаг
const activeItem = document.querySelector(`[data-step="${stepNumber}"]`);
const activeContent = document.querySelector(`.step-content[data-step="${stepNumber}"]`);
const activeText = document.querySelector(`.step-text[data-step="${stepNumber}"]`);
const activeUnderline = activeItem.querySelector('.step-underline');
if (activeItem && activeContent && activeText) {
activeItem.classList.remove('border-slate-700', 'bg-slate-800/50');
activeItem.classList.add('border-amber-500', 'bg-slate-800');
activeContent.classList.remove('opacity-0');
activeText.classList.remove('opacity-0');
if (activeUnderline) {
activeUnderline.style.opacity = '1';
}
}
activeStep = stepNumber;
}
// Обработчики событий для каждого шага
stepItems.forEach(item => {
const stepNumber = item.getAttribute('data-step');
// Клик - постоянная активация
item.addEventListener('click', () => {
activateStep(stepNumber);
});
// Наведение - временный эффект
item.addEventListener('mouseenter', () => {
if (activeStep !== stepNumber) {
item.classList.add('border-amber-500', 'bg-slate-800', 'shadow-lg');
item.classList.remove('border-slate-700', 'bg-slate-800/50');
}
});
item.addEventListener('mouseleave', () => {
if (activeStep !== stepNumber) {
item.classList.remove('border-amber-500', 'bg-slate-800', 'shadow-lg');
item.classList.add('border-slate-700', 'bg-slate-800/50');
}
});
});
// По умолчанию показываем первый шаг, но без визуального выделения
// Контент первого шага виден, но сам пункт не выделен
const firstContent = document.querySelector('.step-content[data-step="1"]');
const firstText = document.querySelector('.step-text[data-step="1"]');
if (firstContent && firstText) {
firstContent.classList.remove('opacity-0');
firstText.classList.remove('opacity-0');
}
});
</script>

View file

@ -0,0 +1,9 @@
---
---
<div class="bg-white rounded-xl shadow-md p-8 mb-8">
<h2 class="text-2xl font-semibold mb-6">Über unsere Partnerschaften</h2>
<p class="mb-4">
Bei Berlin Minivan Transfers glauben wir an die Kraft von Partnerschaften. Wir arbeiten mit verschiedenen Unternehmen und Organisationen zusammen, um unseren Kunden den bestmöglichen Service zu bieten.
</p>
</div>

View file

@ -0,0 +1,9 @@
---
---
<div class="bg-white rounded-xl shadow-md p-8 mb-8">
<h2 class="text-2xl font-semibold mb-6">Unsere Dienstleistungen durch Partnerschaften</h2>
<p class="mb-4">
Unsere Partnerschaften ermöglichen es uns, ein breiteres Spektrum an Dienstleistungen anzubieten, von Flughafentransfers über Geschäftstermine bis hin zu besonderen Anlässen. Gemeinsam schaffen wir nahtlose Reiseerlebnisse für unsere Kunden in Berlin und Umgebung.
</p>
</div>

View file

@ -0,0 +1,104 @@
---
const audiences = [
{
title: "Luxushotels & Boutique-Hotels",
description: "Bieten Sie Ihren Gästen einen nahtlosen Premium-Transferservice direkt vom Check-in.",
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
color: "amber",
staggerClass: "sm:mt-8 md:mt-12"
},
{
title: "Unternehmen & Konzerne",
description: "Sorgen Sie für einen erstklassigen Transport Ihrer Führungskräfte, Kunden und Geschäftspartner.",
icon: "M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
color: "slate",
staggerClass: ""
},
{
title: "Event- & Hochzeitsagenturen",
description: "Garantieren Sie einen stilvollen und zuverlässigen Shuttleservice für Ihre exklusiven Veranstaltungen.",
icon: "M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7",
color: "rose",
staggerClass: ""
},
{
title: "Premium-Reiseveranstalter",
description: "Integrieren Sie einen Chauffeurdienst, der den hohen Erwartungen Ihrer anspruchsvollen Klientel gerecht wird.",
icon: "M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z",
color: "sky",
staggerClass: "sm:mt-8 md:mt-12"
}
];
// Маппинг цветов для соответствия вашему дизайну
const cardStyles = {
amber: { border: "border-amber-200", iconBg: "from-amber-50 to-amber-100", iconText: "text-amber-600", hoverBg: "from-amber-50 to-amber-100" },
slate: { border: "border-slate-200", iconBg: "from-slate-50 to-slate-100", iconText: "text-slate-600", hoverBg: "from-slate-50 to-slate-100" },
rose: { border: "border-rose-200", iconBg: "from-rose-50 to-rose-100", iconText: "text-rose-600", hoverBg: "from-rose-50 to-rose-100" },
sky: { border: "border-sky-200", iconBg: "from-sky-50 to-sky-100", iconText: "text-sky-600", hoverBg: "from-sky-50 to-sky-100" }
};
---
<section class="py-24 md:py-40 bg-white overflow-hidden">
<div class="max-w-7xl mx-auto px-6 sm:px-8 flex flex-col lg:grid lg:grid-cols-2 gap-20 md:gap-24 items-center">
<!-- Левая часть: Текст -->
<div class="text-center lg:text-left space-y-8">
<h2 class="text-4xl md:text-5xl lg:text-[3.5rem] font-bold text-gray-900 leading-tight tracking-tight">
Für Partner, die Exzellenz{' '}
<span class="relative inline-block">
<span class="relative z-10">erwarten</span>
<span class="absolute bottom-2 left-0 h-3 bg-amber-400/30 -rotate-1 -z-0 w-full"></span>
</span>
.
</h2>
<p class="text-lg md:text-xl text-gray-600 leading-relaxed max-w-xl mx-auto lg:mx-0">
Wir arbeiten mit Unternehmen zusammen, die wie wir den höchsten
Anspruch an Qualität, Diskretion und Service haben.
</p>
<div class="relative inline-block">
<div class="w-24 h-1.5 bg-amber-500 rounded-full mx-auto lg:mx-0" />
<div class="absolute inset-0 w-24 h-1.5 bg-amber-400/30 rounded-full mt-1" />
</div>
</div>
<!-- Правая часть: Сетка карточек -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 sm:gap-7 w-full max-w-2xl mx-auto">
{audiences.map((item) => {
const style = cardStyles[item.color];
return (
<div class={`group relative rounded-xl md:rounded-2xl p-6 md:p-8 flex flex-col items-start border bg-white shadow-sm hover:shadow-md hover:-translate-y-2 transition-all duration-300 ${style.border} ${item.staggerClass}`}>
<!-- Эффект при наведении (как в оригинале) -->
<div class="absolute inset-0 rounded-xl md:rounded-2xl overflow-hidden -z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class={`absolute inset-0 bg-gradient-to-br opacity-30 ${style.hoverBg}`} />
</div>
<!-- Иконка -->
<div class={`w-14 h-14 md:w-16 md:h-16 rounded-xl mb-5 flex items-center justify-center bg-gradient-to-br shadow-inner ${style.iconBg} ${style.iconText}`}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
</div>
<h3 class="font-bold text-lg md:text-xl text-gray-800 mb-3 leading-snug">
{item.title}
</h3>
<p class="text-sm md:text-base text-gray-600 leading-relaxed">
{item.description}
</p>
<!-- Футер карточки -->
<div class="mt-5 pt-4 border-t border-gray-100 w-full group-hover:border-transparent transition-colors duration-300">
<span class="text-xs font-medium text-gray-400 group-hover:text-gray-500 transition-colors duration-300">
Mehr erfahren →
</span>
</div>
</div>
);
})}
</div>
</div>
</section>

View file

@ -0,0 +1,40 @@
---
// No props needed for static version
---
<section class="relative bg-gray-900 overflow-hidden">
<div class="absolute inset-0 z-0">
<img
src="/images/payment/payment_hero.avif"
alt="Premium Minivan Transfer in Berlin"
class="w-full h-full object-cover opacity-60"
loading="eager"
/>
</div>
<div class="relative z-10 py-48 px-4 sm:px-6 lg:px-8 text-center animate-fade-in-up">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6">
Premium Transfer Service
</h1>
<p class="text-xl md:text-2xl text-amber-200 max-w-3xl mx-auto">
Sichere und bequeme Bezahlung für Ihren Transfer in Berlin
</p>
</div>
</section>
<style>
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
}
</style>

View file

@ -0,0 +1,79 @@
---
import type { OrderItem } from '@/data/paymentData';
export interface Props {
items: readonly OrderItem[];
shipping: number;
subtotal: number;
total: number;
}
const { items, shipping, subtotal, total } = Astro.props;
---
<div class="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 sticky top-20 animate-slide-in-right">
<h2 class="text-xl font-bold text-gray-800 border-b pb-4 mb-4">
Ihre Bestellung
</h2>
<div class="space-y-4">
{items.map((item) => (
<div key={item.id} class="flex items-center gap-4">
<div class="w-16 h-16 rounded-lg overflow-hidden bg-gray-100 flex-shrink-0">
<img
src={item.imageUrl}
alt={item.name}
width={64}
height={64}
class="object-cover w-full h-full"
/>
</div>
<div class="flex-grow">
<p class="font-semibold text-gray-800">{item.name}</p>
<p class="text-sm text-gray-500">{item.details}</p>
</div>
<div class="text-right flex-shrink-0">
<p class="font-semibold text-gray-800">
{(item.price * item.quantity).toFixed(2)} €
</p>
<p class="text-sm text-gray-500">Menge: {item.quantity}</p>
</div>
</div>
))}
</div>
<div class="mt-6 pt-6 border-t border-gray-200 space-y-2 text-sm">
<div class="flex justify-between text-gray-600">
<span>Zwischensumme:</span>
<span>{subtotal.toFixed(2)} €</span>
</div>
<div class="flex justify-between text-gray-600">
<span>Versand:</span>
<span>{shipping.toFixed(2)} €</span>
</div>
</div>
<div class="mt-4 pt-4 border-t-2 border-dashed border-gray-200">
<div class="flex justify-between items-center text-lg font-bold text-gray-900">
<span>Gesamtsumme:</span>
<span class="text-2xl text-amber-600">{total.toFixed(2)} €</span>
</div>
</div>
</div>
<style>
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.8s ease-out forwards;
}
</style>

View file

@ -0,0 +1,210 @@
---
import Button from '@components/base/Button.astro';
import type { PaymentMethod } from '@/data/paymentData';
export interface Props {
total: number;
paymentMethods: readonly PaymentMethod[];
}
const { total, paymentMethods } = Astro.props;
---
<div class="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 animate-slide-in-left">
<!-- Payment method tabs -->
<div class="flex border-b border-gray-200 mb-6">
{paymentMethods.map((method) => (
<button
data-tab={method.id}
class={`flex-1 py-3 text-sm md:text-base font-medium transition-colors border-b-2 ${
method.id === 'card'
? 'text-amber-600 border-amber-500'
: 'text-gray-500 border-transparent hover:text-gray-800'
}`}
>
{method.name}
</button>
))}
</div>
<!-- Tab content -->
<div>
<!-- Card Payment -->
<div
id="tab-card"
data-tab-content
class="tab-content space-y-4"
>
<div>
<label class="text-sm font-medium text-gray-700">
Kartennummer
</label>
<div class="relative mt-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path>
</svg>
<input
type="text"
placeholder="0000 0000 0000 0000"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-amber-500 focus:border-amber-500"
/>
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700">
Name auf der Karte
</label>
<input
type="text"
placeholder="Max Mustermann"
class="w-full mt-1 py-2 px-3 border border-gray-300 rounded-lg focus:ring-amber-500 focus:border-amber-500"
/>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="text-sm font-medium text-gray-700">
Gültig bis (MM/JJ)
</label>
<input
type="text"
placeholder="MM/JJ"
class="w-full mt-1 py-2 px-3 border border-gray-300 rounded-lg focus:ring-amber-500 focus:border-amber-500"
/>
</div>
<div class="flex-1">
<label class="text-sm font-medium text-gray-700">
CVV
</label>
<div class="relative mt-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" 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>
<input
type="text"
placeholder="123"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-amber-500 focus:border-amber-500"
/>
</div>
</div>
</div>
</div>
<!-- PayPal Payment -->
<div
id="tab-paypal"
data-tab-content
class="tab-content text-center py-8 hidden"
>
<p class="text-gray-600 mb-4">
Sie werden zu PayPal weitergeleitet, um die Zahlung abzuschließen.
</p>
<Button
variant="secondary"
fullWidth={true}
class="bg-blue-600 hover:bg-blue-700 text-white py-3 px-4 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.5 14.25c0 .414.336.75.75.75h3.75c.414 0 .75-.336.75-.75s-.336-.75-.75-.75H8.25c-.414 0-.75.336-.75.75zm0-3c0 .414.336.75.75.75h6.75c.414 0 .75-.336.75-.75s-.336-.75-.75-.75H8.25c-.414 0-.75.336-.75.75z"/>
<path d="M21.25 4.5H2.75A2.75 2.75 0 000 7.25v9.5A2.75 2.75 0 002.75 19.5h18.5A2.75 2.75 0 0024 16.75v-9.5A2.75 2.75 0 0021.25 4.5zM22.5 16.75c0 .69-.56 1.25-1.25 1.25H2.75c-.69 0-1.25-.56-1.25-1.25v-9.5c0-.69.56-1.25 1.25-1.25h18.5c.69 0 1.25.56 1.25 1.25v9.5z"/>
</svg>
Mit PayPal bezahlen
</Button>
</div>
</div>
<!-- Payment button and security badges -->
<div class="mt-8">
<Button
variant="primary"
fullWidth={true}
size="lg"
>
Jetzt bezahlen ({total.toFixed(2)} €)
</Button>
<!-- SecurityBadges component -->
<div class="mt-6 text-center">
<div class="flex items-center justify-center gap-2 text-sm text-green-700 mb-4">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
</svg>
<span>SSL-verschlüsselte & sichere Zahlung</span>
</div>
<div class="flex justify-center items-center space-x-5 opacity-70">
<img
src="/images/payment/visa.svg"
alt="Visa"
width={42}
height={24}
/>
<img
src="/images/payment/mastercard.svg"
alt="Mastercard"
width={42}
height={24}
/>
<img
src="/images/payment/amex.svg"
alt="American Express"
width={42}
height={24}
/>
<img
src="/images/payment/paypal.svg"
alt="PayPal"
width={50}
height={24}
/>
</div>
</div>
</div>
</div>
<style>
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slide-in-left 0.8s ease-out forwards;
}
.tab-content {
transition: opacity 0.3s ease;
}
</style>
<script>
// Tab switching functionality
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('[data-tab]');
const tabContents = document.querySelectorAll('[data-tab-content]');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
// Update active tab button
tabButtons.forEach(btn => {
btn.classList.remove('text-amber-600', 'border-amber-500');
btn.classList.add('text-gray-500', 'border-transparent');
});
this.classList.add('text-amber-600', 'border-amber-500');
this.classList.remove('text-gray-500', 'border-transparent');
// Show target tab content
tabContents.forEach(content => {
content.classList.add('hidden');
});
document.getElementById(`tab-${targetTab}`).classList.remove('hidden');
});
});
});
</script>

View file

@ -0,0 +1,47 @@
export interface OrderItem {
id: number;
name: string;
details: string;
price: number;
quantity: number;
imageUrl: string;
}
export interface PaymentMethod {
id: string;
name: string;
}
// Пример данных для сводки заказа
export const sampleOrderItems: OrderItem[] = [
{
id: 1,
name: 'Flughafentransfer',
details: 'Berlin (BER) nach Stadtzentrum',
price: 85.0,
quantity: 1,
imageUrl: '/images/services/vip_hero.avif',
},
{
id: 2,
name: 'Wartezeit (extra)',
details: 'Zusätzliche 30 Minuten',
price: 30.0,
quantity: 1,
imageUrl: '/images/services/concierge.avif',
},
];
// Способы оплаты для табов
export const paymentMethods: PaymentMethod[] = [
{
id: 'card',
name: 'Kreditkarte',
},
{
id: 'paypal',
name: 'PayPal',
},
];
export const shipping = 5.99;

View file

@ -0,0 +1,94 @@
---
import type { CtaSectionProps } from '@/types/globalInterfaces';
const {
title = "Buchen Sie jetzt Ihren Transfer",
subtitle = "Schnell, sicher und ohne Stress wir bringen Sie pünktlich ans Ziel.",
buttonText = "Sofortanfrage stellen →",
href = "/kontakt"
} = Astro.props as CtaSectionProps;
---
<!-- CTA -->
<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 mb-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">
{title}
</h2>
<p class="text-lg mb-8 max-w-2xl mx-auto opacity-90">
{subtitle}
</p>
<a
href={href}
class="cta-button inline-block bg-white text-blue-700 font-semibold px-8 py-4 rounded-xl shadow-lg transition-all duration-300"
>
{buttonText}
</a>
</div>
</section>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@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;
}
.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,59 @@
---
const { title, subtitle, imageUrl, altText } = Astro.props;
---
<!-- Hero Section -->
<section class="relative h-[70vh] min-h-[500px] w-full overflow-hidden">
<div class="absolute inset-0 hero-bg-container">
<img
id="hero-image"
src={imageUrl}
alt={altText}
width="1920"
height="800"
class="w-full h-full object-cover"
loading="eager"
/>
<div class="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"></div>
</div>
<div class="relative z-10 h-full flex items-center justify-center text-center px-4">
<div class="max-w-4xl">
<h2 id="hero-title" class="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight animate-fade-up">
{title}
</h2>
<p id="hero-subtitle" class="text-xl md:text-2xl text-gray-200 max-w-3xl mx-auto leading-relaxed animate-fade-up" style="animation-delay: 0.4s">
{subtitle}
</p>
</div>
</div>
</section>
<style>
/* Контейнер для фонового изображения */
.hero-bg-container img {
opacity: 0;
animation: fadeInBackground 1.5s ease-out forwards;
animation-delay: 0.2s;
}
/* Анимация появления фона */
@keyframes fadeInBackground {
from { opacity: 0; }
to { opacity: 1; }
}
/* Анимация появления текста */
.animate-fade-up {
opacity: 0;
transform: translateY(20px);
animation: fadeUp 0.8s ease-out forwards;
}
@keyframes fadeUp {
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,56 @@
---
import HeroContent from './HeroContent.astro';
import { authService } from '@/lib/authService';
// Инициализация PocketBase
const pb = authService.createClientFromRequest(Astro.request);
// 1. Получаем запись из коллекции напрямую.
// Если записи нет, Astro выбросит ошибку (это гарантирует отсутствие статических заглушек)
const record = await pb.collection('preise_hero').getFirstListItem('');
// 2. Генерируем прямую ссылку на картинку
const imageUrl = pb.files.getURL(record, record.image);
// 3. Формируем объект данных
const heroData = {
title: record.title,
subtitle: record.subtitle,
imageUrl: imageUrl,
altText: record.alt_text
};
---
<!--
Анимация появления блока.
Убран client:visible, так как данные уже получены на сервере.
-->
<div class="opacity-0 animate-fadeInUp delay-200">
<HeroContent
title={heroData.title}
subtitle={heroData.subtitle}
imageUrl={heroData.imageUrl}
altText={heroData.altText}
/>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s ease-out forwards;
}
.delay-200 {
animation-delay: 0.2s;
}
</style>

View file

@ -0,0 +1,33 @@
---
import CarsSlider from '@components/home/slider/CarsSlider';
import type { PriceProps } from '@/types/globalInterfaces';
const { cars = [], title = "Verfügbare Fahrzeuge", subtitle = "Wählen Sie die perfekte Option für Ihren Transfer" } = Astro.props as PriceProps;
---
<div>
<div class="opacity-0 animate-fadeInUp delay-500">
<CarsSlider cars={cars} client:visible />
</div>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s ease-out forwards;
}
.delay-500 {
animation-delay: 0.5s;
}
</style>

View file

@ -0,0 +1,103 @@
---
import type { PriceService, ServicesSectionProps } from '@/types/globalInterfaces';
const { services = [], title = "Unsere Fahrzeuge für jeden Anlass" } = Astro.props as ServicesSectionProps;
---
<!-- Services -->
<section>
<h2 class="text-2xl sm:text-3xl font-semibold text-center text-gray-800 mb-8 opacity-0 animate-fadeInUp delay-200">
{title}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- Уменьшенный отступ между карточками -->
{services.map((service, index) => (
<div
class="service-card bg-white rounded-2xl shadow-lg overflow-hidden transition-all duration-300 border border-gray-100 opacity-0 animate-cardEntry"
style={`animation-delay: ${index * 0.1}s`}
>
<div class="p-6">
<div class="flex items-start gap-4 mb-3">
<div class="flex-shrink-0 bg-blue-50 p-3 rounded-full text-blue-600 text-xl">
{service.icon}
</div>
<div>
<h3 class="text-xl font-semibold text-gray-800">
{service.title}
</h3>
<p class="text-gray-600 mt-1">
{service.description}
</p>
</div>
</div>
<ul class="space-y-2 mt-3 pl-1">
{service.features?.map((feature, i) => (
<li
class="flex items-center text-sm text-gray-700 opacity-0 animate-featureEntry"
style={`animation-delay: ${0.2 + (i * 0.1)}s`}
>
<span class="w-1.5 h-1.5 bg-blue-600 rounded-full mr-2 flex-shrink-0"></span>
{feature}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</section>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardEntry {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes featureEntry {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s ease-out forwards;
}
.animate-cardEntry {
animation: cardEntry 0.6s ease-out forwards;
}
.animate-featureEntry {
animation: featureEntry 0.4s ease-out forwards;
}
.delay-200 {
animation-delay: 0.2s;
}
.service-card:hover {
transform: translateY(-6px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,126 @@
---
import { authService } from '@/lib/authService';
// Получаем данные особенностей из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
const features = await pb.collection('premium_features').getFullList({
sort: 'order',
filter: 'is_active = true'
});
// Стандартные иконки для особенностей (не хранятся в базе)
const featureIcons = {
'Tadellose Sauberkeit': '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', // Sparkles imitation
'Höchste Sicherheit': 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', // Shield
'Professionelle Chauffeure': 'M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2 M8.5 11a4 4 0 100-8 4 4 0 000 8z M20 8v6M23 11h-6', // UserCheck imitation
'Absolute Pünktlichkeit': 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' // Clock
};
// Добавляем иконки к полученным данным
const displayFeatures = features.map(feature => ({
...feature,
icon_path: featureIcons[feature.title] || 'M4.5 12.74L14.26 3 16 4.74 5.24 15.5 4.5 12.74zM18 19.5h-15v-15h15v15zM13 12.5H11V8h2v4.5zm0 3.5H11v-2h2v2z' // Default icon
})).sort((a, b) => a.order - b.order);
// Если нет данных из API, используем заглушку
const fallbackFeatures = [
{
title: 'Tadellose Sauberkeit',
description: 'Jedes Fahrzeug wird vor der Fahrt professionell gereinigt und desinfiziert.',
icon_path: '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',
order: 1
},
{
title: 'Höchste Sicherheit',
description: 'Unsere Flotte ist mit modernsten Sicherheitssystemen ausgestattet und wird regelmäßig gewartet.',
icon_path: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
order: 2
},
{
title: 'Professionelle Chauffeure',
description: 'Unsere Fahrer sind diskret, ortskundig und auf exzellenten Service geschult.',
icon_path: 'M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2 M8.5 11a4 4 0 100-8 4 4 0 000 8z M20 8v6M23 11h-6',
order: 3
},
{
title: 'Absolute Pünktlichkeit',
description: 'Wir garantieren eine pünktliche Abholung, damit Sie Ihre Termine stressfrei erreichen.',
icon_path: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
order: 4
}
];
const finalFeatures = displayFeatures.length > 0 ? displayFeatures : fallbackFeatures;
---
<section class="bg-gradient-to-r from-blue-50 to-indigo-50 py-20 md:py-28">
<div class="max-w-7xl mx-auto px-6">
<div class="text-center mb-16 fade-up observer-target">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
Was Sie in jedem Fahrzeug erwartet
</h2>
<p class="mt-3 max-w-2xl mx-auto text-lg text-gray-600">
Unser Qualitätsversprechen gilt für die gesamte Flotte.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{finalFeatures.map((feature, index) => (
<div
class="feature-card bg-white rounded-2xl p-8 shadow-md hover:shadow-lg border border-gray-100 fade-up observer-target"
style={`--delay: ${index * 150}ms`}
>
<div class="flex items-center justify-center h-16 w-16 rounded-full bg-gradient-to-r from-blue-100 to-indigo-100 text-blue-600 mx-auto mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={feature.icon_path} />
</svg>
</div>
<h3 class="text-lg font-bold text-gray-800 mb-3">
{feature.title}
</h3>
<p class="text-gray-600">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.observer-target').forEach((el) => {
observer.observe(el);
});
</script>
<style>
.fade-up {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
/* Используем переменную для задержки */
transition-delay: var(--delay, 0ms);
}
.fade-up.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Hover эффект для карточки */
.feature-card {
transition: transform 0.3s ease, box-shadow 0.3s ease, opacity 0.6s, transform 0.6s; /* Объединяем транзишны */
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,98 @@
import { createSignal, onMount, onCleanup, type Component } from 'solid-js';
import UniversalBookingForm from '../booking/UniversalBookingForm';
import type { HeroContent } from '@components/home/hero/HeroContent';
import type { Car } from '../home/slider/SliderContent';
interface FleetBookingWrapperProps {
content: string | HeroContent; // Принимаем как строку, так и объект
}
export const FleetBookingWrapper: Component<FleetBookingWrapperProps> = (props) => {
// Парсим контент, если он передан в виде строки
let content: HeroContent;
try {
content = typeof props.content === 'string' ? JSON.parse(props.content) : props.content;
} catch (error) {
console.error('Error parsing content in FleetBookingWrapper:', error);
// В случае ошибки парсинга используем пустой объект или стандартный контент
content = {
title: 'Minivan & Privat-Transfers in Berlin',
description: 'Bequeme und zuverlässige Transfers zum Flughafen, Stadtrundfahrten und Reisen durch Deutschland. Buchen Sie Ihre Fahrt mit nur wenigen Klicks!',
bookByEmail: 'Per E-Mail buchen',
contactWhatsApp: 'Kontakt per WhatsApp',
emailModalTitle: 'Vollständiges Buchungsformular',
whatsappModalTitle: 'Schneller Kontakt',
whatsappDirectMessage: 'Hallo, ich interessiere mich für Ihre Dienstleistungen und habe eine Frage.',
pickupDetailsTitle: 'Abholdetails',
dropoffDetailsTitle: 'Zielortdetails',
pickup: 'Abholort',
pickupPlaceholder: 'z.B. Flughafen Berlin Brandenburg, Hotel Adlon oder Müllerstraße 123',
dropoff: 'Zielort',
dropoffPlaceholder: 'z.B. Hotel Adlon, Berlin Hauptbahnhof oder Potsdamer Platz',
date: 'Datum',
time: 'Uhrzeit',
passengers: 'Passagiere',
phone: 'Telefonnummer',
name: 'Ihr Name',
message: 'Ihre Nachricht',
messagePlaceholder: 'Hallo, ich möchte einen Transfer buchen...',
additionalInfo: 'Zusätzliche Informationen',
additionalInfoPlaceholder: 'z.B. Flugnummer, Kindersitz, Gepäck, Treffpunkt',
multipleVans: 'mehrere Vans',
checkAvailability: 'Verfügbarkeit prüfen',
sendMessage: 'Nachricht senden',
applyDiscountLabel: 'Ich möchte den 20% Aktions-Rabatt anwenden',
fieldRequired: 'Dieses Feld ist erforderlich',
dateMustBeFuture: 'Das Datum muss in der Zukunft liegen',
dropoffDateBeforePickup: 'Das Rückgabedatum darf nicht vor dem Abholdatum liegen',
invalidPhone: 'Bitte geben Sie eine gültige Telefonnummer ein (z.B. +49 123 4567890)',
bookingSubmitted: 'Vielen Dank! Ihre Anfrage wurde gesendet. Wir melden uns bald bei Ihnen.',
selectOption: 'Auswählen...',
addressCannotBeOnlyNumbers: 'Die Adresse darf nicht nur aus Zahlen bestehen. Bitte geben Sie einen Ortsnamen, Straßennamen oder eine bekannte Landmarke ein.',
addressMustContainLetters: 'Die Adresse muss Buchstaben enthalten (z.B. Straße, Stadt oder Ort).',
invalidName: 'Nur Buchstaben und Leerzeichen erlaubt (z.B. Max Mustermann).',
somethingWentWrong: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
messageSentSuccess: 'Ihre Nachricht wurde erfolgreich gesendet!',
messageTooLong: 'Die Nachricht darf die maximale Länge von {maxChars} Zeichen nicht überschreiten.',
stats: []
} as HeroContent;
}
// Состояние для выбранной машины
const [selectedCar, setSelectedCar] = createSignal<Car | undefined>(undefined);
// Обработчик события выбора машины
const handleSetCar = (event: CustomEvent) => {
if (event.detail) {
console.log('FleetBookingWrapper received car:', event.detail);
setSelectedCar(event.detail);
}
};
onMount(() => {
// Проверяем, есть ли уже сохранённая машина в глобальном хранилище
const globalCar = (window as any).currentSelectedCar as Car | undefined;
if (globalCar) {
setSelectedCar(globalCar);
// Удаляем данные из глобального хранилища, чтобы избежать повторного использования
(window as any).currentSelectedCar = undefined;
}
// Подписываемся на кастомное событие
window.addEventListener('set-fleet-car', handleSetCar as EventListener);
});
onCleanup(() => {
window.removeEventListener('set-fleet-car', handleSetCar as EventListener);
});
return (
<UniversalBookingForm
content={content}
car={selectedCar()}
formType="action"
/>
);
};
export default FleetBookingWrapper;

View file

@ -0,0 +1,163 @@
---
import VehicleCard from './VehicleCard.astro';
import Modal from '@components/base/Modal.astro';
import FleetBookingWrapper from './FleetBookingWrapper.tsx';
import { heroContent } from '@components/home/hero/HeroContent';
import { COLOR_CLASSES } from '@constants/colors';
import { authService } from '@/lib/authService';
// Получаем автомобили для премиум-флота из PocketBase
const pb = authService.createClientFromRequest(Astro.request);
let premiumFleet = [];
try {
const fleetRecords = await pb.collection('premium_fleet').getFullList({
sort: 'order',
filter: 'is_active = true'
});
// Преобразуем данные под нужный формат
premiumFleet = fleetRecords.map(vehicle => {
// Формируем URL изображения только если есть все необходимые данные
let imageUrl = '/images/fleet/cards/car01.avif'; // Заглушка на случай отсутствия изображения
if (vehicle.image && vehicle.collectionId && vehicle.id) {
imageUrl = `${import.meta.env.POCKETBASE_URL}/api/files/${vehicle.collectionId}/${vehicle.id}/${vehicle.image}`;
}
// Исправляем возможную проблему с лишней кавычкой в начале поля price
let price = vehicle.price || "Auf Anfrage";
if (typeof price === 'string' && price.startsWith('"')) {
price = price.substring(1); // Убираем первую кавычку, если она есть
}
return {
id: vehicle.id,
name: vehicle.name || 'Unbenanntes Fahrzeug',
tagline: vehicle.tagline || 'Kein Slogan',
description: vehicle.description || 'Keine Beschreibung verfügbar',
capacity: vehicle.capacity || 'N/A',
imageUrl,
features: Array.isArray(vehicle.features) ? vehicle.features : (typeof vehicle.features === 'string' ? JSON.parse(vehicle.features) : []),
brand: vehicle.brand || vehicle.name || 'Unbekannt',
model: vehicle.model || vehicle.tagline || 'Unbekannt',
year: vehicle.year || 2024,
price,
seats: [vehicle.capacity || 'N/A'],
doors_count: vehicle.doors_count,
horsepower: vehicle.horsepower,
trunk_capacity: vehicle.trunk_capacity
};
});
} catch (error) {
console.error('Error fetching fleet data from PocketBase:', error);
// Заглушка для карточек автомобилей
premiumFleet = [
{
id: 'fallback-1',
name: 'Mercedes-Benz V-Klasse',
tagline: 'Das Flaggschiff des Komforts',
description: 'Die V-Klasse definiert Luxus im Großraumformat. Perfekt für Geschäftsreisen, VIP-Transfers oder anspruchsvolle Familienausflüge.',
capacity: 'Bis zu 7 Fahrgäste',
imageUrl: '/images/fleet/cards/car01.avif',
features: ['Ledersitze', 'WLAN an Bord', 'Getönte Scheiben', 'Ambientebeleuchtung'],
make: 'Mercedes-Benz',
model: 'V-Klasse',
year: '2024',
price: 'Auf Anfrage',
seats: ['Bis zu 7 Fahrgäste']
},
{
id: 'fallback-2',
name: 'VW T7 Multivan',
tagline: 'Der flexible Alleskönner',
description: 'Der neue Multivan verbindet modernstes Design mit unerreichter Flexibilität. Ideal für Sightseeing-Touren oder als mobiles Büro.',
capacity: 'Bis zu 6 Fahrgäste',
imageUrl: '/images/fleet/cards/car02.avif',
features: ['Panorama-Glasdach', 'Digital Cockpit', 'USB-C an jedem Platz', 'Klapptisch'],
make: 'VW',
model: 'Multivan',
year: '2024',
price: 'Auf Anfrage',
seats: ['Bis zu 6 Fahrgäste']
}
];
}
// Используем тексты из heroContent для формы
const bookingContent = heroContent;
---
<section class="py-20 bg-gray-50" id="flotte">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
Unsere Premium-Flotte
</h2>
<p class="mt-4 max-w-2xl mx-auto text-xl text-gray-600">
Wählen Sie das perfekte Fahrzeug für Ihre Ansprüche.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{premiumFleet.map((vehicle) => (
<VehicleCard vehicle={vehicle} />
))}
</div>
</div>
<!-- Скрытая кнопка-триггер для модального окна -->
<button id="fleet-modal-trigger" class="hidden" aria-hidden="true"></button>
<!-- Модальное окно -->
<Modal
modalId="fleet-booking-modal"
triggerId="fleet-modal-trigger"
title="Premium Fahrzeug buchen"
maxWidth="md"
modalBgClass={COLOR_CLASSES.cardBackground}
>
<div class="pb-8">
<!-- Враппер, который ждет данные о машине -->
<FleetBookingWrapper
content={JSON.stringify(bookingContent)}
client:only="solid"
/>
</div>
</Modal>
</section>
<script>
// Скрипт для связки кнопок "Details & Buchung" с модальным окном
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('.js-fleet-book-btn');
const modalTrigger = document.getElementById('fleet-modal-trigger');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
const target = e.currentTarget;
const vehicleData = target.dataset.vehicle;
if (vehicleData && modalTrigger) {
try {
const parsedData = JSON.parse(vehicleData);
// Сохраняем данные автомобиля в глобальном хранилище до открытия модального окна
window.currentSelectedCar = parsedData;
// Отправляем данные в Solid компонент
window.dispatchEvent(new CustomEvent('set-fleet-car', {
detail: parsedData
}));
// Открываем модальное окно (имитируя клик по триггеру)
modalTrigger.click();
} catch (err) {
console.error('Error parsing vehicle data:', err);
}
}
});
});
});
</script>

View file

@ -0,0 +1,152 @@
---
import type { SliderProps } from '@/types/globalInterfaces';
// Иконки стрелок
const ChevronLeft = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`;
const ChevronRight = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`;
const { slides = [] } = Astro.props as SliderProps;
// Фильтрация активных слайдов и сортировка по полю order
const activeSlides = slides
.filter(slide => slide.is_active)
.sort((a, b) => a.order - b.order);
// Если нет слайдов, используем заглушку
const displaySlides = activeSlides.length > 0 ? activeSlides : [
{
image: '/images/fleet/luxury_car01.avif',
title: 'Unsere Premium-Flotte für Berlin',
description: 'Luxus, Komfort und Sicherheit auf höchstem Niveau für Ihre Reisen in und um Berlin.',
},
{
image: '/images/fleet/luxury_car02.avif',
title: 'Exklusive Fahrzeuge für jeden Anlass',
description: 'Entdecken Sie unsere sorgfältig ausgewählte Kollektion an Premium-Fahrzeugen für Ihr Event.',
},
{
image: '/images/fleet/luxury_car03.avif',
title: 'Perfekter Service von A bis Z',
description: 'Erleben Sie unseren erstklassigen Chauffeur-Service von der Buchung bis zur Ankunft am Ziel.',
}
];
---
<div class="slider-container relative h-[60vh] min-h-[400px] w-full overflow-hidden group" id="hero-slider">
<!-- Slides -->
{displaySlides.map((slide, index) => (
<div class={`slide absolute inset-0 w-full h-full transition-opacity duration-1000 ease-in-out ${index === 0 ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} data-index={index}>
<img
src={`${import.meta.env.POCKETBASE_URL}/api/files/${slide.collectionId}/${slide.id}/${slide.image}`}
alt={slide.title}
width="1200"
height="600"
class="h-full w-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-b from-black/80 via-black/70 to-black/90"></div>
<!-- Content -->
<div class="absolute inset-0 flex flex-col items-center justify-center text-center text-white p-6">
<div class="bg-black/40 backdrop-blur-sm rounded-2xl p-8 max-w-3xl mx-auto content-wrapper transform transition-all duration-700 translate-y-10 opacity-0">
<h2 class="text-4xl md:text-6xl font-extrabold tracking-tight drop-shadow-xl">
{slide.title}
</h2>
<p class="mt-4 max-w-2xl mx-auto text-lg md:text-xl text-gray-100 drop-shadow-lg">
{slide.description}
</p>
</div>
</div>
</div>
))}
<!-- Controls -->
<button id="prev-slide" class="absolute left-4 top-1/2 z-20 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/30 text-white transition-all duration-300 ease-out hover:cursor-pointer hover:scale-110 active:scale-95" aria-label="Previous slide" set:html={ChevronLeft}></button>
<button id="next-slide" class="absolute right-4 top-1/2 z-20 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/30 text-white transition-all duration-300 ease-out hover:cursor-pointer hover:scale-110 active:scale-95" aria-label="Next slide" set:html={ChevronRight}></button>
<!-- Dots -->
<div class="absolute bottom-6 left-1/2 z-20 flex -translate-x-1/2 space-x-2 bg-black/50 backdrop-blur-sm py-2 px-4 rounded-full">
{displaySlides.map((_, index) => (
<button
class={`dot h-2 w-2 rounded-full transition-all duration-300 ease-in-out hover:cursor-pointer ${index === 0 ? 'bg-white scale-125' : 'bg-white/50 hover:bg-white'}`}
data-index={index}
aria-label={`Go to slide ${index + 1}`}
></button>
))}
</div>
</div>
<script>
const container = document.getElementById('hero-slider');
if (container) {
const slides = container.querySelectorAll('.slide');
const dots = container.querySelectorAll('.dot');
const prevBtn = document.getElementById('prev-slide');
const nextBtn = document.getElementById('next-slide');
let currentIndex = 0;
let intervalId;
let isPaused = false;
const updateSlide = (newIndex) => {
// Hide current
slides[currentIndex].classList.remove('opacity-100', 'z-10');
slides[currentIndex].classList.add('opacity-0', 'z-0');
slides[currentIndex].querySelector('.content-wrapper')?.classList.remove('opacity-100', 'translate-y-0');
slides[currentIndex].querySelector('.content-wrapper')?.classList.add('opacity-0', 'translate-y-10');
dots[currentIndex].classList.remove('bg-white', 'scale-125');
dots[currentIndex].classList.add('bg-white/50');
// Update index
currentIndex = newIndex;
// Show new
slides[currentIndex].classList.remove('opacity-0', 'z-0');
slides[currentIndex].classList.add('opacity-100', 'z-10');
// Animate Content with delay
setTimeout(() => {
slides[currentIndex].querySelector('.content-wrapper')?.classList.remove('opacity-0', 'translate-y-10');
slides[currentIndex].querySelector('.content-wrapper')?.classList.add('opacity-100', 'translate-y-0');
}, 300);
dots[currentIndex].classList.remove('bg-white/50');
dots[currentIndex].classList.add('bg-white', 'scale-125');
};
const next = () => {
updateSlide((currentIndex + 1) % slides.length);
};
const prev = () => {
updateSlide((currentIndex - 1 + slides.length) % slides.length);
};
// Init first slide content
setTimeout(() => {
slides[0].querySelector('.content-wrapper')?.classList.remove('opacity-0', 'translate-y-10');
slides[0].querySelector('.content-wrapper')?.classList.add('opacity-100', 'translate-y-0');
}, 100);
// Event Listeners
nextBtn?.addEventListener('click', next);
prevBtn?.addEventListener('click', prev);
dots.forEach((dot, idx) => {
dot.addEventListener('click', () => updateSlide(idx));
});
// Auto play
const startTimer = () => {
intervalId = window.setInterval(() => {
if (!isPaused) next();
}, 8000);
};
container.addEventListener('mouseenter', () => { isPaused = true; });
container.addEventListener('mouseleave', () => { isPaused = false; });
startTimer();
}
</script>

View file

@ -0,0 +1,109 @@
---
import Button from '../base/Button.astro';
import type { VehicleCardProps } from '@/types/globalInterfaces';
const { vehicle } = Astro.props as VehicleCardProps;
const displayedFeatures = vehicle.features.slice(0, 3);
const extraFeaturesCount = Math.max(0, vehicle.features.length - 3);
// Подготавливаем объект для передачи в форму (адаптируем поля под формат Car из формы)
const vehicleDataForForm = JSON.stringify({
id: vehicle.id,
make: vehicle.brand,
model: vehicle.model,
year: vehicle.year,
price: vehicle.price,
src: vehicle.imageUrl,
seats: vehicle.seats,
name: vehicle.name,
tagline: vehicle.tagline,
description: vehicle.description,
capacity: vehicle.capacity,
doors: vehicle.doors_count,
horsepower: vehicle.horsepower,
trunk: vehicle.trunk_capacity,
features: vehicle.features
});
---
<div class="vehicle-card bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col border border-gray-100 h-full">
<div class="relative h-64 w-full overflow-hidden group">
<img
src={vehicle.imageUrl}
alt={`Bild von ${vehicle.name}`}
width="400"
height="256"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
loading="lazy"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="absolute bottom-4 left-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<h3 class="text-xl font-bold text-white drop-shadow-lg">
{vehicle.name}
</h3>
</div>
<div class="absolute top-2 right-2 bg-yellow-500 text-white px-2 py-1 rounded text-sm font-bold shadow-md">
{vehicle.price}
</div>
</div>
<div class="p-6 flex-grow flex flex-col bg-gradient-to-b from-white to-gray-50">
<div class="flex justify-between items-start mb-3">
<h3 class="text-xl font-bold text-gray-800">{vehicle.name}</h3>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Premium
</span>
</div>
<p class="text-blue-600 font-semibold text-sm mb-3">
{vehicle.tagline}
</p>
<p class="text-gray-600 text-sm mb-4 flex-grow">
{vehicle.description}
</p>
<ul class="space-y-3 mb-6">
<li class="flex items-center text-sm">
<svg class="text-blue-500 mr-2 flex-shrink-0 w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span class="text-gray-700 font-medium">
{vehicle.capacity}
</span>
</li>
{displayedFeatures.map((feature, index) => (
<li class="flex items-center text-sm" key={index}>
<svg class="text-green-500 mr-2 flex-shrink-0 w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
<span class="text-gray-700">{feature}</span>
</li>
))}
{extraFeaturesCount > 0 && (
<li class="text-xs text-blue-600 font-medium ml-6">
+{extraFeaturesCount} weitere Funktionen
</li>
)}
</ul>
<div class="w-full">
<Button
variant="blue"
size="md"
fullWidth={true}
class="js-fleet-book-btn"
data-vehicle={vehicleDataForForm}
>
Details & Buchung
</Button>
</div>
</div>
</div>
<style>
.vehicle-card {
transition: transform 0.5s ease, box-shadow 0.5s ease;
}
.vehicle-card:hover {
transform: translateY(-10px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
</style>

View file

@ -0,0 +1,17 @@
export interface Vehicle {
id?: string;
name: string;
tagline: string;
description: string;
capacity: string;
imageUrl: string;
features: string[];
make?: string;
model?: string;
year?: string;
price?: string;
seats?: string[];
}
// Экспортируем пустой массив, так как данные теперь приходят из PocketBase
export const premiumFleet: readonly Vehicle[] = [];

View file

@ -0,0 +1,46 @@
---
interface Props {
averageRating: number;
totalReviews: number;
}
const { averageRating, totalReviews } = Astro.props;
// Массив для отрисовки звезд (1..5)
const stars = [1, 2, 3, 4, 5];
---
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 mb-8 text-center border border-gray-100 transition-shadow duration-300 hover:shadow-2xl">
<div class="flex flex-col md:flex-row items-center justify-center gap-6">
{/* Цифра рейтинга */}
<div>
<div class="text-5xl md:text-6xl font-extrabold text-gray-800">
{averageRating.toFixed(1)}
</div>
<div class="text-gray-500 text-sm mt-1 uppercase tracking-wide">Durchschnitt</div>
</div>
{/* Разделитель (виден только на десктопе) */}
<div class="hidden md:block w-px h-16 bg-gray-200"></div>
{/* Звезды и кол-во */}
<div class="text-left flex flex-col items-center md:items-start">
<div class="flex space-x-1 mb-2">
{stars.map((star) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
class={`h-7 w-7 ${star <= Math.round(averageRating) ? 'text-[#FE9901]' : 'text-gray-300'}`}
fill="currentColor"
>
<path d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-17.82l49.1-151.05-128.77-93.55a16 16 0 019.38-28.84l159.05-.29L240.79 31.81a16 16 0 0130.42 0l49.18 153.64 159.05.29a16 16 0 019.38 28.84l-128.77 93.55 49.1 151.05A16 16 0 01394 480z"/>
</svg>
))}
</div>
<p class="text-gray-600 text-sm">
Basierend auf <span class="font-bold text-gray-900">{totalReviews}</span> Bewertungen
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,153 @@
---
import { authService } from '@/lib/authService';
// 1. Получаем URL PocketBase для формирования ссылок на картинки
const PB_URL = import.meta.env.PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090';
let reviews = [];
try {
const pb = authService.createClientFromRequest(Astro.request);
// Получаем 3 последних отзыва
const result = await pb.collection('reviews').getList(1, 3, {
sort: '-created',
expand: 'author'
});
// Форматируем данные
reviews = result.items.map((r) => {
const authorData = r.expand?.author;
const authorName = authorData?.name || authorData?.email?.split('@')[0] || 'Anonym';
// Логика формирования URL аватара
// Путь: /api/files/COLLECTION_ID/RECORD_ID/FILENAME
const avatarFilename = authorData?.avatar;
const authorCollectionId = authorData?.collectionId;
const authorId = authorData?.id;
const avatarUrl = (avatarFilename && authorCollectionId && authorId)
? `${PB_URL}/api/files/${authorCollectionId}/${authorId}/${avatarFilename}`
: null;
return {
id: r.id,
author: authorName,
initials: authorName.substring(0, 2).toUpperCase(),
rating: r.rating,
title: r.title,
content: r.content,
avatarUrl: avatarUrl,
created: new Date(r.created).toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
})
};
});
} catch (e) {
console.error("Error fetching latest reviews:", e);
reviews = [];
}
const stars = [1, 2, 3, 4, 5];
---
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Заголовок секции */}
<div class="text-center mb-12 animate-fade-in-up">
<h2 class="text-3xl font-bold text-gray-900 sm:text-4xl">
Letzte Bewertungen
</h2>
<p class="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
Lesen Sie, was andere über unseren Service sagen
</p>
</div>
{/* Сетка отзывов */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{reviews.length > 0 ? (
reviews.map((review) => (
<div class="bg-white rounded-2xl shadow-lg p-6 hover:shadow-xl transition-shadow duration-300 flex flex-col h-full">
{/* Шапка отзыва: Аватар и Имя */}
<div class="flex items-center mb-4">
{/* АВАТАР ИЛИ ИНИЦИАЛЫ */}
<div class="flex-shrink-0">
{review.avatarUrl ? (
<img
src={review.avatarUrl}
alt={review.author}
width="48"
height="48"
class="w-12 h-12 rounded-full object-cover border border-gray-100 shadow-sm"
loading="lazy"
/>
) : (
<div class="bg-gradient-to-br from-blue-100 to-indigo-200 text-indigo-800 font-bold rounded-full w-12 h-12 flex items-center justify-center text-lg shadow-sm">
{review.initials}
</div>
)}
</div>
<div class="ml-4">
<h3 class="text-lg font-bold text-gray-900">
{review.author}
</h3>
<time class="text-xs text-gray-500">
{review.created}
</time>
</div>
</div>
{/* Звезды */}
<div class="flex mb-3 space-x-1">
{stars.map((star) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
class={`h-5 w-5 ${star <= review.rating ? 'text-[#FE9901]' : 'text-gray-300'}`}
fill="currentColor"
>
<path d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-17.82l49.1-151.05-128.77-93.55a16 16 0 019.38-28.84l159.05-.29L240.79 31.81a16 16 0 0130.42 0l49.18 153.64 159.05.29a16 16 0 019.38 28.84l-128.77 93.55 49.1 151.05A16 16 0 01394 480z"/>
</svg>
))}
</div>
{/* Заголовок отзыва */}
{review.title && (
<h4 class="text-lg font-bold text-gray-900 mb-2 leading-tight">
{review.title}
</h4>
)}
{/* Текст отзыва */}
<p class="text-gray-700 text-sm leading-relaxed italic flex-grow">
„{review.content}“
</p>
</div>
))
) : (
<div class="col-span-full text-center py-12 text-gray-500 bg-white rounded-xl shadow-sm">
Noch keine Bewertungen verfügbar.
</div>
)}
</div>
{/* Кнопка "Все отзывы" */}
<div class="text-center mt-12">
<a
href="/bewertungen"
class="inline-flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Alle Bewertungen ansehen
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
</div>
</div>
</section>

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