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:35:04 +02:00
commit 3bb535efe8
394 changed files with 38048 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
import React from 'react'
import { Controller, useFormContext, useWatch } from 'react-hook-form'
import { GermanStates } from '@heygermany/sdk'
import { MultiSelect, Radio, Stack, Title, Group, Box, Input, Alert } from '@pikku/mantine/core'
import { DateInput } from '@mantine/dates'
import { IconInfoCircle } from '@tabler/icons-react'
import { useI18n } from '@/context/i18n-provider'
import { CountrySearch } from './CountrySearch'
import { SUPPORTED_COUNTRIES } from '@/config'
export const BasicInfo: React.FunctionComponent = () => {
const t = useI18n()
const { control, formState: { errors } } = useFormContext()
const countryEducation = useWatch({ control, name: 'countryEducation' })
const isUnsupportedCountry = countryEducation && !SUPPORTED_COUNTRIES.includes(countryEducation)
const germanStates = Object.values(GermanStates)
return (
<Stack gap="lg">
<Title order={2}>{t('jobs.apply.steps.basicinfo')}</Title>
{/* German States Multi-Select */}
<Box>
<Controller
name="stateWorkPreference"
control={control}
rules={{ required: t('jobs.basicinfo.validation.staterequired') }}
render={({ field }) => (
<MultiSelect
label={t('jobs.basicinfo.worklocation.question')}
value={field.value || []}
onChange={(newValue) => {
if (newValue.includes('ALL')) {
// If ALL is selected, select all states
if (!field.value?.includes('ALL')) {
field.onChange(germanStates)
} else {
// If ALL is already selected and user clicks it again, deselect all
field.onChange([])
}
} else {
field.onChange(newValue)
}
}}
data={[
{ value: 'ALL', label: t('jobs.basicinfo.worklocation.selectall')},
...germanStates.map(state => ({ value: state, label: state }))
]}
placeholder={t('jobs.basicinfo.worklocation.placeholder')}
searchable
clearable
error={errors.stateWorkPreference?.message as string}
w="100%"
/>
)}
/>
</Box>
{/* Open to Work */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.jointalentpool.question')}>
<Controller
name="joinTalentPool"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.joinTalentPool?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* Living in Germany */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.livingingermany.question')}>
<Controller
name="livingInGermany"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.livingInGermany?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* EU Passport */}
<Box>
<Input.Wrapper label={t('jobs.basicinfo.eupassport.question')}>
<Controller
name="euPassport"
control={control}
rules={{
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
}}
render={({ field }) => (
<Radio.Group
value={field.value?.toString()}
onChange={(value) => field.onChange(value === 'true' ? true : false)}
error={errors.euPassport?.message as string}
>
<Group gap="lg" mb="xs">
<Radio value="true" label={t('jobs.basicinfo.yes')} />
<Radio value="false" label={t('jobs.basicinfo.no')} />
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
</Box>
{/* Date of Birth */}
<Box>
<Controller
name="dateOfBirth"
control={control}
rules={{
required: t('jobs.basicinfo.validation.dateofbirthrequired')
}}
render={({ field }) => (
<DateInput
label={t('jobs.basicinfo.dateofbirth.question')}
placeholder={t('jobs.basicinfo.dateofbirth.placeholder')}
value={field.value ? new Date(field.value) : null}
onChange={field.onChange}
error={errors.dateOfBirth?.message as string}
clearable
weekendDays={[]}
/>
)}
/>
</Box>
{/* Education Country */}
<Box>
<Controller
name="countryEducation"
control={control}
rules={{
required: t('jobs.basicinfo.validation.countryrequired'),
}}
render={({ field }) => (
<CountrySearch
value={field.value}
onChange={field.onChange}
label={t('jobs.basicinfo.education.question')}
placeholder={t('jobs.basicinfo.education.placeholder')}
error={errors.countryEducation?.message as string}
/>
)}
/>
{isUnsupportedCountry && (
<Alert
icon={<IconInfoCircle size={16} />}
title={t('jobs.basicinfo.unsupportedcountry.title')}
color="blue"
mt="sm"
>
{t('jobs.basicinfo.unsupportedcountry.message')}
</Alert>
)}
</Box>
</Stack>
)
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { Box, Stack, Title, Text, Flex, Container } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
interface CandidateSectionProps {
title: string
example: string
description: string
children: React.ReactNode
}
export const CandidateSection: React.FunctionComponent<CandidateSectionProps> = ({
title,
example,
description,
children
}) => {
return (
<Flex gap="md" direction={{ base: 'column', md: 'row' }}>
<Stack flex={1}>
<Title order={2}>{asI18n(title)}</Title>
<Text size="xl" mb="md">
{asI18n(example)}
</Text>
<Text>
{asI18n(description)}
</Text>
</Stack>
<Box flex={1}>
{children}
</Box>
</Flex>
)
}

View File

@@ -0,0 +1,41 @@
import React, { useMemo } from 'react'
import { Select } from '@pikku/mantine/core'
import { useGetCountries } from '@/hooks/useGetCountries'
interface CountrySearchProps {
value: string
onChange: (value: string) => void
error?: string
label?: any
placeholder?: any
}
export const CountrySearch: React.FunctionComponent<CountrySearchProps> = ({
value,
onChange,
error,
label,
placeholder
}) => {
const { data: countriesMap = {}, isLoading } = useGetCountries()
const countryOptions = useMemo(() => {
return Object.entries(countriesMap).map(([iso3, name]) => ({
value: iso3,
label: name
}))
}, [countriesMap])
return (
<Select
label={label as any}
value={value}
data={countryOptions}
onChange={(selectedValue) => onChange(selectedValue || '')}
placeholder={placeholder as any}
searchable
error={error as any}
disabled={isLoading && countryOptions.length === 0}
/>
)
}

View File

@@ -0,0 +1,19 @@
import { Stack, Title, Text, Container, Paper } from "@pikku/mantine/core"
import { useI18n } from '@/context/i18n-provider'
export const Done: React.FunctionComponent<{ name: string }> = ({ name }) => {
const t = useI18n()
return (
<Container p={0} size='md' mt='10%'>
<Paper p={{ base: '4rem', lg: '8rem' }} withBorder radius='lg'>
<Stack>
<Title order={2}>
{t('jobs.done.title', { name })}
</Title>
<Text size="xl" lineBreaks>{t('jobs.done.body')}</Text>
</Stack>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,142 @@
import { Controller, useFormContext } from 'react-hook-form'
import { Checkbox, Box, Text, Group, Stack, Title } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import {
ApplicationCandidateData,
ApplicationCandidateLanguage,
CandidateDocumentTypes,
LanguageLevels,
} from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useEffect } from 'react'
import { CandidateSection } from './CandidateSection'
import { useI18n } from '@/context/i18n-provider'
import { EnumSelect } from '@/components/common/EnumSelect'
import { asI18n } from '@pikku/react'
export const GermanLanguage: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, clearErrors, setValue, formState: { errors }, watch } = useFormContext<ApplicationCandidateLanguage>()
const languageCertificateProvided = watch('languageCertificateProvided')
const selfAssessment = watch('languageSelfAssessmentLevel')
useEffect(() => {
if (languageCertificateProvided === null) {
setValue('languageCertificateProvided', true)
} else if (languageCertificateProvided === true) {
setValue('languageSelfAssessmentLevel', null)
}
clearErrors()
}, [languageCertificateProvided])
useEffect(() => {
if (selfAssessment) {
clearErrors()
}
}, [selfAssessment])
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LANGUAGE, trigger)
const validateLanguages = (files?: any[], selfAssessment?: DB.LanguageLevel | null, certificateProvided?: boolean | null): boolean | string => {
if (certificateProvided) {
if (!files || files.length === 0) {
return t('jobs.germanlanguage.validation.certificaterequired')
}
if (files.length > 5) {
return t('jobs.germanlanguage.validation.maxfiles')
}
} else {
if (!selfAssessment) {
return t('jobs.germanlanguage.validation.selfassessmentrequired')
}
}
return true
}
return (
<CandidateSection
title={t('jobs.germanlanguage.title')}
example={t('jobs.germanlanguage.example')}
description={t('jobs.germanlanguage.description')}
>
<Stack gap="lg">
<Controller
name="languageCertificateProvided"
control={control}
render={({ field }) => (
<Group gap="xs" mb="sm">
<Checkbox
id="language-certificate-not-provided"
checked={field.value !== true}
onChange={() => field.onChange(!field.value)}
/>
<Text
size="sm"
style={{ cursor: 'pointer' }}
component="label"
htmlFor="language-certificate-not-provided"
>
{t('jobs.germanlanguage.nocertificate')}
</Text>
</Group>
)}
/>
{languageCertificateProvided && (
<Controller
name="languageCertificates"
control={control}
rules={{
validate: () => validateLanguages(candidate.languageCertificates, undefined, languageCertificateProvided)
}}
render={() => (
<FileUpload
files={candidate.languageCertificates.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
)}
/>
)}
{!languageCertificateProvided && (
<Box>
<Title order={3} size="lg" mb="md">{t('jobs.germanlanguage.selfassessment.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('jobs.germanlanguage.selfassessment.description')}
</Text>
<Controller
name="languageSelfAssessmentLevel"
control={control}
rules={{
validate: (value) => validateLanguages(undefined, value, languageCertificateProvided)
}}
render={({ field }) => (
<Box w="fit-content">
<EnumSelect
label={t('jobs.germanlanguage.selfassessment.levellabel')}
value={field.value}
onChange={field.onChange}
enumName='LanguageLevel'
enumObject={LanguageLevels}
placeholder={t('jobs.germanlanguage.selfassessment.placeholder')}
error={errors.languageSelfAssessmentLevel?.message}
/>
</Box>
)}
/>
</Box>
)}
{errors.languageCertificates && (
<Text size="sm" c="error">
{asI18n(errors.languageCertificates?.message ?? '')}
</Text>
)}
</Stack>
</CandidateSection>
)
}

View File

@@ -0,0 +1,25 @@
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
import { IconMailQuestion } from '@tabler/icons-react'
import { asI18n } from '@pikku/react'
export const HelpAffix: React.FunctionComponent = () => {
return (
<div style={{ position: 'absolute', bottom: 20, right: 20 }} >
<Tooltip label={asI18n('Need help? Contact us')} position="left">
<ActionIcon
component="a"
href="mailto:info@hey-germany.com"
size={50}
radius="xl"
variant="filled"
color="primary"
style={{
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
}}
>
<IconMailQuestion size={28} stroke={2} color="white" />
</ActionIcon>
</Tooltip>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Stack, Title, Container, Paper, Button, Text, List } from "@pikku/mantine/core"
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { pikku } from '@/pikku/http'
import { ApplicantStatuses } from '@heygermany/sdk'
import { asI18n } from '@pikku/react'
export const Invalid: React.FunctionComponent<{ name: string, candidateId: string }> = ({ name, candidateId }) => {
const queryClient = useQueryClient()
const resetApplicationMutation = useMutation({
mutationFn: async () => {
await pikku().patch('/candidate/:candidateId', {
candidateId,
status: ApplicantStatuses.INITIAL,
submittedAt: null
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['candidate'] })
}
})
return (
<Container p={0} size='md' mt={{ base: '2rem', md: '3rem' }}>
<Paper p={{ base: '1.5rem', md: '2rem', lg: '2.5rem' }} withBorder radius='lg' bg='gray.0'>
<Stack gap='lg'>
<Title order={2} c='gray.7' lh={1.2}>
{asI18n(`Hi ${name},`)}
</Title>
<Stack gap='xs'>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('Thanks for uploading your documents!')}
</Text>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('We&apos;ve reviewed your files, but unfortunately, one or more of them couldn&apos;t be processed for your personal evaluation.')}
</Text>
</Stack>
<Stack gap='xs'>
<Text size='lg' fw={600} c='gray.7' lh={1.3}>
{asI18n('This can happen if:')}
</Text>
<List spacing={2} size='lg' c='gray.7' withPadding style={{ lineHeight: 1.3 }}>
<List.Item>{asI18n('The document is incomplete or blurry')}</List.Item>
<List.Item>{asI18n('The content is unclear or unreadable')}</List.Item>
<List.Item>{asI18n('The file you uploaded is not related to the document we requested')}</List.Item>
</List>
</Stack>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation.')}
</Text>
<Button
onClick={() => resetApplicationMutation.mutate()}
loading={resetApplicationMutation.isPending}
disabled={resetApplicationMutation.isPending}
size="md"
mt='xs'
maw={280}
mx='auto'
variant='filled'
color='yellow.5'
c='black'
>
{asI18n('Upload documents again')}
</Button>
</Stack>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,93 @@
import { Controller, useFormContext } from 'react-hook-form'
import { Checkbox, Box, Text, Group } from '@pikku/mantine/core'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateLicense, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { useEffect } from 'react'
import { CandidateSection } from './CandidateSection'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export const License: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors }, watch, setValue } = useFormContext<ApplicationCandidateLicense>()
const licenseProvided = watch('licenseProvided')
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LICENSE, trigger)
const validateLicenses = (files: any[]): boolean | string => {
if (!files || files.length === 0) {
return t('jobs.license.validation.required')
}
if (files.length > 5) {
return t('jobs.license.validation.maxfiles')
}
return true
}
useEffect(() => {
if (licenseProvided === null) {
setValue('licenseProvided', true)
}
}, [licenseProvided])
useEffect(() => {
if (licenseProvided === false) {
trigger()
}
}, [licenseProvided])
return (
<CandidateSection
title={t('jobs.license.title')}
example={t('jobs.license.example')}
description={t('jobs.license.description')}
>
<Box>
<Controller
name="licenseProvided"
control={control}
render={({ field }) => (
<Group gap="xs" mb="lg">
<Checkbox
id="license-not-provided"
checked={field.value === false}
onChange={(e) => field.onChange(!e.currentTarget.checked)}
/>
<Text size="sm" style={{ cursor: 'pointer' }} component="label" htmlFor="license-not-provided">
{t('jobs.license.nolicense')}
</Text>
</Group>
)}
/>
{licenseProvided && (
<Controller
name="licenses"
control={control}
rules={{
validate: () => validateLicenses(candidate.licenses)
}}
render={() => (
<FileUpload
files={candidate.licenses.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
)}
/>
)}
{errors.licenses && (
<Text size="sm" c="error" mt="xs">
{asI18n(errors.licenses?.message ?? '')}
</Text>
)}
</Box>
</CandidateSection>
)
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { Controller, useFormContext } from 'react-hook-form'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateWorkExperience, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { CandidateSection } from './CandidateSection'
import { Box, Text } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { asI18n } from '@pikku/react'
export const ProfessionalExperience: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateWorkExperience>()
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.WORK_EXPERIENCE, trigger)
const validateWorkExperience = (files: any[]): boolean | string => {
if (files && files.length > 5) {
return t('jobs.professionalexperience.validation.maxfiles')
}
return true
}
return (
<CandidateSection
title={t('jobs.professionalexperience.title')}
example={t('jobs.professionalexperience.example')}
description={t('jobs.professionalexperience.description')}
>
<Box>
<Controller
name="workExperience"
control={control}
rules={{
validate: () => validateWorkExperience(candidate.workExperience)
}}
render={() => (
<Box mt="xs">
<FileUpload
files={candidate.workExperience.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
)}
/>
{errors.workExperience && (
<Text size="sm" c="dimmed" mt="xs">
{asI18n(errors.workExperience?.message ?? '')}
</Text>
)}
</Box>
</CandidateSection>
)
}

View File

@@ -0,0 +1,179 @@
import { Link, useNavigate, useParams } from '@tanstack/react-router'
import { Button, Checkbox, Stack, TextInput, Text, Anchor, Alert } from "@pikku/mantine/core"
import { Controller, useForm } from "react-hook-form"
import { requestMagicLink } from "@/lib/auth"
import { pikku } from "@/pikku/http"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useI18n } from "@/context/i18n-provider"
import { useState } from "react"
import { asI18n } from "@pikku/react"
export const StartApplication: React.FunctionComponent<{ initializing: boolean }> = ({ initializing }) => {
const navigate = useNavigate()
const params = useParams({ strict: false })
const lang = params.lang as string
const t = useI18n()
const [emailExists, setEmailExists] = useState(false)
const [existingEmail, setExistingEmail] = useState('')
const [hasError, setHasError] = useState(false)
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
email: '',
fullName: '',
termsAndPrivacyAccepted: false
}
})
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async (data: any) => {
if (data.termsAndPrivacyAccepted && data.fullName && data.email) {
try {
await pikku().post('/candidate', {
name: data.fullName,
email: data.email
});
(window as any).fbq?.('trackCustom', 'StartApplication', {
source: 'check_my_qualification_button',
page: '/en/jobs/apply'
});
queryClient.invalidateQueries({ queryKey: ['candidate'] });
navigate({ to: `/${lang}/jobs/application` });
} catch (error: any) {
if (error.status === 409) {
setEmailExists(true)
setExistingEmail(data.email)
setHasError(false)
throw error
}
setHasError(true)
setEmailExists(false)
throw error
}
}
}
})
const sendMagicLinkMutation = useMutation({
mutationFn: async () => {
await requestMagicLink(existingEmail)
}
})
return (
<Stack component="form" gap="md" onSubmit={handleSubmit(data => mutation.mutateAsync(data))}>
<Controller
name="fullName"
control={control}
rules={{ required: t('jobs.startapplication.validation.fullnamerequired') }}
disabled={initializing}
render={({ field }) => (
<TextInput
id="fullName"
label={t('jobs.startapplication.fullname')}
error={errors.fullName?.message}
{...field}
/>
)}
/>
<Controller
name="email"
control={control}
rules={{
required: t('jobs.startapplication.validation.emailrequired'),
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: t('jobs.startapplication.validation.emailinvalid')
}
}}
disabled={initializing}
render={({ field }) => (
<TextInput
id="email"
type="email"
label={t('jobs.startapplication.email')}
autoComplete="email"
error={errors.email?.message}
{...field}
/>
)}
/>
<Controller
name="termsAndPrivacyAccepted"
control={control}
disabled={initializing}
rules={{ required: t('jobs.startapplication.validation.termsrequired') }}
render={({ field }) => (
<Checkbox
id="terms"
checked={field.value}
onChange={field.onChange}
disabled={field.disabled}
error={errors.termsAndPrivacyAccepted?.message}
label={
<Text size="sm">
{t('jobs.startapplication.termstext.prefix')}{asI18n(' ')}
<Anchor component={Link} to={`/${lang}/legal/terms-and-conditions`} size="sm">
{t('jobs.startapplication.termstext.terms')}
</Anchor>{asI18n(' ')}
{t('jobs.startapplication.termstext.and')}{asI18n(' ')}
<Anchor component={Link} to={`/${lang}/legal/privacy`} size="sm">
{t('jobs.startapplication.termstext.privacy')}
</Anchor>{asI18n(' ')}
{t('jobs.startapplication.termstext.suffix')}
</Text>
}
/>
)}
/>
{hasError && (
<Alert color="red" title={t('jobs.startapplication.error.title')}>
<Text size="sm">
{t('jobs.startapplication.error.message')}
</Text>
</Alert>
)}
{emailExists && (
<Alert color="blue" title={t('jobs.startapplication.emailexists.title')}>
<Text size="sm" mb="md">
{t('jobs.startapplication.emailexists.message')}
</Text>
<Button
size="sm"
variant="light"
onClick={() => sendMagicLinkMutation.mutate()}
loading={sendMagicLinkMutation.isPending}
disabled={sendMagicLinkMutation.isSuccess}
>
{sendMagicLinkMutation.isSuccess
? t('jobs.startapplication.emailexists.sent')
: t('jobs.startapplication.emailexists.button')}
</Button>
</Alert>
)}
<Stack gap="xs">
<Button
size="lg"
type="submit"
loading={mutation.isPending}
disabled={initializing || mutation.isPending}
px="xl"
>
{t('jobs.startapplication.checkqualification')}
</Button>
<Text size="sm" c="dimmed" ta="center">
{t('jobs.startapplication.freetext')}
</Text>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,95 @@
import { Controller, useFormContext } from 'react-hook-form'
import type { DB } from '@heygermany/sdk'
import { ApplicationCandidateData, ApplicationCandidateEducation, CandidateDocumentTypes } from '@heygermany/sdk'
import { FileUpload } from '../../ui/FileUpload'
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
import { CandidateSection } from './CandidateSection'
import { Alert, Box, List, Text, ThemeIcon } from '@pikku/mantine/core'
import { useI18n } from '@/context/i18n-provider'
import { IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { asI18n } from '@pikku/react'
export const TrainingCertificates: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
const t = useI18n()
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateEducation>()
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.EDUCATION, trigger)
const uploadedEducationDocuments = [...candidate.education]
const i18n = (key: string) => t(key as any)
const educationCopy = {
title: i18n('jobs.educationdocuments.title'),
example: i18n('jobs.educationdocuments.example'),
description: i18n('jobs.educationdocuments.description'),
validationRequired: i18n('jobs.educationdocuments.validation.required'),
validationMaxFiles: i18n('jobs.educationdocuments.validation.maxfiles'),
checklistTitle: i18n('jobs.educationdocuments.checklist.title'),
checklist: [
i18n('jobs.educationdocuments.checklist.item1'),
i18n('jobs.educationdocuments.checklist.item2'),
i18n('jobs.educationdocuments.checklist.item3')
]
}
const validateEducation = (files: DB.CandidateDocument[]): string | boolean => {
if (!files || files.length === 0) {
return educationCopy.validationRequired
} else if (files.length > 10) {
return educationCopy.validationMaxFiles
}
return true
}
return (
<CandidateSection
title={educationCopy.title}
example={educationCopy.example}
description={educationCopy.description}
>
<Box>
{educationCopy.checklist.length > 0 && (
<Alert
color="blue"
mb="md"
title={asI18n(educationCopy.checklistTitle)}
icon={<IconInfoCircle size={16} />}
>
<List size="sm" spacing="xs">
{educationCopy.checklist.map((item) => (
<List.Item
key={item}
icon={
<ThemeIcon size={18} radius="xl" color="blue.6">
<IconCheck size={12} />
</ThemeIcon>
}
>
{asI18n(item)}
</List.Item>
))}
</List>
</Alert>
)}
<Controller
name="education"
control={control}
rules={{
validate: () => validateEducation(uploadedEducationDocuments)
}}
render={() => (
<Box mt="xs">
<FileUpload
files={uploadedEducationDocuments.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
uploadFiles={uploadFiles}
deleteFile={deleteFile}
/>
</Box>
)}
/>
{errors.education && (
<Text size="sm" c="error" mt="xs">{asI18n(errors.education.message ?? '')}</Text>
)}
</Box>
</CandidateSection>
)
}