chore: heygermany customer project
This commit is contained in:
66
apps/website/components/ui/CSSArrowSeparator.tsx
Normal file
66
apps/website/components/ui/CSSArrowSeparator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
apps/website/components/ui/Emphasize.tsx
Normal file
68
apps/website/components/ui/Emphasize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/website/components/ui/FileUpload.tsx
Normal file
170
apps/website/components/ui/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/website/components/ui/FooterBar.tsx
Normal file
48
apps/website/components/ui/FooterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/website/components/ui/LanguageSelector.tsx
Normal file
58
apps/website/components/ui/LanguageSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
43
apps/website/components/ui/Logo.tsx
Normal file
43
apps/website/components/ui/Logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
0
apps/website/components/ui/NavBar.module.css
Normal file
0
apps/website/components/ui/NavBar.module.css
Normal file
66
apps/website/components/ui/NavBar.tsx
Normal file
66
apps/website/components/ui/NavBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/website/components/ui/ThemeToggle.module.css
Normal file
24
apps/website/components/ui/ThemeToggle.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
apps/website/components/ui/ThemeToggle.tsx
Normal file
25
apps/website/components/ui/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/website/components/ui/TopBar.tsx
Normal file
25
apps/website/components/ui/TopBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user