Files
MyClub/frontend/src/pages/ContactPage.tsx
T
Tomas Dvorak 3d621e2187 dev day #71
2025-10-25 16:33:53 +02:00

433 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
Box,
Button,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
Input,
Text,
Textarea,
VStack,
useToast,
useColorModeValue,
Container,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
SimpleGrid,
HStack,
Avatar,
Badge,
Icon,
Link,
Divider
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { sendContact } from '../services/public';
import { useSettings } from '../hooks/useSettings';
import MainLayout from '../components/layout/MainLayout';
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
import { trackContactSubmit, trackFormSubmit } from '../utils/umami';
import SponsorsSection from '../components/common/SponsorsSection';
import ContactMap from '../components/home/ContactMap';
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
type ContactFormData = {
name: string;
email: string;
subject: string;
message: string;
source?: string;
};
const ContactPage: React.FC = () => {
const toast = useToast();
const { settings } = useSettings();
const cardBg = useColorModeValue('white', 'gray.800');
const cardBorder = useColorModeValue('gray.200', 'gray.700');
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// Public contacts (grouped by category)
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
const [contactsLoading, setContactsLoading] = useState(true);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>();
const { mutate, isLoading } = useMutation({
mutationFn: (data: ContactFormData) => sendContact({ ...data, source: 'contact' }),
onSuccess: () => {
reset();
// Track successful contact form submission
trackContactSubmit(true);
trackFormSubmit('Contact Form', true);
toast({
title: 'Zpráva odeslána',
description: 'Děkujeme za vaši zprávu. Brzy se vám ozveme zpět.',
status: 'success',
duration: 5000,
isClosable: true,
});
},
onError: (error: any) => {
// Heuristics for Axios errors
const code = error?.code || '';
const msgFromServer = error?.response?.data?.error || error?.response?.data?.message;
const isTimeout = code === 'ECONNABORTED' || /timeout/i.test(String(error?.message || ''));
const isNetwork = !!error?.isAxiosError && !error?.response;
const description = msgFromServer
|| (isTimeout ? 'Vypršel časový limit požadavku. Zkuste to prosím znovu za chvíli.'
: isNetwork ? 'Požadavek se nezdařil (síť/CORS). Zkuste to znovu nebo obnovte stránku.'
: 'Něco se pokazilo. Zkuste to prosím znovu později.');
// Track failed contact form submission
trackContactSubmit(false);
trackFormSubmit('Contact Form', false);
toast({
title: 'Chyba',
description,
status: 'error',
duration: 6000,
isClosable: true,
});
},
});
// Load public contacts once
useEffect(() => {
let mounted = true;
(async () => {
try {
const data = await getPublicContacts();
if (mounted) setContactsData(data);
} catch (e) {
console.error('Failed to load contacts', e);
} finally {
if (mounted) setContactsLoading(false);
}
})();
return () => { mounted = false; };
}, []);
const onSubmit = (data: ContactFormData) => {
mutate(data);
};
return (
<MainLayout>
<Container maxW="container.xl">
<VStack align="stretch" spacing={8}>
{/* Top: Map + Contact categories (tabs) */}
<Box>
<Heading size="xl" mb={4}>Kontakt</Heading>
{/* contacts are loaded in useEffect */}
{(() => {
const lat = (settings as any)?.location_latitude;
const lng = (settings as any)?.location_longitude;
const hasLocation = !!lat && !!lng;
const categories = Object.entries(contactsData?.categories || {});
const uncategorized = contactsData?.uncategorized || [];
const hasContacts = categories.length > 0 || uncategorized.length > 0;
const hasContactInfo = !!(
(settings as any)?.contact_address ||
(settings as any)?.contact_phone ||
(settings as any)?.contact_email
);
if (!hasLocation && !hasContacts && !hasContactInfo) return null;
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{/* Map on the left */}
{hasLocation && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<ContactMap
latitude={lat}
longitude={lng}
zoom={(settings as any)?.map_zoom_level || 15}
address={(settings as any)?.contact_address}
clubName={(settings as any)?.club_name || (settings as any)?.site_title}
mapStyle={(settings as any)?.map_style || 'default'}
clubPrimaryColor={(settings as any)?.primary_color}
clubSecondaryColor={(settings as any)?.accent_color}
/>
</Box>
)}
{/* Right column: contact info card (if any) + contacts tabs */}
{(hasContactInfo || hasContacts) && (
<VStack align="stretch" spacing={4}>
{hasContactInfo && (
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
<Heading size="md" mb={3}>Kontaktní údaje</Heading>
<VStack align="stretch" spacing={3}>
{(settings as any)?.contact_address && (
<HStack align="start">
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Adresa</Text>
<Text>{(settings as any)?.contact_address}</Text>
{(settings as any)?.contact_city && (
<Text>
{(settings as any)?.contact_zip && `${(settings as any)?.contact_zip} `}
{(settings as any)?.contact_city}
</Text>
)}
{(settings as any)?.contact_country && <Text>{(settings as any)?.contact_country}</Text>}
</VStack>
</HStack>
)}
{(settings as any)?.contact_phone && (
<HStack align="start">
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Telefon</Text>
<Link href={`tel:${(settings as any)?.contact_phone}`} color="blue.500">
{(settings as any)?.contact_phone}
</Link>
</VStack>
</HStack>
)}
{(settings as any)?.contact_email && (
<HStack align="start">
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Email</Text>
<Link href={`mailto:${(settings as any)?.contact_email}`} color="blue.500">
{(settings as any)?.contact_email}
</Link>
</VStack>
</HStack>
)}
</VStack>
</Box>
)}
{hasContacts && (
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
<Heading size="md" mb={3}>Kontaktní osoby</Heading>
<Tabs colorScheme="blue" isFitted>
<TabList>
{categories.map(([name]) => (
<Tab key={name}>{name}</Tab>
))}
{uncategorized.length > 0 && <Tab>Ostatní</Tab>}
</TabList>
<TabPanels>
{categories.map(([name, persons]) => (
<TabPanel key={name} pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{persons.map((contact) => (
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</TabPanel>
))}
{uncategorized.length > 0 && (
<TabPanel pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{uncategorized.map((contact) => (
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</TabPanel>
)}
</TabPanels>
</Tabs>
</Box>
)}
</VStack>
)}
</SimpleGrid>
);
})()}
</Box>
<Divider />
{/* Contact form at the end */}
<Box>
<Heading size="lg" mb={2} color={settings?.primaryColor || 'brand.500'}>
Kontaktujte nás
</Heading>
<Text color="gray.500">
Máte dotaz nebo připomínku? Napište nám zprávu a my se vám ozveme co nejdříve zpět.
</Text>
</Box>
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="1px"
borderColor={cardBorder}
boxShadow="sm"
p={6}
>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.name}>
<FormLabel htmlFor="name">Jméno a příjmení *</FormLabel>
<Input
id="name"
placeholder="Jan Novák"
{...register('name', {
required: 'Toto pole je povinné',
minLength: { value: 2, message: 'Jméno musí mít alespoň 2 znaky' },
})}
/>
<FormErrorMessage>
{errors.name && errors.name.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel htmlFor="email">E-mailová adresa *</FormLabel>
<Input
id="email"
type="email"
placeholder="vas@email.cz"
{...register('email', {
required: 'Toto pole je povinné',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Neplatná e-mailová adresa',
},
})}
/>
<FormErrorMessage>
{errors.email && errors.email.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.subject}>
<FormLabel htmlFor="subject">Předmět</FormLabel>
<Input
id="subject"
placeholder="Předmět zprávy"
{...register('subject', {
required: 'Předmět je povinný',
maxLength: { value: 100, message: 'Předmět může mít maximálně 100 znaků' },
})}
/>
<FormErrorMessage>
{errors.subject && errors.subject.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.message}>
<FormLabel htmlFor="message">Zpráva *</FormLabel>
<Textarea
id="message"
rows={6}
placeholder="Napište nám zprávu..."
{...register('message', {
required: 'Toto pole je povinné',
minLength: { value: 10, message: 'Zpráva musí mít alespoň 10 znaků' },
maxLength: { value: 2000, message: 'Zpráva může mít maximálně 2000 znaků' },
})}
/>
<FormErrorMessage>
{errors.message && errors.message.message}
</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme={settings?.primaryColor || 'brand'}
size="lg"
width="full"
mt={4}
isLoading={isLoading}
loadingText="Odesílám..."
data-umami-event="Contact Form Submit"
>
Odeslat zprávu
</Button>
</VStack>
</form>
</Box>
</VStack>
</Container>
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
export default ContactPage;