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 10:31:48 +02:00
commit 81ea8ef5d1
394 changed files with 38048 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
import React, { useMemo, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { Box, Button, Checkbox, SimpleGrid, Stack, Text } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import {
ApplicationCandidateData,
CandidateDocumentTypes,
LanguageCodes,
ProfessionalRecognitionStatuses,
} from '@heygermany/sdk'
import { useI18n } from '@/context/i18n-provider'
import { pikku } from '@/pikku/http'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { EnumSelect } from '@/components/common/EnumSelect'
import { FileUpload } from '@/components/ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useLocation, useParams } from '@tanstack/react-router'
import { CountrySearch } from '../form/CountrySearch'
interface ApplicantProfileForm {
nationality: string
currentResidence: string
professionalRecognitionStatus: DB.ProfessionalRecognitionStatus | null
qualificationStatusGermany: DB.NurseRecognitionGermany | null
recognitionLikelihood: DB.RecognitionLikelihood | null
languagesSpoken: string[]
}
export const ApplicantProfile: React.FunctionComponent = () => {
const params = useParams({ strict: false })
const searchParams = useLocation({ select: (l) => new URLSearchParams(l.searchStr) })
const candidateIdFromUrl = params.candidateId as string | undefined
const candidateIdFromQuery = searchParams.get('candidateId')
const candidateId = candidateIdFromUrl || candidateIdFromQuery || undefined
// Fetch existing candidate data
const { data: candidate } = useQuery({
queryKey: ['candidate', candidateId],
queryFn: async () => {
if (candidateId) {
return await pikku().get('/candidate/:candidateId', { candidateId })
}
return await pikku().get('/candidate')
},
})
if (!candidate) {
return null
}
return <ApplicantProfileInner candidate={candidate} />
}
const ApplicantProfileInner: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const queryClient = useQueryClient()
const [isSaving, setIsSaving] = useState(false)
// Update mutation
const updateMutation = useMutation({
mutationFn: async (data: ApplicantProfileForm) => {
if (!candidate?.candidateId) {
throw new Error('Candidate ID not found')
}
setIsSaving(true)
// Create a minimum delay promise (2 seconds)
const minDelay = new Promise(resolve => setTimeout(resolve, 2000))
// Update candidate profile
const updatePromise = pikku().patch(`/candidate/:candidateId`, {
candidateId: candidate.candidateId,
...data,
languagesSpoken: data.languagesSpoken as DB.LanguageCode[],
})
// Wait for both the update and minimum delay
await Promise.all([updatePromise, minDelay])
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['candidate'] })
setIsSaving(false)
// Show success notification or redirect
},
onError: () => {
setIsSaving(false)
},
})
const { control, handleSubmit, trigger, formState: { errors } } = useForm<ApplicantProfileForm>({
defaultValues: {
nationality: candidate.nationality || '',
currentResidence: candidate.currentResidence || '',
professionalRecognitionStatus: candidate.professionalRecognitionStatus || null,
qualificationStatusGermany: candidate.qualificationStatusGermany || null,
recognitionLikelihood: candidate.recognitionLikelihood || null,
languagesSpoken: candidate.languagesSpoken || [],
},
})
// File upload for CV (using WORK_EXPERIENCE document type)
const { uploadFiles, deleteFile } = useUploadCandidateFiles(
candidate.candidateId,
CandidateDocumentTypes.WORK_EXPERIENCE,
trigger
)
// Available languages (sorted alphabetically, English first)
const selectEnums = t.getEnumOptions(LanguageCodes, 'languagecode')
const availableLanguages = useMemo(() => {
// English first, then sort rest alphabetically by label
const english = selectEnums.find(l => l.value === 'en')!
const others = selectEnums.filter(l => l.value !== 'en').sort((a, b) => a.label.localeCompare(b.label))
return [english, ...others]
}, [t, selectEnums])
return (
<>
<Text size="sm" c="dimmed" mb="lg">
{t('jobs.profile.description')}
</Text>
<form onSubmit={handleSubmit((data) => updateMutation.mutate(data))}>
<Stack gap="md">
{/* Nationality */}
<Controller
name="nationality"
control={control}
render={({ field }) => (
<CountrySearch
label={t('jobs.profile.nationality.label')}
placeholder={t('jobs.profile.nationality.placeholder')}
value={field.value}
onChange={field.onChange}
error={errors.nationality?.message}
/>
)}
/>
{/* Current Residence */}
<Controller
name="currentResidence"
control={control}
render={({ field }) => (
<CountrySearch
label={t('jobs.profile.residence.label')}
placeholder={t('jobs.profile.residence.placeholder')}
value={field.value}
onChange={field.onChange}
error={errors.currentResidence?.message}
/>
)}
/>
{/* Professional Recognition Status */}
<Controller
name="professionalRecognitionStatus"
control={control}
render={({ field }) => (
<EnumSelect
label={t('jobs.profile.recognitionstatus.label')}
enumObject={ProfessionalRecognitionStatuses}
enumName='professionalRecognitionStatus'
value={field.value}
onChange={field.onChange}
error={errors.professionalRecognitionStatus?.message} />
)}
/>
{/* Languages Spoken */}
<Box>
<Text size="sm" fw={500} mb="xs">{t('jobs.profile.languages.label')}</Text>
<Controller
name="languagesSpoken"
control={control}
render={({ field }) => (
<Checkbox.Group
value={field.value}
onChange={field.onChange}
error={errors.languagesSpoken?.message}
>
<SimpleGrid cols={3} spacing="sm">
{availableLanguages.map((lang) => (
<Checkbox
key={lang.value}
value={lang.value}
label={lang.label}
/>
))}
</SimpleGrid>
</Checkbox.Group>
)}
/>
</Box>
{/* CV Upload */}
<Box>
<Text size="sm" fw={500} mb="xs">{t('jobs.profile.cv.label')}</Text>
<Text size="sm" c="dimmed" mb="sm">
{t('jobs.profile.cv.description')}
</Text>
<FileUpload
files={candidate?.workExperience?.map((file: DB.CandidateDocument) => ({
id: file.documentId,
fileName: file.fileName
})) || []}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
{/* Submit Button */}
<Button
type="submit"
size="lg"
w='100%'
mt="md"
loading={isSaving}
>
{t('jobs.profile.savebutton')}
</Button>
</Stack>
</form>
</>
)
}
export default ApplicantProfile

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import {
Box,
Container,
Grid,
Stack,
Text,
Group,
Button,
rem,
Drawer,
Alert,
} from '@pikku/mantine/core'
import { IconInfoCircle } from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import GetHelp from './GetHelp'
import ApplicantProfile from './ApplicantProfile'
import ConnectWithEmployers from './ConnectWithEmployers'
interface CTASectionProps {
joinTalentPool: boolean
publicMode?: boolean
}
export const CTASection: React.FunctionComponent<CTASectionProps> = ({
joinTalentPool,
publicMode = false,
}) => {
const t = useI18n()
const [drawerOpened, setDrawerOpened] = useState(false)
const [drawerTitle, setDrawerTitle] = useState('')
const [drawerContent, setDrawerContent] = useState<'getHelp' | 'connect'>('connect')
const openDrawer = (contentType: 'getHelp' | 'connect', title: string) => {
setDrawerTitle(title)
setDrawerContent(contentType)
setDrawerOpened(true)
}
const renderDrawerContent = () => {
switch (drawerContent) {
case 'getHelp':
return <GetHelp />
case 'connect':
if (publicMode) {
return <ConnectWithEmployers />
}
return (
<Stack gap="md">
{!joinTalentPool && (
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
<Text size="sm" fw={500}>
{t('jobs.improveyourprofile.optin.title')}
</Text>
<Text size="sm" mt="xs">
{t('jobs.improveyourprofile.optin.description')}
</Text>
</Alert>
)}
<ApplicantProfile />
</Stack>
)
default:
return null
}
}
return (
<>
<Box bg="white" py="xl">
<Container size="xl" p={0}>
<Grid gutter={0}>
<Grid.Col span={{ base: 12, lg: 6 }}>
<Stack gap="xl" p="xl" me={rem(40)}>
<Stack gap="xs">
<Text size="xxxl" fw={700} lh={1.2}>
{t('jobs.ctasection.ready.prefix')}
</Text>
<Text size="xxxl" fw={700} c="primary" lh={1.2}>
{t('jobs.ctasection.ready.suffix')}
</Text>
</Stack>
<Text size="xl" maw={rem(500)}>
{t('jobs.ctasection.description')}
</Text>
<Group>
<Button
size="md"
color="primary"
onClick={() => openDrawer('connect', t('jobs.ctasection.buttons.connect'))}
>
{t('jobs.ctasection.buttons.connect')}
</Button>
<Button
size="md"
variant="outline"
color="gray"
onClick={() => openDrawer('getHelp', t('jobs.ctasection.buttons.contact'))}
>
{t('jobs.ctasection.buttons.contact')}
</Button>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 6 }}>
<Box pos="relative" h={{ base: '300px', lg: '100%' }}>
<img
src="https://media.istockphoto.com/id/998313080/photo/smiling-medical-team-standing-together-outside-a-hospital.jpg?s=612x612&w=0&k=20&c=fXzbjAoStQ_8jTM4TQxbHBEjhETI3vq5_7d_JL19eCA="
alt="Smiling medical team standing together outside a hospital"
style={{
height: '100%',
width: '100%',
objectFit: 'cover',
objectPosition: 'center',
clipPath: 'polygon(12% 0, 100% 0%, 100% 100%, 0 100%)',
}}
/>
</Box>
</Grid.Col>
</Grid>
</Container>
</Box>
<Drawer
opened={drawerOpened}
onClose={() => setDrawerOpened(false)}
title={asI18n(drawerTitle)}
size="lg"
position="right"
>
{renderDrawerContent()}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,61 @@
import { Stack, Text, List, Divider } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { mKey } from '@/i18n/messages'
import { compareRecognitionOptionKeys } from '@/lib/content/compare-recognition-options'
export default function CompareOptionsRecognition() {
const t = useI18n()
// Structure (which options, ordering) is data; text comes from messages.
const options = compareRecognitionOptionKeys.map((key) => ({
key,
name: mKey(`jobs.compareoptionsrecognition.options.${key}.name`),
description: mKey(`jobs.compareoptionsrecognition.options.${key}.description`),
duration: mKey(`jobs.compareoptionsrecognition.options.${key}.duration`),
}))
return (
<Stack gap="md">
<Text mt="md" mb="md">
{t('jobs.compareoptionsrecognition.context')}
</Text>
<Text fw="bold" mb="xs">
{t('jobs.compareoptionsrecognition.choose')}
</Text>
<List withPadding spacing="xs" mb="md">
{options.map((option: any) => (
<List.Item key={option.key}>
<Text fw="bold">{option.name}:</Text> {option.description}
</List.Item>
))}
</List>
<Divider />
<Text fw="bold" mt="md" mb="xs">
{t('jobs.compareoptionsrecognition.duration')}
</Text>
<List withPadding spacing="xs" mb="md">
{options.map((option: any) => (
<List.Item key={option.key + '-duration'}>
<Text fw="bold">{option.name}:</Text> {option.duration}
</List.Item>
))}
</List>
<Text mt="md">
{t('jobs.compareoptionsrecognition.requirementsaftercompletion')}
</Text>
<Text fw="bold" mt="md">
{t('jobs.compareoptionsrecognition.professionaltitleintro')}
</Text>
<Text fw="bold" fz="md">
{t('jobs.compareoptionsrecognition.professionaltitle')}
</Text>
<Text mt="sm" lineBreaks>{t('jobs.compareoptionsrecognition.outcome')}</Text>
</Stack>
)
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { Checkbox, Stack, Text, Group } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
export default function ConnectWithEmployers() {
const t = useI18n()
const [joinTalentPool, setJoinTalentPool] = useState(false)
useEffect(() => {
fetch('/json/user.json')
.then((response) => response.json())
.then((data) => {
setJoinTalentPool(data.joinTalentPool ?? false)
})
.catch(() => {
console.error('Failed to load user data, using default values.')
})
}, [])
const handleCheckboxChange = (checked: boolean) => {
setJoinTalentPool(checked)
localStorage.setItem('joinTalentPool', JSON.stringify(checked))
}
return (
<Stack gap="md">
<Text mb="md" lineBreaks>{t('jobs.connectwithemployers.description')}</Text>
<Group gap="xs" align="center" mt="md">
<Checkbox
id="joinTalentPool"
checked={joinTalentPool}
onChange={(e) => handleCheckboxChange(e.target.checked)}
label={t('jobs.connectwithemployers.jointalentpool')}
size="md"
/>
</Group>
</Stack>
)
}

View File

@@ -0,0 +1,99 @@
import {
Stack,
Text,
List,
Divider,
Anchor,
Card,
Title,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { mList } from '@/i18n/messages'
import { asI18n } from '@pikku/react'
interface DocumentsChecklistProps {
openGetHelpDrawer: () => void
}
export default function DocumentsChecklist({
openGetHelpDrawer,
}: DocumentsChecklistProps) {
const t = useI18n()
return (
<Stack gap="lg" mt="md">
<Text lineBreaks>{t('jobs.documentschecklist.intro.part1')}</Text>
<Text lineBreaks>{t('jobs.documentschecklist.intro.part2')}</Text>
<Card withBorder padding="md" radius="sm">
<Text size="sm">
{t('jobs.documentschecklist.intro.help')}
<Anchor
c="primary"
fw={700}
td="underline"
onClick={() => {
openGetHelpDrawer()
}}
>
{asI18n('here')}
</Anchor>
</Text>
</Card>
<Divider />
<Title order={4}>
{t('jobs.documentschecklist.submitoriginal.title')}
</Title>
<List withPadding spacing="xs">
{mList('jobs.documentschecklist.submitoriginal.items').map(
(item, idx) => (
<List.Item key={'original-' + idx}>{item}</List.Item>
)
)}
</List>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.submitcopies.title')}</Title>
<List withPadding spacing="xs">
{mList('jobs.documentschecklist.submitcopies.items').map(
(item, idx) => (
<List.Item key={'copy-' + idx}>{item}</List.Item>
)
)}
</List>
<Card withBorder padding="md" radius="sm">
<Text size="sm" lineBreaks>{t('jobs.documentschecklist.translationinfo')}</Text>
</Card>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.advice.title')}</Title>
<Text lineBreaks>{t('jobs.documentschecklist.advice.part1')}</Text>
<Text lineBreaks>{t('jobs.documentschecklist.advice.part2')}</Text>
<Divider />
<Title order={4}>{t('jobs.documentschecklist.contact.title')}</Title>
<Text>
<strong>{asI18n('Address:')}</strong>
<br />
{t('jobs.documentschecklist.contact.address')}
</Text>
<Text>
<strong>{asI18n('Email:')}</strong>{asI18n(' ')}
<Anchor
href={`mailto:${t('jobs.documentschecklist.contact.email')}`}
c="primary"
fw={700}
>
{t('jobs.documentschecklist.contact.email')}
</Anchor>
</Text>
<Text>
<strong>{asI18n('Phone:')}</strong>{asI18n(' ')}{t('jobs.documentschecklist.contact.phone')}
</Text>
<Text>
<em>{t('jobs.documentschecklist.contact.hours')}</em>
</Text>
</Stack>
)
}

View File

@@ -0,0 +1,65 @@
import {
Stack,
Text,
Title,
List,
Anchor,
Table,
Divider,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { paidCourseProviders as providers } from '@/lib/content/paid-course-providers'
import { freeCourseLinks as freeLinks } from '@/lib/content/free-course-links'
export default function GermanLanguageTips() {
const t = useI18n()
return (
<Stack gap="md" mt="md">
<Text lineBreaks>{t('jobs.germanlanguagetips.tip')}</Text>
<Title order={3}>{t('jobs.germanlanguagetips.freecourses.title')}</Title>
<List>
{freeLinks.map((link, idx) => (
<List.Item key={idx}>
<Anchor href={link.url} target="_blank" rel="noopener noreferrer">
{asI18n(link.name)}
</Anchor>
</List.Item>
))}
</List>
<Text lineBreaks>{t('jobs.germanlanguagetips.b1congrats')}</Text>
<Text lineBreaks>{t('jobs.germanlanguagetips.b2requirement')}</Text>
<Divider />
<Title order={3}>{t('jobs.germanlanguagetips.paidcourses.title')}</Title>
<Table withTableBorder withColumnBorders striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Provider</Table.Th>
<Table.Th>Levels</Table.Th>
<Table.Th>Format</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{providers.map((course, idx) => (
<Table.Tr key={idx}>
<Table.Td>{course.provider}</Table.Td>
<Table.Td>{course.levels}</Table.Td>
<Table.Td>{course.format}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Text lineBreaks>{t('jobs.germanlanguagetips.paidcourses.fundingintro')}</Text>
{/* TODO(i18n): the `jobs.germanlanguagetips.fundingoptions` source cell is
corrupt JSON in the Google Sheet (truncated, unescaped quotes), so this
section has rendered empty in all locales. Restore as a TS content
module + messages once the source content is recovered. */}
</Stack>
)
}

View File

@@ -0,0 +1,204 @@
import { IconExternalLink } from '@tabler/icons-react'
import { Stack, Text, List, Divider, Group, Anchor } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export default function GetHelp() {
const t = useI18n()
return (
<Stack gap="md">
<Text mt="md" mb="md">
{t('jobs.gethelp.description')}
</Text>
<List withPadding spacing="xs" mb="md">
<List.Item>{t('jobs.gethelp.services.explainprocess')}</List.Item>
<List.Item>{t('jobs.gethelp.services.helpdocuments')}</List.Item>
<List.Item>{t('jobs.gethelp.services.reviewapplication')}</List.Item>
<List.Item>{t('jobs.gethelp.services.supportcommunication')}</List.Item>
<List.Item>{t('jobs.gethelp.services.adviserequirements')}</List.Item>
</List>
<Text mb="md" lineBreaks>{t('jobs.gethelp.support')}</Text>
<Stack gap="md">
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.dare.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.dare.phone')}: +49 (0)175 2264572`)}</Text>
<Text lineBreaks>{asI18n('Whatsapp: +49-17163442306')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.dare.email')}: `)}
<Anchor href="mailto:info@dareconsulting.de" c="primary" fw={700}>
{asI18n('info@dareconsulting.de')}
</Anchor>
</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://dareconsulting.de/de/services/#BBeFaP"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.dare.languages')}: English, German, Spanish`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.bamf.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.bamf.phone')}: +49 (0) 30 1815 1111`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.anerkennung-in-deutschland.de/en/contact/recognition"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.contactform')}
</Anchor>
</Group>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.make-it-in-germany.com/en/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.bamf.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.bamf.languages')}: English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.gesbit.name')}</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.gesbit.phone')}: +49 (0)30 / 31510900`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://gesbit.de/arbeitsmarkt-und-beschaeftigung/hotline-anerkennung-beruflicher-qualifikationen/#c2712"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.gesbit.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.gesbit.languages')}: Turkish, Ukrainian, English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.clubdialog.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.clubdialog.email')}: `)}
<Anchor
href="mailto:anerkennung@club-dialog.de"
c="primary"
fw={700}
>
{asI18n('anerkennung@club-dialog.de')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.clubdialog.phone')}: +49 (0)30 / 263 476 05`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://anerkennung-berufsqualifikationen-ua.de/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.clubdialog.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.clubdialog.languages')}: Russian, Polish, Ukrainian, English, German`)}</Text>
</Stack>
<Stack
gap="xs"
pb="md"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text fw="bold">{t('jobs.gethelp.organizations.lared.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.lared.email')}: `)}
<Anchor href="mailto:anerkennung@la-red.eu" c="primary" fw={700}>
{asI18n('anerkennung@la-red.eu')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.lared.phone')}: +49 (0)30 / 457 989 555`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://la-red.eu/lara-assessment-and-recognition-of-foreign-qualifications/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.lared.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.lared.languages')}: Spanish, French, Italian, Arabic, English, German`)}</Text>
</Stack>
<Stack gap="xs">
<Text fw="bold">{t('jobs.gethelp.organizations.tbb.name')}</Text>
<Text size="sm">
{asI18n(`${t('jobs.gethelp.organizations.tbb.email')}: `)}
<Anchor href="mailto:diploma@tbb-berlin.de" c="primary" fw={700}>
{asI18n('diploma@tbb-berlin.de')}
</Anchor>
</Text>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.tbb.phone')}: +49 (0)30 / 236 233 25`)}</Text>
<Group gap="xs" align="center">
<IconExternalLink size={16} />
<Anchor
href="https://www.tbb-berlin.de/beratungsangebote/consulting-services/"
target="_blank"
rel="noopener noreferrer"
size="sm"
c="primary"
fw={700}
>
{t('jobs.gethelp.organizations.tbb.website')}
</Anchor>
</Group>
<Text lineBreaks>{asI18n(`${t('jobs.gethelp.organizations.tbb.languages')}: Turkish, Arabic, Russian, English, German`)}</Text>
</Stack>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,56 @@
import { Stack, Text, Divider, Anchor, Title, Card } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export default function HowToApply() {
const t = useI18n()
return (
<Stack gap="md" mt="md">
<Text lineBreaks>{t('jobs.howtoapply.intro1')}</Text>
<Text>
{asI18n(`${t('jobs.howtoapply.applicationform')} `)}
<Anchor
href="/pdf/Application_Form_Nurse.pdf"
target="_blank"
rel="noopener noreferrer"
c="primary"
td="underline"
fw={700}
>
{asI18n('here')}
</Anchor>
</Text>
<Text lineBreaks>{t('jobs.howtoapply.reminder')}</Text>
<Text lineBreaks>{t('jobs.howtoapply.submission')}</Text>
<Divider />
<Title order={4}>{t('jobs.howtoapply.contacttitle')}</Title>
<Text lineBreaks>{t('jobs.howtoapply.contact.address')}</Text>
<Text>
{asI18n('Email: ')}
<Anchor
href={`mailto:${t('jobs.howtoapply.contact.email')}`}
c="primary"
fw={700}
>
{t('jobs.howtoapply.contact.email')}
</Anchor>
</Text>
<Text>{asI18n(`Phone: ${t('jobs.howtoapply.contact.phone')}`)}</Text>
<Text lineBreaks>{t('jobs.howtoapply.contact.hours')}</Text>
<Card withBorder padding="md" radius="sm" mt="md">
<Text lineBreaks>{t('jobs.howtoapply.tip')}</Text>
</Card>
<Text lineBreaks>{t('jobs.howtoapply.waiver')}</Text>
<Divider />
<Text lineBreaks>{t('jobs.howtoapply.counseling')}</Text>
</Stack>
)
}

