chore: heygermany customer project
This commit is contained in:
83
apps/website/components/jobs/CandidateResult.tsx
Normal file
83
apps/website/components/jobs/CandidateResult.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Box, Container, rem, Stack, Text } from "@pikku/mantine/core"
|
||||
import { CTASection } from "./results/CTASection"
|
||||
import NextSteps from "./results/NextSteps"
|
||||
import { QualificationsSection } from "./results/QualificationsSection"
|
||||
import React from "react"
|
||||
import { CandidateResults } from "@heygermany/sdk"
|
||||
import { Emphasize } from "../ui/Emphasize"
|
||||
import { useI18n } from "@/context/i18n-provider"
|
||||
|
||||
export const CandidateResult: React.FunctionComponent<{
|
||||
candidateResults: CandidateResults
|
||||
ctaPublicMode?: boolean
|
||||
}> = ({ candidateResults, ctaPublicMode = false }) => {
|
||||
const { textContent } = candidateResults
|
||||
const t = useI18n()
|
||||
|
||||
if (!textContent.renderPath) {
|
||||
return <div>{t('jobs.result.noeligibletemplate')}</div>
|
||||
}
|
||||
|
||||
const isHelper = textContent.renderPath.startsWith("helper.")
|
||||
const afterExplanationParaNotes = textContent.notes.afterExplanation
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<Box bg="white" py="xl">
|
||||
<Container size="lg" ta="center">
|
||||
<Emphasize text={textContent.hero} tt="capitalize" size="xxxl" fw={700} mt="xl" mb="md" />
|
||||
<Emphasize text={textContent.result} size="xxl" fw={700} mt="xl" mb="xl" />
|
||||
|
||||
<Container maw={rem(800)} size="md" mb="xl">
|
||||
<Stack gap="md">
|
||||
{/* First paragraph */}
|
||||
{textContent.explanation[0] && <Emphasize size="lgxl" text={textContent.explanation[0]} />}
|
||||
|
||||
{/* Remaining paragraphs */}
|
||||
{textContent.explanation.slice(1).map((p: string, i: number) => <Emphasize key={i} size="lgxl" text={p} />)}
|
||||
|
||||
{/* Visa note(s) after explanation */}
|
||||
{afterExplanationParaNotes.map((n: string, idx: number) => (
|
||||
<Emphasize key={`note-${idx}`} size="lgxl" text={n} />
|
||||
))}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Qualifications Section */}
|
||||
<QualificationsSection candidateResults={candidateResults} />
|
||||
|
||||
{/* What does this mean — render only if not Helper */}
|
||||
{!isHelper && textContent.recognition?.length ? (
|
||||
<Container maw={rem(800)} size="md" my="xl">
|
||||
<Text fz="xxxl" fw={700} mb='lg' ta='center'>
|
||||
{t('jobs.nextsteps.whatdoesthismean')}
|
||||
</Text>
|
||||
|
||||
<Stack gap='sm' c="dimmed" mx="auto" align='center'>
|
||||
<Emphasize primary={false} text={textContent.recognition[0]} ta='center' fz="xl" fw={700} mb='lg' />
|
||||
{textContent.recognition.slice(1).map((line: string, idx: number) => (
|
||||
<Emphasize
|
||||
primary={false}
|
||||
renderInlineStyles={false}
|
||||
key={idx}
|
||||
ta='center'
|
||||
text={line}
|
||||
fz='xl'
|
||||
fw={500}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Container>
|
||||
) : null}
|
||||
|
||||
{/* NextSteps Section */}
|
||||
<NextSteps joinTalentPool={candidateResults.joinTalentPool!} nextSteps={candidateResults.nextSteps} isHelper={isHelper} />
|
||||
|
||||
{/* CTA Section */}
|
||||
<CTASection joinTalentPool={candidateResults.joinTalentPool!} publicMode={ctaPublicMode} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
163
apps/website/components/jobs/DemoCandidateResultPage.tsx
Normal file
163
apps/website/components/jobs/DemoCandidateResultPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { CandidateResults } from '@heygermany/sdk'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { CandidateResult } from './CandidateResult'
|
||||
|
||||
function buildDemoCandidateResults(
|
||||
t: ReturnType<typeof useI18n>,
|
||||
candidateName: string
|
||||
): CandidateResults {
|
||||
const currentGermanLevel = 'B1'
|
||||
|
||||
return {
|
||||
education: {
|
||||
title: t('jobs.qualifications.education.match.title'),
|
||||
description: t('jobs.qualifications.education.match.description'),
|
||||
status: 'completed',
|
||||
requirements: [
|
||||
t('jobs.qualifications.education.match.requirements.degree'),
|
||||
t('jobs.qualifications.education.match.requirements.accredited'),
|
||||
t('jobs.qualifications.education.match.requirements.structured'),
|
||||
],
|
||||
},
|
||||
license: {
|
||||
title: 'Nursing License',
|
||||
description: 'Missing nursing license or registration from your home country.',
|
||||
status: 'required',
|
||||
requirements: [
|
||||
'Nursing license from your home country required for job recognition',
|
||||
],
|
||||
steps: ['Obtain your license from your home country'],
|
||||
},
|
||||
language: {
|
||||
title: 'German Proficiency',
|
||||
description: 'Demonstrate required German language skills.',
|
||||
status: 'in-progress',
|
||||
progress: 75,
|
||||
requirements: [
|
||||
'German level B2',
|
||||
'Language certificate (Goethe-Institut, telc, TestDaF, or ECL)',
|
||||
],
|
||||
steps: [
|
||||
`Improve your German level from ${currentGermanLevel} to B2`,
|
||||
'Get a language certificate from Goethe-Institut, telc, TestDaF, or ECL',
|
||||
],
|
||||
},
|
||||
experience: {
|
||||
title: 'Work Experience',
|
||||
description: 'Upload your CV to our platform to connect with employers.',
|
||||
status: 'optional',
|
||||
requirements: [
|
||||
'Curriculum Vitae (CV)',
|
||||
],
|
||||
steps: [
|
||||
'Create your CV',
|
||||
'Upload it to your profile',
|
||||
],
|
||||
},
|
||||
copies: {
|
||||
title: 'Copies',
|
||||
description: 'Provide notarized or certified copies of required documents.',
|
||||
status: 'required',
|
||||
requirements: [
|
||||
'Passport copy',
|
||||
'Education documents copies',
|
||||
'Police clearance certificate copy',
|
||||
'Certificate of good standing copy',
|
||||
],
|
||||
steps: [
|
||||
'Prepare document copies',
|
||||
'Translate by sworn translator if required',
|
||||
],
|
||||
},
|
||||
originals: {
|
||||
title: 'Originals',
|
||||
description: 'Prepare required originals for submission to the authority.',
|
||||
status: 'required',
|
||||
requirements: [
|
||||
'Completed application form',
|
||||
'CV (signed and dated)',
|
||||
'Medical fitness certificate',
|
||||
'Certificate of good conduct',
|
||||
],
|
||||
steps: [
|
||||
'Prepare originals',
|
||||
'Submit to the authority',
|
||||
],
|
||||
},
|
||||
joinTalentPool: false,
|
||||
nextSteps: {
|
||||
gethelp: {
|
||||
title: 'Get Help',
|
||||
subtitle: 'Get free support for your application in Germany. Many services help internationally trained professionals like you succeed in job recognition. Seek help before you start the process — it can make all the difference.',
|
||||
costs: 'Cost: Free',
|
||||
button: 'Find a counseling service',
|
||||
},
|
||||
connect: {
|
||||
title: 'Connect with Employers',
|
||||
subtitle: 'Engaging with a German employer early can boost your chances of success. They can guide you through requirements, provide documents, and support your recognition process from start to finish. We’ll help you connect with your future employer.',
|
||||
costs: 'Cost: Free',
|
||||
buttonjoin: 'Join our talent pool',
|
||||
buttonimprove: 'Improve your profile',
|
||||
},
|
||||
documents: {
|
||||
title: 'Prepare Your Documents',
|
||||
subtitle: "Gather all necessary documents you will need for your job recognition application. Some must be in original, others as copies, and in most cases, certified translations are required",
|
||||
costs: 'Cost: 200-300€ for translated documents',
|
||||
button: 'Check required documents',
|
||||
},
|
||||
submit: {
|
||||
title: 'Submit Your Application',
|
||||
subtitle: `To begin the recognition process, you'll need to submit an application for permission to use the professional title "Registered Nurse" (Pflegefachfrau or Pflegefachmann) to the relevant authority (LaGeSo). Make sure you have all required documents before you start the process. Incomplete submissions can slow down your recognition process unnecessarily`,
|
||||
costs: 'Cost: 250-350€ for recognition fee',
|
||||
button: 'Learn how to apply',
|
||||
},
|
||||
compensatory: {
|
||||
title: 'Complete a Compensatory Measure',
|
||||
subtitle: 'After you submit your recognition application, you’ll receive a Deficit Notice from LaGeSo. It shows where your qualification differs from the German nursing standard and outlines the compensatory measures you can take to achieve full recognition. You can choose either an Adaptation Program or a Knowledge Exam.',
|
||||
costs: 'Cost: Varies',
|
||||
button: 'Explore compensatory measures',
|
||||
},
|
||||
german: {
|
||||
title: 'Improve Your German',
|
||||
subtitle: `You're currently at ${currentGermanLevel} — almost there! To gain full recognition and work as a nurse in Berlin, you'll need to improve your German to B2 level. There are several options available to help fund your language courses.`,
|
||||
costs: 'Cost: Varies (see tips)',
|
||||
button: 'Find courses and funding options',
|
||||
},
|
||||
},
|
||||
textContent: {
|
||||
renderPath: 'nurse.missingLicense',
|
||||
hero: `Good News ${candidateName}`,
|
||||
result: 'You May Qualify as a Nurse in Berlin',
|
||||
explanation: [
|
||||
'Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.',
|
||||
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way. Please keep in mind that this is an early and independent assessment to support your journey. Final recognition is granted by the official German authorities and includes additional steps. This assessment is not legally binding.",
|
||||
],
|
||||
recognition: [
|
||||
'🔍 Likely Outcome of Job Recognition: Partial Job Recognition',
|
||||
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
|
||||
'To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.',
|
||||
"Please note: Your professional <strong>license</strong> wasn't uploaded yet. It's a required document for recognition, so make sure you have it available when starting your application.",
|
||||
],
|
||||
notes: {
|
||||
afterExplanation: [
|
||||
'Please note that you need a B1 German language certificate in order to obtain a visa for Germany.',
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function DemoCandidateResultPage() {
|
||||
const t = useI18n()
|
||||
const searchParams = useLocation({ select: (l) => new URLSearchParams(l.searchStr) })
|
||||
const candidateName = searchParams.get('name') || 'Maria Santos'
|
||||
const candidateResults = buildDemoCandidateResults(t, candidateName)
|
||||
|
||||
return (
|
||||
<CandidateResult
|
||||
candidateResults={candidateResults}
|
||||
ctaPublicMode={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
191
apps/website/components/jobs/form/BasicInfo.tsx
Normal file
191
apps/website/components/jobs/form/BasicInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
41
apps/website/components/jobs/form/CountrySearch.tsx
Normal file
41
apps/website/components/jobs/form/CountrySearch.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
19
apps/website/components/jobs/form/Done.tsx
Normal file
19
apps/website/components/jobs/form/Done.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
apps/website/components/jobs/form/GermanLanguage.tsx
Normal file
142
apps/website/components/jobs/form/GermanLanguage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/website/components/jobs/form/HelpAffix.tsx
Normal file
25
apps/website/components/jobs/form/HelpAffix.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
apps/website/components/jobs/form/Invalid.tsx
Normal file
73
apps/website/components/jobs/form/Invalid.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
93
apps/website/components/jobs/form/License.tsx
Normal file
93
apps/website/components/jobs/form/License.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal file
56
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
apps/website/components/jobs/form/StartApplication.tsx
Normal file
179
apps/website/components/jobs/form/StartApplication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal file
95
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal file
229
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal 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
|
||||
139
apps/website/components/jobs/results/CTASection.tsx
Normal file
139
apps/website/components/jobs/results/CTASection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
99
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal file
99
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal file
65
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
apps/website/components/jobs/results/GetHelp.tsx
Normal file
204
apps/website/components/jobs/results/GetHelp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/website/components/jobs/results/HowToApply.tsx
Normal file
56
apps/website/components/jobs/results/HowToApply.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
apps/website/components/jobs/results/NextSteps.tsx
Normal file
231
apps/website/components/jobs/results/NextSteps.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
150
apps/website/components/jobs/results/Qualification.tsx
Normal file
150
apps/website/components/jobs/results/Qualification.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
187
apps/website/components/jobs/results/QualificationsSection.tsx
Normal file
187
apps/website/components/jobs/results/QualificationsSection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user