chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 11:17:38 +02:00
commit 40352e4179
370 changed files with 35601 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { Box } from '@pikku/mantine/core';
import React from 'react';
interface CSSArrowSeparatorProps {
/**
* Color of the arrow (default: dimmed)
*/
color?: string;
/**
* Size of the arrow in pixels (default: 20)
*/
size?: number;
/**
* Margin around the component (default: 'lg')
*/
margin?: string | number;
}
export const CSSArrowSeparator: React.FC<CSSArrowSeparatorProps> = ({
color = 'var(--mantine-color-dimmed)',
size = 20,
margin = 'lg'
}) => {
return (
<Box style={{ margin }}>
{/* Top horizontal line */}
<Box
style={{
width: '100%',
height: '1px',
backgroundColor: color,
marginBottom: '4px',
}}
/>
{/* Arrow pointing down */}
<Box
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Box
style={{
width: 0,
height: 0,
borderLeft: `${size}px solid transparent`,
borderRight: `${size}px solid transparent`,
borderTop: `${size}px solid ${color}`,
}}
/>
</Box>
{/* Bottom horizontal line */}
<Box
style={{
width: '100%',
height: '1px',
backgroundColor: color,
marginTop: '4px',
}}
/>
</Box>
);
};

View File

@@ -0,0 +1,68 @@
import { Text } from "@pikku/mantine/core"
import { asI18n } from "@pikku/react"
import React from "react"
interface EmphasizeProps {
text: string;
primary?: boolean
renderInlineStyles?: boolean
[key: string]: any
}
function stripInlineTags(text: string) {
return text
.replace(/<\/?(strong|i|em)>/g, '')
.replace(/<br\s*\/?>/g, '\n')
.replace(/<\/br>/g, '')
}
export function Emphasize({
text,
primary = true,
renderInlineStyles = true,
...textProps
}: EmphasizeProps) {
if (!renderInlineStyles) {
return <Text {...textProps}>{asI18n(stripInlineTags(text))}</Text>
}
// Split text by <strong> and <i> tags while preserving the tags
const parts = text.split(/(<strong>.*?<\/strong>|<i>.*?<\/i>|<em>.*?<\/em>|<br>.*?<\/br>)/g);
return (
<Text {...textProps}>
{parts.map((part, i) => {
if ((part.startsWith('<strong>') && part.endsWith('</strong>')) || (part.startsWith('<em>') && part.endsWith('</em>'))){
// Extract content between <strong> tags
const content = part.replace(/<\/?strong>/g, '').replace(/<\/?em>/g, '');
return (
<Text
key={i}
component="strong"
fw={700}
span
c={primary ? 'primary' : undefined}
>
{asI18n(content)}
</Text>
);
} else if (part.startsWith('<i>') && part.endsWith('</i>')) {
// Extract content between <i> tags
const content = part.replace(/<\/?i>/g, '');
return (
<Text key={i} fs="italic" span>
{asI18n(content)}
</Text>
);
} else if (part.startsWith('<br>') && part.endsWith('</br>')) {
return (
<br key={i}/>
);
} else {
// Regular text
return <React.Fragment key={i}>{asI18n(part)}</React.Fragment>;
}
})}
</Text>
);
}

View File