View File

@@ -0,0 +1,231 @@
import { useState } from 'react'
import {
Button,
Text,
Drawer,
Stack,
} from '@pikku/mantine/core'
import {
IconInfoCircle,
IconBriefcase,
IconFile,
IconCircleCheck,
IconLanguage,
IconExternalLink,
} from '@tabler/icons-react'
import GetHelp from './GetHelp'
import ConnectWithEmployers from './ConnectWithEmployers'
import CompareOptionsRecognition from './CompareOptionsRecognition'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import DocumentsChecklist from './DocumentsChecklist'
import HowToApply from './HowToApply'
import GermanLanguageTips from './GermanLanguageTips'
import ApplicantProfile from './ApplicantProfile'
import { ExpandableSteps, type ExpandableStep } from '@/components/common/ExpandableSteps'
import { useMantineTheme } from '@pikku/mantine/core'
import { NextSteps as NextStepsType } from '@heygermany/sdk'
interface NextStepsProps {
joinTalentPool: boolean
nextSteps: NextStepsType | null
isHelper?: boolean
}
interface NextStepContentProps {
subtitle?: string
costs?: string
textButton?: string
onButtonClick?: () => void
}
function NextStepContent({ subtitle, costs, textButton, onButtonClick }: NextStepContentProps) {
const theme = useMantineTheme()
return (
<Stack justify='center' align='center' ta='start'>
{subtitle && (
<Text
c="dimmed"
fz="lg"
mt="md"
>
{asI18n(subtitle)}
</Text>
)}
{costs && (
<Text
c={`${theme.colors.gray[8]}`}
fw={400}
fz="md"
mt="md"
mb="lg"
>
{asI18n(costs)}
</Text>
)}
{textButton && onButtonClick && (
<Button
leftSection={<IconExternalLink size={24} />}
size="md"
onClick={(e) => {
e.stopPropagation()
onButtonClick()
}}
>
{asI18n(textButton)}
</Button>
)}
</Stack>
)
}
export default function NextSteps({ joinTalentPool, nextSteps, isHelper = false }: NextStepsProps) {
const t = useI18n()
const [drawerOpened, setDrawerOpened] = useState(false)
const [drawerTitle, setDrawerTitle] = useState('')
const [drawerContent, setDrawerContent] = useState<
'getHelp' | 'connect' | 'documents' | 'apply' | 'compensatory' | 'language'
>('getHelp')
// Handle missing next steps data
if (!nextSteps) {
console.error('[NextSteps] No next steps data provided from backend')
return null
}
const openDrawer = (contentType: 'getHelp' | 'connect' | 'documents' | 'apply' | 'compensatory' | 'language', title: string) => {
setDrawerTitle(title)
setDrawerContent(contentType)
setDrawerOpened(true)
}
// For helpers, only show: gethelp, connect, and conditionally german
// For others, show all available steps
const allSteps: ExpandableStep[] = [
{
title: nextSteps.gethelp.title,
icon: <IconInfoCircle size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.gethelp.subtitle}
costs={nextSteps.gethelp.costs}
textButton={nextSteps.gethelp.button}
onButtonClick={() => openDrawer('getHelp', nextSteps.gethelp.title)}
/>
),
},
{
title: nextSteps.connect.title,
icon: <IconBriefcase size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.connect.subtitle}
costs={nextSteps.connect.costs}
textButton={joinTalentPool
? nextSteps.connect.buttonimprove
: nextSteps.connect.buttonjoin}
onButtonClick={() => openDrawer('connect', nextSteps.connect.title)}
/>
),
},
...(!isHelper && nextSteps.documents ? [{
title: nextSteps.documents.title,
icon: <IconFile size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.documents?.subtitle}
costs={nextSteps.documents?.costs}
textButton={nextSteps.documents?.button}
onButtonClick={() => openDrawer('documents', nextSteps.documents!.title)}
/>
),
}] : []),
...(!isHelper && nextSteps.submit ? [{
title: nextSteps.submit.title,
icon: <IconFile size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.submit?.subtitle}
costs={nextSteps.submit?.costs}
textButton={nextSteps.submit?.button}
onButtonClick={() => openDrawer('apply', nextSteps.submit!.title)}
/>
),
}] : []),
...(!isHelper && nextSteps.compensatory ? [{
title: nextSteps.compensatory.title,
icon: <IconCircleCheck size={38} />,
content: () => (
<NextStepContent
subtitle={nextSteps.compensatory?.subtitle}
costs={nextSteps.compensatory?.costs}
textButton={nextSteps.compensatory?.button}
onButtonClick={() => openDrawer('compensatory', nextSteps.compensatory!.title)}
/>
),
}] : []),
...(nextSteps.german ? [{
title: nextSteps.german.title,
icon: <IconLanguage size={36} />,
content: () => (
<NextStepContent
subtitle={nextSteps.german?.subtitle}
costs={nextSteps.german?.costs}
textButton={nextSteps.german?.button}
onButtonClick={() => openDrawer('language', nextSteps.german!.title)}
/>
),
}] : []),
]
const renderDrawerContent = () => {
switch (drawerContent) {
case 'getHelp':
return <GetHelp />
case 'connect':
return joinTalentPool ? <ApplicantProfile /> : <ConnectWithEmployers />
case 'documents':
return (
<DocumentsChecklist
openGetHelpDrawer={() => openDrawer('getHelp', nextSteps.gethelp.title)}
/>
)
case 'apply':
return <HowToApply />
case 'compensatory':
return <CompareOptionsRecognition />
case 'language':
return <GermanLanguageTips />
default:
console.error(`[NextSteps] Unknown drawer content type: ${drawerContent}`)
return (
<Text c="dimmed" p="md">
{asI18n('Content not available')}
</Text>
)
}
}
return (
<>
<ExpandableSteps
steps={allSteps}
defaultExpanded={[0]}
singleExpand={true}
showTitle={true}
title={t('jobs.nextsteps.title')}
/>
<Drawer
opened={drawerOpened}
onClose={() => setDrawerOpened(false)}
title={asI18n(drawerTitle)}
size="lg"
position="right"
>
{renderDrawerContent()}
</Drawer>
</>
)
}

