chore: heygermany customer project
This commit is contained in:
193
apps/website/components/jobs/form/BasicInfo.tsx
Normal file
193
apps/website/components/jobs/form/BasicInfo.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
37
apps/website/components/jobs/form/CandidateSection.tsx
Normal file
37
apps/website/components/jobs/form/CandidateSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/website/components/jobs/form/CountrySearch.tsx
Normal file
43
apps/website/components/jobs/form/CountrySearch.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
apps/website/components/jobs/form/Done.tsx
Normal file
21
apps/website/components/jobs/form/Done.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
144
apps/website/components/jobs/form/GermanLanguage.tsx
Normal file
144
apps/website/components/jobs/form/GermanLanguage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
27
apps/website/components/jobs/form/HelpAffix.tsx
Normal file
27
apps/website/components/jobs/form/HelpAffix.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
75
apps/website/components/jobs/form/Invalid.tsx
Normal file
75
apps/website/components/jobs/form/Invalid.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
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've reviewed your files, but unfortunately, one or more of them couldn'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>
|
||||
)
|
||||
}
|
||||
95
apps/website/components/jobs/form/License.tsx
Normal file
95
apps/website/components/jobs/form/License.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
58
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal file
58
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
182
apps/website/components/jobs/form/StartApplication.tsx
Normal file
182
apps/website/components/jobs/form/StartApplication.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import Link from "@/framework/link"
|
||||
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 { useRouter, useParams } from "@/framework/navigation"
|
||||
import { useState } from "react"
|
||||
import { asI18n } from "@pikku/react"
|
||||
|
||||
export const StartApplication: React.FunctionComponent<{ initializing: boolean }> = ({ initializing }) => {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
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'] });
|
||||
router.push(`/${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} href={`/${lang}/legal/terms-and-conditions`} size="sm">
|
||||
{t('jobs.startapplication.termstext.terms')}
|
||||
</Anchor>{asI18n(' ')}
|
||||
{t('jobs.startapplication.termstext.and')}{asI18n(' ')}
|
||||
<Anchor component={Link} href={`/${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>
|
||||
);
|
||||
}
|
||||
97
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal file
97
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user