@@ -0,0 +1,170 @@
import { useState } from 'react';
import { Dropzone, MIME_TYPES, FileRejection } from '@mantine/dropzone';
import { Button, Box, Text, Stack, Group, ActionIcon, Paper, Alert } from '@pikku/mantine/core';
import { IconX, IconPlus } from '@tabler/icons-react';
import { useI18n } from '@/context/i18n-provider';
import { asI18n } from '@pikku/react';
type UploadedFile = {
id: string
fileName: string
}
export type UploadFile = (files: File[]) => Promise<void>
export type DeleteFile = (id: string) => Promise<void>
interface FileUploadProps {
multiple?: boolean;
accept?: string;
maxFileSize?: number;
chooseLabel?: string;
className?: string;
files: UploadedFile[]
uploadFiles: UploadFile
deleteFile: DeleteFile
}
export const FileUpload: React.FunctionComponent<FileUploadProps> = ({
multiple = true,
accept = ".pdf,.jpg,.jpeg,.png",
maxFileSize = 210000000,
chooseLabel,
files = [],
uploadFiles,
deleteFile
}) => {
const t = useI18n()
const [uploading, setUploading] = useState(false);
const [rejectionError, setRejectionError] = useState<string | null>(null);
const defaultChooseLabel = chooseLabel ? asI18n(chooseLabel) : t('jobs.fileupload.choosefile')
const handleUpload = async (files: File[]) => {
setRejectionError(null); // Clear any previous errors
setUploading(true);
try {
await uploadFiles(files)
} catch (error) {
console.error('Error uploading files:', error)
}
setUploading(false);
}
const handleReject = (rejectedFiles: FileRejection[]) => {
if (rejectedFiles.length > 0) {
const firstError = rejectedFiles[0].errors[0];
let errorMessage: string;
switch (firstError.code) {
case 'file-invalid-type':
errorMessage = t('jobs.fileupload.error.file-invalid-type');
break;
case 'file-too-large':
errorMessage = t('jobs.fileupload.error.file-too-large');
break;
case 'file-too-small':
errorMessage = t('jobs.fileupload.error.file-too-small');
break;
case 'too-many-files':
errorMessage = t('jobs.fileupload.error.too-many-files');
break;
default:
errorMessage = firstError.message;
}
setRejectionError(errorMessage);
}
}
const handleDelete = async (file: UploadedFile) => {
try {
await deleteFile(file.id)
} catch (error) {
console.error('Error deleting file:', error)
}
}
const acceptTypes = accept.split(',').map(type => {
switch (type.trim()) {
case '.pdf': return MIME_TYPES.pdf;
case '.jpg':
case '.jpeg': return MIME_TYPES.jpeg;
case '.png': return MIME_TYPES.png;
default: return type.trim();
}
});
return (
<Paper p="xl" withBorder>
<Box pos="relative">
<Dropzone
multiple={multiple}
accept={acceptTypes}
maxSize={maxFileSize}
onDrop={handleUpload}
onReject={handleReject}
loading={uploading}
bd='none'
bg='transparent'
p={0}
>
<Stack gap="md">
<Button
size="md"
loading={uploading}
leftSection={<IconPlus size={16} />}
>
{defaultChooseLabel}
</Button>
{!files || files.length === 0 ? (
<Stack gap="xs">
<Text size="md" fw={500}>{t('jobs.fileupload.dragdroptext')}</Text>
<Text size="sm" c="dimmed">
{t('jobs.fileupload.acceptedformats')}
<br />
{t('jobs.fileupload.maxsize')}
</Text>
</Stack>
) : null}
</Stack>
</Dropzone>
</Box>
{files && files.length > 0 && (
<Stack gap="sm" mt="md">
{files.map((file) => (
<Group key={file.id} justify="space-between" align="center">
<Text
fw={500}
c="dark"
maw={250}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{asI18n(file.fileName)}
</Text>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleDelete(file)}
aria-label={t('jobs.fileupload.delete')}
>
<IconX size={16} />
</ActionIcon>
</Group>
))}
</Stack>
)}
{rejectionError && (
<Alert color="red" mt="md" onClose={() => setRejectionError(null)} withCloseButton>
{asI18n(rejectionError)}
</Alert>
)}
</Paper>
);
}

View File

@@ -0,0 +1,48 @@
import { Link, useParams } from '@tanstack/react-router'
import { Stack, Container, Group, Anchor, Box } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
export default function FooterBar() {
const t = useI18n()
const params = useParams({ strict: false })
const lang = params.lang as string || 'en'
return (
<Box
component="footer"
bg="white"
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
>
<Container size="lg" w="100%">
<Stack gap="lg" py="xl">
<Box
pos="relative"
h={75}
w="100%"
style={{ aspectRatio: '4/2' }}
>
<img
src="/brands.png"
alt="Partner Logos"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain' }}
/>
</Box>
<Group justify="center" gap="lg" pt="sm">
<Anchor component={Link} to={`/${lang}/legal/imprint`} size="sm">
{t('layout.footer.imprint')}
</Anchor>
<Anchor component={Link} to={`/${lang}/legal/terms-and-conditions`} size="sm">
{t('layout.footer.termsofuse')}
</Anchor>
<Anchor component={Link} to={`/${lang}/legal/privacy`} size="sm">
{t('layout.footer.privacynotice')}
</Anchor>
<Anchor href="mailto:info@hey-germany.com" size="sm">
{t('layout.footer.contact')}
</Anchor>
</Group>
</Stack>
</Container>
</Box>
)
}

View File

@@ -0,0 +1,58 @@
import { Select, Group, Text } from '@pikku/mantine/core'
import { useLocation, useNavigate, useParams, useRouter } from '@tanstack/react-router'
import { LANGUAGES } from '@/config'
import { useI18n } from '@/context/i18n-provider'
export default function LanguageSelector() {
const t = useI18n()
const params = useParams({ strict: false }) as { lang?: string }
const pathname = useLocation({ select: (l) => l.pathname })
const navigate = useNavigate()
const router = useRouter()
// Extract lang from params or pathname (for routes like /en/legal/imprint)
const lang = params.lang || pathname.split('/')[1]
const selectedLanguage = LANGUAGES.find(language => language.value === lang)!
const switchLanguage = (to: string | null) => {
if (to && to !== lang) {
// Store language preference in cookie
document.cookie = `NEXT_LOCALE=${to}; path=/; max-age=31536000; SameSite=Lax`
// Create new path with updated language
const newPath = pathname.replace(`/${lang}`, `/${to}`)
// Navigate to new language
navigate({ to: newPath })
router.invalidate()
}
}
return (
<Select
size='sm'
w={140}
value={lang}
data={LANGUAGES}
onChange={switchLanguage}
leftSection={<img src={selectedLanguage.flag} alt={selectedLanguage.label} width={18} height={18} />}
renderOption={({ option }) => {
const language = LANGUAGES.find(lang => lang.value === option.value)
if (language) {
return (
<Group gap="xs">
<img
src={language.flag}
alt={language.label}
width={18}
height={18}
/>
<Text visibleFrom='md'>{t(`language.${language.value}` as any)}</Text>
</Group>
)
}
}}
/>
)
}