View File

@@ -0,0 +1,150 @@
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import { LanguageLevels } from '@heygermany/sdk'
import {
Stack,
Title,
Text,
Box,
Paper,
Progress,
List,
ThemeIcon,
Flex,
rem,
useMantineTheme,
} from '@pikku/mantine/core'
import {
IconCircleCheck,
IconExclamationCircle,
IconClock,
IconInfoCircle,
IconCircle,
} from '@tabler/icons-react'
interface QualificationProps {
title: string
description: string
status: string
requirements: string[]
steps?: string[]
progress?: number
fillHeight?: boolean
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'green'
case 'required':
return 'red'
case 'in-progress':
return 'orange'
case 'optional':
return 'blue'
default:
return 'gray'
}
}
const getStatusIcon = (status: string) => {
const iconProps = { size: 28, color: getStatusColor(status) }
switch (status) {
case 'completed':
return <IconCircleCheck {...iconProps} />
case 'required':
return <IconExclamationCircle {...iconProps} />
case 'in-progress':
return <IconClock {...iconProps} />
case 'optional':
return <IconInfoCircle {...iconProps} />
default:
return <IconCircle {...iconProps} />
}
}
export const Qualification: React.FunctionComponent<QualificationProps> = ({
title,
description,
status,
requirements,
steps,
progress,
fillHeight = true,
}) => {
const t = useI18n()
const theme = useMantineTheme()
function translateLevels(req: string): string {
for (const key of Object.keys(LanguageLevels)) {
req = req.replace(key, t(`enums.languagelevel.${key}`.toLowerCase() as any))
}
return req
}
return (
<Paper flex={1} withBorder p="lg" radius="lg" h={fillHeight ? '100%' : undefined}>
<Stack flex={1} gap={0}>
<Flex align="center" gap="xs" mb={5}>
<ThemeIcon variant="transparent" color={getStatusColor(status)}>
{getStatusIcon(status)}
</ThemeIcon>
<Text size="xl">{asI18n(title)}</Text>
</Flex>
<Flex mb="md" gap="sm" align="center">
<Text component="span" fw="600" c={getStatusColor(status)}>
{t(`enums.documentstatus.${status}` as any)}
</Text>
{!isNaN(progress!) && (
<Progress.Root flex={1} h="md" radius="xl">
<Progress.Section value={progress!}>
<Progress.Label c="dimmed" fz="sm" fw={700}>
{progress}%
</Progress.Label>
</Progress.Section>
</Progress.Root>
)}
</Flex>
<Text size="lg" c="dimmed" mb="md">
{asI18n(description)}
</Text>
{requirements && (
<Box mb="md">
<Text size="lg" fw={500} mb="xs">
{asI18n(`${t('jobs.qualification.requirements')}:`)}
</Text>
<List pl="md" size="md">
{requirements.map((req, reqIndex) => (
<List.Item key={reqIndex}>
<Text c={`${theme.colors.gray[7]}`} fz="md" fw={300}>
{asI18n(translateLevels(req))}
</Text>
</List.Item>
))}
</List>
</Box>
)}
{(steps?.length && steps.length > 0) ? (
<Box mb="md">
<Text component='h4' size="lg" fw={500} mb="xs">
{asI18n(`${t('jobs.qualification.nextsteps')}:`)}
</Text>
<List pl="md" type="ordered" size="md">
{steps.map((step, stepIndex) => (
<List.Item key={stepIndex}>
<Text c={`${theme.colors.gray[7]}`} fz="md" fw={300}>
{asI18n(translateLevels(step))}
</Text>
</List.Item>
))}
</List>
</Box>
) : null}
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,80 @@
import {
Paper,
Text,
Flex,
ThemeIcon,
rem,
useMantineTheme,
Stack,
} from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
import {
IconCircleCheck,
IconExclamationCircle,
IconClock,
IconInfoCircle,
IconCircle,
} from '@tabler/icons-react'
interface QualificationSectionStatusCardProps {
label: string
status: 'completed' | 'in-progress' | 'required' | 'optional'
color: string
icon: React.ReactNode
}
const getStatusIcon = (
status: QualificationSectionStatusCardProps['status'],
color: string
) => {
const iconProps = { size: 20, color }
switch (status) {
case 'completed':
return <IconCircleCheck {...iconProps} />
case 'required':
return <IconExclamationCircle {...iconProps} />
case 'in-progress':
return <IconClock {...iconProps} />
case 'optional':
return <IconInfoCircle {...iconProps} />
default:
return <IconCircle {...iconProps} />
}
}
export const QualificationSectionStatusCard: React.FC<
QualificationSectionStatusCardProps
> = ({ label, status, color, icon }) => {
const theme = useMantineTheme()
const t = useI18n()
return (
<Paper withBorder shadow="xs" p="md" radius="md" mb="xl" w={rem(220)}>
<Stack>
<Flex justify='space-between'>
<Text size="lg" c="dimmed" mb="lg">
{asI18n(label.split(' ')[0] ?? '')}
</Text>
<ThemeIcon
size="lg"
radius="xl"
variant="outline"
bg={color}
color='white'
p={4}
>
{icon}
</ThemeIcon>
</Flex>
<Flex align="center">
<ThemeIcon variant="transparent" size="md" radius="md">
{getStatusIcon(status, `${theme.colors.gray[7]}`)}
</ThemeIcon>
<Text size="xl" fw={600}>
{t(`enums.documentstatus.${status}` as any)}
</Text>
</Flex>
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,187 @@
import {
Container,
Stack,
Group,
Paper,
ThemeIcon,
Box,
Grid,
Flex,
Text,
rem,
useMantineTheme,
} from '@pikku/mantine/core'
import {
IconIdBadge2,
IconWorld,
IconBriefcase,
IconSchool,
} from '@tabler/icons-react'
import { Qualification } from './Qualification'
import { QualificationSectionStatusCard } from './QualificationSectionStatusCard'
import { CandidateResults } from '@heygermany/sdk'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
interface QualificationData {
title: string
description: string
status: string
requirements: string[]
steps?: string[]
progress?: number
}
type SectionOut = {
key: "education" | "license" | "language" | "additional"
title: string
color: "education" | "license" | "language" | "additional"
icon: string
qualifications: QualificationData[]
}
const ICONS: Record<string, React.ReactElement> = {
school: <IconSchool size={38} />,
"id-badge": <IconIdBadge2 size={38} />,
world: <IconWorld size={38} />,
briefcase: <IconBriefcase size={38} />,
};
const getQualificationSectionStatus = (
title: string,
qualifications: QualificationData[]
) => {
const statuses = qualifications.map((q) => q.status)
if (statuses.includes('in-progress')) return 'in-progress'
if (statuses.every((s) => s === 'completed')) return 'completed'
if (statuses.every((s) => s === 'required')) return 'required'
if (statuses.includes('optional')) return 'optional'
return 'required'
}
function buildSections(candidateResults: CandidateResults, t: any): SectionOut[] {
return [
{
key: "education",
title: t('enums.qualificationsections.education'),
color: "education",
icon: "school",
qualifications: [candidateResults.education],
},
{
key: "license",
title: t('enums.qualificationsections.license'),
color: "license",
icon: "id-badge",
qualifications: [candidateResults.license],
},
{
key: "language",
title: t('enums.qualificationsections.language'),
color: "language",
icon: "world",
qualifications: [candidateResults.language],
},
{
key: "additional",
title: t('enums.qualificationsections.additional'),
color: "additional",
icon: "briefcase",
qualifications: [candidateResults.experience, candidateResults.copies, candidateResults.originals],
},
]
}
export const QualificationsSection: React.FunctionComponent<{ candidateResults: CandidateResults }> = ({ candidateResults }) => {
const t = useI18n()
const sections = buildSections(candidateResults, t)
const theme = useMantineTheme()
const mainSections = sections.slice(0, 3) // Education, License, Language
const additionalSection = sections[3] // Additional Requirements
return (
<>
<Container size="xl" py="xl" maw="full">
<Stack gap="xl">
<Flex wrap="wrap" gap="md" justify="center">
{sections.map((section, i) => (
<QualificationSectionStatusCard
key={i}
label={section.title}
status={section.color === 'additional' ? 'required' : getQualificationSectionStatus(section.title, section.qualifications)}
color={`var(--mantine-color-${section.color}-6)`}
icon={ICONS[section.icon]}
/>
))}
</Flex>
</Stack>
</Container>
<Box bg={theme.colors.gray[0]}>
<Container bg={theme.colors.gray[0]} size="xl" py="xl" maw="full">
<Grid mt="xl" grow>
{mainSections.map((section, sectionIndex) => (
<Grid.Col key={sectionIndex} span={{ base: 12, md: 4 }} mt="xl">
<Group align="center" mb="lg">
<ThemeIcon variant="transparent" color={section.color} size="xl" radius="md">{ICONS[section.icon]}</ThemeIcon>
<Text c={`${theme.colors.gray[7]}`} size="xl" fw={700}>
{asI18n(section.title)}
</Text>
</Group>
<Flex
gap="md"
flex={1}
align="stretch"
h='full'
>
{section.qualifications.map((qualification, qualIndex) => (
<Qualification
key={qualIndex}
title={qualification.title}
description={qualification.description}
status={qualification.status}
requirements={qualification.requirements}
steps={qualification.steps}
progress={qualification.progress}
/>
))}
</Flex>
</Grid.Col>
))}
</Grid>
<Box mb="md" mt={rem(80)}>
<Group align="center" mb="lg" gap="sm">
{ICONS[additionalSection.icon]}
<Text fw={700} size="xl">
{asI18n(additionalSection.title)}
</Text>
</Group>
<Grid>
{additionalSection.qualifications.map(
(qualification, qualIndex) => (
<Grid.Col key={qualIndex} span={{ base: 12, md: 4 }}>
<Paper shadow="xs" radius="lg" h="100%">
<Qualification
title={qualification.title}
description={qualification.description}
status={qualification.status}
requirements={qualification.requirements}
steps={qualification.steps}
progress={qualification.progress}
/>
</Paper>
</Grid.Col>
)
)}
</Grid>
</Box>
</Container>
</Box>
</>
)
}