mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
520 lines
25 KiB
TypeScript
520 lines
25 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 ContactMap from '../components/home/ContactMap';
|
|
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
|
import { facrApi } from '../services/facr/facrApi';
|
|
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
|
import { getImageUrl } from '../utils/imageUtils';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
type ContactFormData = {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
source?: string;
|
|
};
|
|
|
|
const ContactPage: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
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);
|
|
// Club competitions (for tabs fallback) and aliases map
|
|
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
|
|
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
|
|
|
|
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: t('contact.message_sent'),
|
|
description: t('contact.message_sent_desc'),
|
|
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 ? t('contact.timeout_error')
|
|
: isNetwork ? t('contact.network_error')
|
|
: t('contact.general_error'));
|
|
|
|
// Track failed contact form submission
|
|
trackContactSubmit(false);
|
|
trackFormSubmit('Contact Form', false);
|
|
|
|
toast({
|
|
title: t('contact.error_title'),
|
|
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; };
|
|
}, []);
|
|
|
|
// Load club competitions + aliases (used to populate tabs when no contact categories are defined)
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const clubId = (settings as any)?.club_id || '';
|
|
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
|
|
let comps: Array<{ code?: string; name: string }> = [];
|
|
if (clubId) {
|
|
try {
|
|
const club = await facrApi.getClub(String(clubId), clubType);
|
|
const arr = Array.isArray((club as any)?.competitions) ? (club as any).competitions : [];
|
|
arr.forEach((c: any) => comps.push({ code: c.code, name: c.name || c.code }));
|
|
} catch {}
|
|
}
|
|
let amap: Record<string, string> = {};
|
|
try {
|
|
const list = await getCompetitionAliasesPublic();
|
|
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
|
|
} catch {}
|
|
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
|
|
setAliasesMap(amap);
|
|
setCompetitions(withAliases);
|
|
} catch {}
|
|
})();
|
|
}, [settings]);
|
|
|
|
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} alignItems="start">
|
|
{/* Map on the left */}
|
|
{hasLocation && (
|
|
<Box borderRadius="lg" overflow="hidden" boxShadow="md" alignSelf="start">
|
|
<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}>{t('contact.contact_info')}</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">{t('contact.address')}</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">{t('contact.phone')}</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">{t('contact.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}>{t('contact.contact_persons')}</Heading>
|
|
<Tabs colorScheme="blue" isFitted isLazy>
|
|
{(() => {
|
|
const categoryEntries = Object.entries(contactsData?.categories || {});
|
|
const compNames = competitions.map((c) => (c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name).filter(Boolean);
|
|
const useCategories = categoryEntries.length > 0;
|
|
const tabs = useCategories ? categoryEntries.map(([n]) => n) : compNames;
|
|
const hasOthers = uncategorized.length > 0;
|
|
return (
|
|
<>
|
|
<TabList>
|
|
{tabs.map((n) => (
|
|
<Tab key={n}>{n}</Tab>
|
|
))}
|
|
{hasOthers && <Tab>{t('contact.others')}</Tab>}
|
|
</TabList>
|
|
<TabPanels>
|
|
{useCategories
|
|
? categoryEntries.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={getImageUrl(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>
|
|
))
|
|
: tabs.map((name) => {
|
|
const persons = (contactsData?.categories || {})[name] || [];
|
|
return (
|
|
<TabPanel key={name} pt={4}>
|
|
{persons.length > 0 ? (
|
|
<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={getImageUrl(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>
|
|
) : (
|
|
<Text color="gray.500">{t('contact.no_contacts')}</Text>
|
|
)}
|
|
</TabPanel>
|
|
);
|
|
})}
|
|
{hasOthers && (
|
|
<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={getImageUrl(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'}>
|
|
{t('contact.contact_us')}
|
|
</Heading>
|
|
<Text color="gray.500">
|
|
{t('contact.contact_description')}
|
|
</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">{t('contact.name_label')}</FormLabel>
|
|
<Input
|
|
id="name"
|
|
placeholder={t('contact.name_placeholder')}
|
|
{...register('name', {
|
|
required: t('contact.name_required'),
|
|
minLength: { value: 2, message: t('contact.name_min_length') },
|
|
})}
|
|
/>
|
|
<FormErrorMessage>
|
|
{errors.name && errors.name.message}
|
|
</FormErrorMessage>
|
|
</FormControl>
|
|
|
|
<FormControl isInvalid={!!errors.email}>
|
|
<FormLabel htmlFor="email">{t('contact.email_label')}</FormLabel>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder={t('contact.email_placeholder')}
|
|
{...register('email', {
|
|
required: t('contact.email_required'),
|
|
pattern: {
|
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
|
message: t('contact.email_invalid'),
|
|
},
|
|
})}
|
|
/>
|
|
<FormErrorMessage>
|
|
{errors.email && errors.email.message}
|
|
</FormErrorMessage>
|
|
</FormControl>
|
|
|
|
<FormControl isInvalid={!!errors.subject}>
|
|
<FormLabel htmlFor="subject">{t('contact.subject_label')}</FormLabel>
|
|
<Input
|
|
id="subject"
|
|
placeholder={t('contact.subject_placeholder')}
|
|
{...register('subject', {
|
|
required: t('contact.subject_required'),
|
|
maxLength: { value: 100, message: t('contact.subject_max_length') },
|
|
})}
|
|
/>
|
|
<FormErrorMessage>
|
|
{errors.subject && errors.subject.message}
|
|
</FormErrorMessage>
|
|
</FormControl>
|
|
|
|
<FormControl isInvalid={!!errors.message}>
|
|
<FormLabel htmlFor="message">{t('contact.message_label')}</FormLabel>
|
|
<Textarea
|
|
id="message"
|
|
rows={6}
|
|
placeholder={t('contact.message_placeholder')}
|
|
{...register('message', {
|
|
required: t('contact.message_required'),
|
|
minLength: { value: 10, message: t('contact.message_min_length') },
|
|
maxLength: { value: 2000, message: t('contact.message_max_length') },
|
|
})}
|
|
/>
|
|
<FormErrorMessage>
|
|
{errors.message && errors.message.message}
|
|
</FormErrorMessage>
|
|
</FormControl>
|
|
|
|
<Button
|
|
type="submit"
|
|
colorScheme={settings?.primaryColor || 'brand'}
|
|
size="lg"
|
|
width="full"
|
|
mt={4}
|
|
isLoading={isLoading}
|
|
loadingText={t('contact.sending')}
|
|
data-umami-event="Contact Form Submit"
|
|
>
|
|
{t('contact.send_message')}
|
|
</Button>
|
|
</VStack>
|
|
</form>
|
|
</Box>
|
|
</VStack>
|
|
</Container>
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default ContactPage;
|