View File

@@ -0,0 +1,43 @@
import { Group, Text, Badge } from '@pikku/mantine/core'
import { Link, useLocation, useParams } from '@tanstack/react-router'
import { DEFAULT_LOCALE } from '@/config'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
interface LogoProps {
size?: 'sm' | 'md' | 'lg'
showBeta?: boolean
type?: string
}
export default function Logo({ size = 'md', showBeta = true, type }: LogoProps) {
const t = useI18n()
const dimensions = {
sm: { width: 24, height: 24, textSize: 'lg' as const },
md: { width: 32, height: 32, textSize: 'xl' as const },
lg: { width: 40, height: 40, textSize: 'xl' as const }
}
const { width, height, textSize } = dimensions[size]
const pathname = useLocation({ select: (l) => l.pathname })
const { lang } = useParams({ strict: false }) as { lang?: string }
const homeHref = lang ? `/${lang}` : pathname.startsWith('/pilot') ? '/de' : `/${DEFAULT_LOCALE}`
return (
<Group gap="xs" align="center">
<Link to={homeHref}>
<img
src="/heygermany-logo-small.png"
alt="HeyGermany logo"
width={width}
height={height}
/>
</Link>
<Text size={textSize} fw={500}>
{t('logo.wordmark')}
</Text>
{type && <Badge variant="light" color="primary">{asI18n(type)}</Badge>}
{!type && showBeta && <Text size="xs" c="gray.8">{t('layout.topbar.beta')}</Text>}
</Group>
)
}

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
import {
Icon2fa,
IconBellRinging,
IconDatabaseImport,
IconFingerprint,
IconKey,
IconLogout,
IconReceipt2,
IconSettings,
IconSwitchHorizontal,
} from '@tabler/icons-react';
import { Code, Group } from '@pikku/mantine/core';
import classes from './NavbarSimple.module.css';
const data = [
{ link: '', label: 'Notifications', icon: IconBellRinging },
{ link: '', label: 'Billing', icon: IconReceipt2 },
{ link: '', label: 'Security', icon: IconFingerprint },
{ link: '', label: 'SSH Keys', icon: IconKey },
{ link: '', label: 'Databases', icon: IconDatabaseImport },
{ link: '', label: 'Authentication', icon: Icon2fa },
{ link: '', label: 'Other Settings', icon: IconSettings },
];
export function NavbarSimple() {
const [active, setActive] = useState('Billing');
const links = data.map((item) => (
<a
className={classes.link}
data-active={item.label === active || undefined}
href={item.link}
key={item.label}
onClick={(event) => {
event.preventDefault();
setActive(item.label);
}}
>
<item.icon className={classes.linkIcon} stroke={1.5} />
<span>{item.label}</span>
</a>
));
return (
<nav className={classes.navbar}>
<div className={classes.navbarMain}>
<Group className={classes.header} justify="space-between">
</Group>
{links}
</div>
<div className={classes.footer}>
<a href="#" className={classes.link} onClick={(event) => event.preventDefault()}>
<IconSwitchHorizontal className={classes.linkIcon} stroke={1.5} />
<span>Change account</span>
</a>
<a href="#" className={classes.link} onClick={(event) => event.preventDefault()}>
<IconLogout className={classes.linkIcon} stroke={1.5} />
<span>Logout</span>
</a>
</div>
</nav>
);
}

View File

@@ -0,0 +1,24 @@
.icon {
width: 18px;
height: 18px;
}
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,25 @@
import { IconMoon, IconSun } from '@tabler/icons-react';
import cx from 'clsx';
import { ActionIcon, Group, useComputedColorScheme, useMantineColorScheme } from '@pikku/mantine/core';
import { useI18n } from '@/context/i18n-provider';
import classes from './ThemeToggle.module.css';
export function ThemeToggle() {
const t = useI18n();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
return (
<Group justify="center">
<ActionIcon
onClick={() => setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light')}
variant="default"
size="lg"
aria-label={t('theme.toggle')}
>
<IconSun className={cx(classes.icon, classes.light)} stroke={1.5} />
<IconMoon className={cx(classes.icon, classes.dark)} stroke={1.5} />
</ActionIcon>
</Group>
);
}

View File

@@ -0,0 +1,25 @@
import { Box, Container, Flex } from '@pikku/mantine/core'
import LanguageSelector from './LanguageSelector'
import Logo from './Logo'
export default function TopBar() {
return (
<Box
dir='ltr'
bg="white"
pos="sticky"
top={0}
style={{ zIndex: 50, boxShadow: 'var(--mantine-shadow-md)' }}
>
<Container size="xl" py="md">
<Flex justify="space-between" align="center">
<Logo />
<Flex gap="sm" align="center">
<LanguageSelector />
</Flex>
</Flex>
</Container>
</Box>
)
}