chore: heygermany customer project
This commit is contained in:
8
apps/website/components/LoginComponent.module.css
Normal file
8
apps/website/components/LoginComponent.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-size: cover;
|
||||
background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80);
|
||||
}
|
||||
121
apps/website/components/LoginComponent.tsx
Normal file
121
apps/website/components/LoginComponent.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import classes from './LoginComponent.module.css';
|
||||
|
||||
export type LoginFormData = {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
export interface LoginComponentProps {
|
||||
namespace: 'backoffice' | 'company';
|
||||
mutation: {
|
||||
mutateAsync: (data: { email: string; password: string }) => Promise<any>;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function LoginComponent({ namespace, mutation }: LoginComponentProps) {
|
||||
const t = useI18n();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
await mutation.mutateAsync({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<Paper w='100%' maw={450} p='xl' withBorder>
|
||||
<Title order={1} mb='lg'>
|
||||
{t(`login.${namespace}.title` as any)}
|
||||
</Title>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{mutation.error && (
|
||||
<Alert color="red" title={t('login.error.title')} mb="md">
|
||||
<Text size="sm">
|
||||
{t('login.error.message')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
type="email"
|
||||
label={t('login.emaillabel')}
|
||||
placeholder={t('login.emailplaceholder')}
|
||||
size="md"
|
||||
radius="md"
|
||||
{...register('email', {
|
||||
required: t('login.validation.emailrequired'),
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/,
|
||||
message: t('login.validation.emailinvalid'),
|
||||
},
|
||||
})}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
<PasswordInput
|
||||
type="password"
|
||||
label={t('login.passwordlabel')}
|
||||
placeholder={t('login.passwordplaceholder')}
|
||||
mt="md"
|
||||
size="md"
|
||||
radius="md"
|
||||
{...register('password', {
|
||||
required: t('login.validation.passwordrequired'),
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t('login.validation.passwordminlength'),
|
||||
},
|
||||
})}
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('login.rememberme')}
|
||||
mt="xl"
|
||||
size="md"
|
||||
{...register('rememberMe')}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="xl"
|
||||
size="md"
|
||||
radius="md"
|
||||
type="submit"
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{t('login.loginbutton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/website/components/NotFoundTitle.module.css
Normal file
35
apps/website/components/NotFoundTitle.module.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.root {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
line-height: 1;
|
||||
margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
|
||||
color: var(--mantine-color-gray-2);
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
margin-top: var(--mantine-spacing-xl);
|
||||
margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Outfit', var(--mantine-font-family);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
32
apps/website/components/NotFoundTitle.tsx
Normal file
32
apps/website/components/NotFoundTitle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Container, Group, Text, Title } from '@pikku/mantine/core';
|
||||
import { DEFAULT_LOCALE } from '@/config';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { useParams, useRouter } from '@/framework/navigation';
|
||||
import classes from './NotFoundTitle.module.css';
|
||||
|
||||
export function NotFoundTitle() {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { lang } = useParams<{ lang?: string }>();
|
||||
|
||||
const handleGoHome = () => {
|
||||
router.push(lang ? `/${lang}` : `/${DEFAULT_LOCALE}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className={classes.root}>
|
||||
<div className={classes.label}>404</div>
|
||||
<Title className={classes.title}>{t('notfound.title')}</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||
{t('notfound.description')}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button variant="subtle" size="md" onClick={handleGoHome}>
|
||||
{t('notfound.cta')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/backoffice/ApplicantStatusSelect.tsx
Normal file
43
apps/website/components/backoffice/ApplicantStatusSelect.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Select } from '@pikku/mantine/core';
|
||||
import type { DB } from '@heygermany/sdk';
|
||||
import { ApplicantStatuses } from '@heygermany/sdk';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
interface ApplicantStatusSelectProps {
|
||||
value: DB.ApplicantStatus | null;
|
||||
onChange: (value: DB.ApplicantStatus | null) => void;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export const ApplicantStatusSelect: React.FC<ApplicantStatusSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
isValid
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const allStatuses = Object.values(ApplicantStatuses);
|
||||
const preferredOrder: DB.ApplicantStatus[] = [
|
||||
ApplicantStatuses.INITIAL,
|
||||
ApplicantStatuses.PENDING,
|
||||
ApplicantStatuses.INVALID,
|
||||
ApplicantStatuses.COMPLETE,
|
||||
];
|
||||
|
||||
const orderedStatuses = [
|
||||
...preferredOrder.filter((status) => allStatuses.includes(status)),
|
||||
...allStatuses.filter((status) => !preferredOrder.includes(status)),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={value}
|
||||
onChange={(value) => onChange(value as DB.ApplicantStatus | null)}
|
||||
data={orderedStatuses.map((status) => ({
|
||||
value: status,
|
||||
label: t(`enums.applicantstatus.${status.toLowerCase()}` as any),
|
||||
disabled: status === ApplicantStatuses.COMPLETE && !isValid,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
403
apps/website/components/backoffice/DocumentFieldFactory.tsx
Normal file
403
apps/website/components/backoffice/DocumentFieldFactory.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { TextInput, Select, Textarea, NumberInput, Group, Text, MultiSelect } from '@pikku/mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import {
|
||||
ApplicantStatuses,
|
||||
CandidateDocumentTypes,
|
||||
LanguageLevels,
|
||||
NurseRecognitionGermanyValues,
|
||||
NurseRecognitionHomeCountryValues,
|
||||
} from '@heygermany/sdk';
|
||||
import React from 'react';
|
||||
import { EnumSelect } from '@/components/common/EnumSelect';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
export type FieldComponentProps = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
label?: any;
|
||||
placeholder?: any;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type FieldComponent = (props: FieldComponentProps) => React.ReactElement;
|
||||
|
||||
// Reusable component functions
|
||||
export const TextInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
|
||||
<TextInput
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
label={label as any}
|
||||
placeholder={placeholder as any}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export const TextareaComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
label={label as any}
|
||||
placeholder={placeholder as any}
|
||||
disabled={disabled}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NumberInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
|
||||
<NumberInput
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
label={label as any}
|
||||
placeholder={placeholder as any}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DateInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => (
|
||||
<DateInput
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={(date: string | null) => onChange(date)}
|
||||
label={label as any}
|
||||
placeholder={placeholder as any}
|
||||
disabled={disabled}
|
||||
valueFormat="YYYY-MM-DD"
|
||||
/>
|
||||
);
|
||||
|
||||
export const LanguageLevelSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EnumSelect
|
||||
enumObject={LanguageLevels}
|
||||
enumName="LanguageLevel"
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
placeholder={t('backoffice.filters.selectlevel')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BooleanSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
const options = [
|
||||
{ value: 'true', label: t('common.yes') },
|
||||
{ value: 'false', label: t('common.no') },
|
||||
{ value: 'null', label: t('backoffice.filters.unknown') }
|
||||
];
|
||||
|
||||
const getCurrentValue = () => {
|
||||
if (value === true) return 'true';
|
||||
if (value === false) return 'false';
|
||||
return 'null';
|
||||
};
|
||||
|
||||
const handleChange = (newValue: string | null) => {
|
||||
if (newValue === 'true') onChange(true);
|
||||
else if (newValue === 'false') onChange(false);
|
||||
else onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={getCurrentValue()}
|
||||
onChange={handleChange}
|
||||
label={label as any}
|
||||
placeholder={t('backoffice.filters.select')}
|
||||
disabled={disabled}
|
||||
data={options}
|
||||
styles={{
|
||||
input: {
|
||||
...(value === null && !disabled ? {
|
||||
borderColor: '#FFA500',
|
||||
borderWidth: '2px'
|
||||
} : {})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StringArrayComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
const arrayValue = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<Group gap="xs" align="flex-start">
|
||||
<Text size="sm" fw={500}>{asI18n(String(label ?? ''))}</Text>
|
||||
<Textarea
|
||||
value={arrayValue.join('\n')}
|
||||
onChange={(e) => onChange(e.target.value.split('\n').filter(item => item.trim()))}
|
||||
placeholder={t('backoffice.filters.oneitemperline')}
|
||||
disabled={disabled}
|
||||
autosize
|
||||
minRows={2}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const NurseRecognitionGermanySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionGermanyValues}
|
||||
enumName="NurseRecognitionGermany"
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
placeholder={t('backoffice.filters.selectrecognitionlevel')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NurseRecognitionHomeCountrySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EnumSelect
|
||||
enumObject={NurseRecognitionHomeCountryValues}
|
||||
enumName="NurseRecognitionHomeCountry"
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
placeholder={t('backoffice.filters.selectrecognitionlevel')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApplicantStatusSelectComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EnumSelect
|
||||
enumObject={ApplicantStatuses}
|
||||
enumName="ApplicantStatus"
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
placeholder={placeholder || t('backoffice.candidate.fields.selectstatus')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WhatItMeansSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
// Define the enum values based on the SQL schema
|
||||
const WhatItMeansEnum = {
|
||||
PARTIAL_RECOGNITION_B_ONE: 'PARTIAL_RECOGNITION_B_ONE',
|
||||
PARTIAL_RECOGNITION_B_TWO: 'PARTIAL_RECOGNITION_B_TWO',
|
||||
NONE: 'NONE',
|
||||
} as const;
|
||||
|
||||
const whatItMeansOptions = Object.values(WhatItMeansEnum).map(meaning => ({
|
||||
value: meaning,
|
||||
label: meaning.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label as any}
|
||||
placeholder={t('backoffice.filters.selectmeaning')}
|
||||
disabled={disabled}
|
||||
data={whatItMeansOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NextStepsArraySelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
const arrayValue = Array.isArray(value) ? value : [];
|
||||
|
||||
// Define the enum values based on the SQL schema
|
||||
const NextStepsEnum = {
|
||||
IMPROVE_GERMAN_B_TWO: 'IMPROVE_GERMAN_B_TWO',
|
||||
IMPROVE_GERMAN_B_ONE: 'IMPROVE_GERMAN_B_ONE',
|
||||
SUBMIT_APPLICATION_ASSISTANT: 'SUBMIT_APPLICATION_ASSISTANT',
|
||||
GET_HELP: 'GET_HELP',
|
||||
CONNECT_WITH_EMPLOYERS: 'CONNECT_WITH_EMPLOYERS',
|
||||
} as const;
|
||||
|
||||
const nextStepsOptions = Object.values(NextStepsEnum).map(step => ({
|
||||
value: step,
|
||||
label: step.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())
|
||||
}));
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
label={label as any}
|
||||
placeholder={t('backoffice.filters.selectsteps')}
|
||||
value={arrayValue}
|
||||
onChange={(val) => onChange(val || [])}
|
||||
data={nextStepsOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MinimumGermanLevelSelectComponent: FieldComponent = ({ value, onChange, label, disabled }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EnumSelect
|
||||
enumObject={LanguageLevels}
|
||||
enumName="LanguageLevel"
|
||||
value={value || null}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
placeholder={t('backoffice.filters.selectlevel')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Duration input component that converts text like "1y 5m" to hours
|
||||
export const DurationInputComponent: FieldComponent = ({ value, onChange, label, placeholder, disabled }) => {
|
||||
const t = useI18n();
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
|
||||
// Convert hours to display format
|
||||
const hoursToDisplayFormat = (hours: number): string => {
|
||||
if (!hours || hours === 0) return '';
|
||||
|
||||
let remaining = hours;
|
||||
const parts = [];
|
||||
|
||||
// Calculate years
|
||||
const years = Math.floor(remaining / (365 * 24));
|
||||
if (years > 0) {
|
||||
parts.push(`${years}y`);
|
||||
remaining -= years * 365 * 24;
|
||||
}
|
||||
|
||||
// Calculate months
|
||||
const months = Math.floor(remaining / (30 * 24));
|
||||
if (months > 0) {
|
||||
parts.push(`${months}m`);
|
||||
remaining -= months * 30 * 24;
|
||||
}
|
||||
|
||||
// Calculate days
|
||||
const days = Math.floor(remaining / 24);
|
||||
if (days > 0) {
|
||||
parts.push(`${days}d`);
|
||||
remaining -= days * 24;
|
||||
}
|
||||
|
||||
// Remaining hours
|
||||
if (remaining > 0) {
|
||||
parts.push(`${remaining}h`);
|
||||
}
|
||||
|
||||
return parts.join(' ') || '0h';
|
||||
};
|
||||
|
||||
// Convert display format to hours
|
||||
const displayFormatToHours = (input: string): number => {
|
||||
if (!input) return 0;
|
||||
|
||||
let totalHours = 0;
|
||||
const yearMatch = input.match(/(\d+)y/);
|
||||
const monthMatch = input.match(/(\d+)m/);
|
||||
const dayMatch = input.match(/(\d+)d/);
|
||||
const hourMatch = input.match(/(\d+)h/);
|
||||
|
||||
if (yearMatch) totalHours += parseInt(yearMatch[1]) * 365 * 24;
|
||||
if (monthMatch) totalHours += parseInt(monthMatch[1]) * 30 * 24;
|
||||
if (dayMatch) totalHours += parseInt(dayMatch[1]) * 24;
|
||||
if (hourMatch) totalHours += parseInt(hourMatch[1]);
|
||||
|
||||
return totalHours;
|
||||
};
|
||||
|
||||
// Initialize input value from props
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined && value !== null) {
|
||||
setInputValue(hoursToDisplayFormat(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleInputChange = (newInputValue: string) => {
|
||||
setInputValue(newInputValue);
|
||||
const hours = displayFormatToHours(newInputValue);
|
||||
onChange(hours);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
label={label as any}
|
||||
placeholder={placeholder || t('backoffice.filters.durationexample')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" pb="xs">
|
||||
{value ? asI18n(`${value} hours`) : asI18n('')}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
// Field metadata mapping using enum values directly
|
||||
export const FIELD_METADATA: Record<string, Record<string, FieldComponent>> = {
|
||||
// Nursing License fields
|
||||
[CandidateDocumentTypes.LICENSE]: {
|
||||
licenseNumber: TextInputComponent,
|
||||
licenseType: TextInputComponent,
|
||||
issuingAuthority: TextInputComponent,
|
||||
issueDate: DateInputComponent,
|
||||
expiryDate: DateInputComponent,
|
||||
status: TextInputComponent,
|
||||
},
|
||||
|
||||
// Training Certificate fields
|
||||
[CandidateDocumentTypes.EDUCATION]: {
|
||||
institution: TextInputComponent,
|
||||
program: TextInputComponent,
|
||||
country: TextInputComponent,
|
||||
completionDate: DateInputComponent,
|
||||
nursingRelatedDegree: BooleanSelectComponent,
|
||||
accreditedUniversity: BooleanSelectComponent,
|
||||
accreditedStudyProgram: BooleanSelectComponent,
|
||||
hasTheory: BooleanSelectComponent,
|
||||
hasPractice: BooleanSelectComponent,
|
||||
durationHours: DurationInputComponent,
|
||||
isNursingFocus: BooleanSelectComponent,
|
||||
},
|
||||
|
||||
// German Language Certificate fields
|
||||
[CandidateDocumentTypes.LANGUAGE]: {
|
||||
level: LanguageLevelSelectComponent,
|
||||
issuingBody: TextInputComponent,
|
||||
issueDate: DateInputComponent,
|
||||
certifiedSkills: StringArrayComponent,
|
||||
},
|
||||
|
||||
// Work Experience fields
|
||||
[CandidateDocumentTypes.WORK_EXPERIENCE]: {
|
||||
employer: TextInputComponent,
|
||||
position: TextInputComponent,
|
||||
department: TextInputComponent,
|
||||
startDate: DateInputComponent,
|
||||
endDate: DateInputComponent,
|
||||
durationMonths: NumberInputComponent,
|
||||
isFullTime: BooleanSelectComponent,
|
||||
duties: StringArrayComponent,
|
||||
},
|
||||
|
||||
// Nursing Qualification Rule fields
|
||||
'nursing_qualification_rule': {
|
||||
},
|
||||
};
|
||||
|
||||
export function getFieldComponent(documentType: string, fieldName: string): FieldComponent {
|
||||
return FIELD_METADATA[documentType]?.[fieldName] || TextInputComponent;
|
||||
}
|
||||
29
apps/website/components/backoffice/DocumentStatusSelect.tsx
Normal file
29
apps/website/components/backoffice/DocumentStatusSelect.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Select } from '@pikku/mantine/core';
|
||||
import type { DB } from '@heygermany/sdk';
|
||||
import { DocumentStatuses } from '@heygermany/sdk';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
|
||||
interface DocumentStatusSelectProps {
|
||||
value: DB.DocumentStatus;
|
||||
onChange: (value: DB.DocumentStatus) => void;
|
||||
}
|
||||
|
||||
export const DocumentStatusSelect: React.FC<DocumentStatusSelectProps> = ({
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Select
|
||||
placeholder={t('backoffice.candidate.fields.selectstatus')}
|
||||
value={value}
|
||||
onChange={(value) => onChange(value as DB.DocumentStatus)}
|
||||
data={[
|
||||
{ value: DocumentStatuses.UNVERIFIED, label: t('backoffice.documents.status.unverified') },
|
||||
{ value: DocumentStatuses.VERIFIED, label: t('backoffice.documents.status.verified') },
|
||||
{ value: DocumentStatuses.INVALID, label: t('backoffice.documents.status.invalid') },
|
||||
{ value: DocumentStatuses.MISSING, label: t('backoffice.documents.status.missing') }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
324
apps/website/components/backoffice/DocumentVerificationCard.tsx
Normal file
324
apps/website/components/backoffice/DocumentVerificationCard.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Card, Text, Table, Badge, ActionIcon, Drawer, Box, Group, Stack, Alert } from '@pikku/mantine/core';
|
||||
import { IconArrowRight, IconEye, IconMaximize, IconMinimize, IconPlus, IconRotateClockwise, IconX } from '@tabler/icons-react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import type { DB } from '@heygermany/sdk';
|
||||
import { DocumentStatuses } from '@heygermany/sdk';
|
||||
import { getFieldComponent } from './DocumentFieldFactory';
|
||||
import { pikku } from '@/pikku/http';
|
||||
import { useUpdateDocumentVerification } from '@/hooks/backoffice/useUpdateDocumentVerification';
|
||||
import { useUploadBackOfficeCandidateFiles } from '@/hooks/backoffice/useUploadBackOfficeCandidateFiles';
|
||||
import { UseMutationResult } from '@tanstack/react-query';
|
||||
import { DocumentStatusSelect } from './DocumentStatusSelect';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
type VerificationDocument = {
|
||||
documentId: string;
|
||||
documentStatus: DB.DocumentStatus | null;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type DocumentVerificationCardProps = {
|
||||
category: DB.CandidateDocumentType;
|
||||
candidateId: string;
|
||||
documents: VerificationDocument[];
|
||||
aiExtractedData: Record<string, any>;
|
||||
finalData: Record<string, any>;
|
||||
mutationHook: UseMutationResult<any, Error, any, unknown>;
|
||||
verbose?: boolean;
|
||||
requiredFields?: string[];
|
||||
};
|
||||
|
||||
export function DocumentVerificationCard({
|
||||
candidateId,
|
||||
documents,
|
||||
category,
|
||||
aiExtractedData,
|
||||
finalData,
|
||||
mutationHook,
|
||||
verbose = false,
|
||||
requiredFields = [],
|
||||
}: DocumentVerificationCardProps) {
|
||||
const [documentUrl, setDocumentUrl] = useState<string | null>(null);
|
||||
const [documentPreviewTitle, setDocumentPreviewTitle] = useState('Document Preview');
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [deletingDocumentId, setDeletingDocumentId] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const updateDocumentVerification = useUpdateDocumentVerification(candidateId);
|
||||
const { uploadFiles, deleteFile } = useUploadBackOfficeCandidateFiles(candidateId, category);
|
||||
const t = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const showDocument = async (documentId: string, label: string) => {
|
||||
const response = await pikku().get('/backoffice/candidate/:candidateId/document/:documentId', { documentId, candidateId });
|
||||
setDocumentPreviewTitle(label);
|
||||
setDocumentUrl(response.url)
|
||||
}
|
||||
|
||||
const allFields = new Set([
|
||||
...Object.keys(aiExtractedData || {}),
|
||||
...Object.keys(finalData || {})
|
||||
]);
|
||||
|
||||
// Show only required fields when not in verbose mode
|
||||
const fieldsToShow = verbose
|
||||
? Array.from(allFields)
|
||||
: requiredFields.filter(field => allFields.has(field));
|
||||
|
||||
const getFieldLabel = (fieldName: string) => {
|
||||
return fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/([A-Z])/g, ' $1');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({ ...finalData });
|
||||
}, [finalData]);
|
||||
|
||||
const handleCopyOver = (fieldName: string) => {
|
||||
const aiValue = aiExtractedData?.[fieldName];
|
||||
if (aiValue !== undefined) {
|
||||
const updatedData = { ...formData, [fieldName]: aiValue };
|
||||
setFormData(updatedData);
|
||||
mutationHook.mutate({ [fieldName]: aiValue });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: any) => {
|
||||
setFormData((currentData) => ({
|
||||
...currentData,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
mutationHook.mutate({ [fieldName]: value });
|
||||
};
|
||||
|
||||
const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(event.target.files || []);
|
||||
|
||||
if (!selectedFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
await uploadFiles(selectedFiles);
|
||||
} catch (error) {
|
||||
console.error('Error uploading files in backoffice:', error);
|
||||
setUploadError('Failed to upload document(s). Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (documentId: string, label: string) => {
|
||||
const confirmed = window.confirm(`Delete ${label}?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingDocumentId(documentId);
|
||||
|
||||
try {
|
||||
await deleteFile(documentId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file in backoffice:', error);
|
||||
setDeleteError('Failed to delete document. Please try again.');
|
||||
} finally {
|
||||
setDeletingDocumentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card shadow="sm" px={0} pb={0} radius="md" withBorder>
|
||||
<Card.Section withBorder inheritPadding py="sm">
|
||||
<Stack gap="sm" px="md">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileSelection}
|
||||
/>
|
||||
{documents.length > 0 ? (
|
||||
documents.map((document, index) => {
|
||||
const label = document.label || `Document ${index + 1}`;
|
||||
return (
|
||||
<Group key={`${document.documentId}-${index}`} justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="light"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => showDocument(document.documentId, label)}
|
||||
>
|
||||
{asI18n(label)}
|
||||
</Badge>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDocument(document.documentId, label)}
|
||||
disabled={deletingDocumentId === document.documentId}
|
||||
aria-label={asI18n(`Delete ${label}`)}
|
||||
title={asI18n(`Delete ${label}`)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<DocumentStatusSelect
|
||||
value={document.documentStatus ?? DocumentStatuses.UNVERIFIED}
|
||||
onChange={(status) => updateDocumentVerification.mutate({
|
||||
documentId: document.documentId,
|
||||
documentStatus: status
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">{t('backoffice.documents.nofilesavailable' as any)}</Text>
|
||||
)}
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="light"
|
||||
style={{
|
||||
cursor: isUploading ? 'default' : 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
opacity: isUploading ? 0.7 : 1,
|
||||
}}
|
||||
onClick={isUploading ? undefined : () => fileInputRef.current?.click()}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus size={12} />
|
||||
<Text size="xs" inherit>{asI18n(isUploading ? t('backoffice.documents.uploading' as any) : t('backoffice.documents.uploaddocuments' as any))}</Text>
|
||||
</Group>
|
||||
</Badge>
|
||||
{uploadError ? (
|
||||
<Alert color="red" variant="light">
|
||||
{asI18n(uploadError)}
|
||||
</Alert>
|
||||
) : null}
|
||||
{deleteError ? (
|
||||
<Alert color="red" variant="light">
|
||||
{asI18n(deleteError)}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
|
||||
{fieldsToShow.length > 0 ? (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Field</Table.Th>
|
||||
<Table.Th>AI Value</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
<Table.Th>User Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{fieldsToShow.map((fieldName) => {
|
||||
const FieldComponent = getFieldComponent(category, fieldName);
|
||||
|
||||
return (
|
||||
<Table.Tr key={fieldName}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(getFieldLabel(fieldName))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<FieldComponent
|
||||
value={aiExtractedData?.[fieldName]}
|
||||
onChange={() => { }} // Read-only
|
||||
disabled={true}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => handleCopyOver(fieldName)}
|
||||
disabled={!aiExtractedData?.[fieldName]}
|
||||
>
|
||||
<IconArrowRight size={14} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<FieldComponent
|
||||
value={formData?.[fieldName]}
|
||||
onChange={(value) => handleFieldChange(fieldName, value)}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Box p="md" ta="center">
|
||||
<Text c="dimmed" size="sm">{asI18n('No required fields')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
opened={!!documentUrl}
|
||||
onClose={() => {
|
||||
setDocumentUrl(null);
|
||||
setIsFullscreen(false);
|
||||
setRotation(0);
|
||||
}}
|
||||
position="right"
|
||||
size={isFullscreen ? "100%" : "60%"}
|
||||
title={
|
||||
<Group justify="space-between" style={{ width: '100%', paddingRight: '1rem' }}>
|
||||
<Text>{asI18n(documentPreviewTitle)}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => setRotation((prev) => (prev + 90) % 360)}
|
||||
title={asI18n('Rotate image')}
|
||||
>
|
||||
<IconRotateClockwise size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={asI18n(isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <IconMinimize size={18} /> : <IconMaximize size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{documentUrl && (
|
||||
<Box style={{ height: '100%' }}>
|
||||
<iframe
|
||||
src={documentUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 100px)',
|
||||
border: 'none',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
title={asI18n('Document Preview')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DocumentVerificationCard } from './DocumentVerificationCard';
|
||||
import { useUpdateCandidateEducation } from '@/hooks/backoffice/useUpdateCandidateEducation';
|
||||
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
|
||||
|
||||
type EducationVerificationCardProps = {
|
||||
candidateId: string;
|
||||
education: any;
|
||||
documents?: any[];
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export function EducationVerificationCard({ candidateId, education, documents = [], verbose = false }: EducationVerificationCardProps) {
|
||||
const mutationHook = useUpdateCandidateEducation(candidateId);
|
||||
const verificationDocuments = documents
|
||||
.map((document, index) => ({
|
||||
documentId: document.documentId,
|
||||
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
|
||||
label: document.fileName || `File ${index + 1}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<DocumentVerificationCard
|
||||
candidateId={candidateId}
|
||||
documents={verificationDocuments}
|
||||
category={CandidateDocumentTypes.EDUCATION}
|
||||
aiExtractedData={{
|
||||
institution: education.aiInstitution,
|
||||
country: education.aiCountry,
|
||||
program: education.aiProgram,
|
||||
completionDate: education.aiCompletionDate,
|
||||
nursingRelatedDegree: education.aiNursingRelatedDegree,
|
||||
accreditedUniversity: education.aiAccreditedUniversity,
|
||||
accreditedStudyProgram: education.aiAccreditedStudyProgram,
|
||||
isNursingFocus: education.aiIsNursingFocus,
|
||||
hasTheory: education.aiHasTheory,
|
||||
hasPractice: education.aiHasPractice,
|
||||
durationHours: education.aiDurationHours
|
||||
}}
|
||||
finalData={{
|
||||
institution: education.institution,
|
||||
country: education.country,
|
||||
program: education.program,
|
||||
completionDate: education.completionDate,
|
||||
nursingRelatedDegree: education.nursingRelatedDegree,
|
||||
accreditedUniversity: education.accreditedUniversity,
|
||||
accreditedStudyProgram: education.accreditedStudyProgram,
|
||||
isNursingFocus: education.isNursingFocus,
|
||||
hasTheory: education.hasTheory,
|
||||
hasPractice: education.hasPractice,
|
||||
durationHours: education.durationHours
|
||||
}}
|
||||
mutationHook={mutationHook}
|
||||
verbose={verbose}
|
||||
requiredFields={[
|
||||
'institution',
|
||||
'country',
|
||||
'program',
|
||||
'nursingRelatedDegree',
|
||||
'accreditedUniversity',
|
||||
'accreditedStudyProgram',
|
||||
'isNursingFocus',
|
||||
'hasTheory',
|
||||
'hasPractice',
|
||||
'durationHours'
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DocumentVerificationCard } from './DocumentVerificationCard';
|
||||
import { useUpdateCandidateExperience } from '@/hooks/backoffice/useUpdateCandidateExperience';
|
||||
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
|
||||
|
||||
type ExperienceVerificationCardProps = {
|
||||
candidateId: string;
|
||||
experience: any;
|
||||
documents?: any[];
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export function ExperienceVerificationCard({ candidateId, experience, documents = [], verbose = false }: ExperienceVerificationCardProps) {
|
||||
const mutationHook = useUpdateCandidateExperience(candidateId);
|
||||
const verificationDocuments = documents
|
||||
.map((document, index) => ({
|
||||
documentId: document.documentId,
|
||||
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
|
||||
label: document.fileName || `File ${index + 1}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<DocumentVerificationCard
|
||||
candidateId={candidateId}
|
||||
documents={verificationDocuments}
|
||||
category={CandidateDocumentTypes.WORK_EXPERIENCE}
|
||||
aiExtractedData={{
|
||||
employer: experience.aiEmployer,
|
||||
position: experience.aiPosition,
|
||||
department: experience.aiDepartment,
|
||||
startDate: experience.aiStartDate,
|
||||
endDate: experience.aiEndDate,
|
||||
durationMonths: experience.aiDurationMonths,
|
||||
isFullTime: experience.aiIsFullTime,
|
||||
duties: experience.aiDuties
|
||||
}}
|
||||
finalData={{
|
||||
employer: experience.employer,
|
||||
position: experience.position,
|
||||
department: experience.department,
|
||||
startDate: experience.startDate,
|
||||
endDate: experience.endDate,
|
||||
durationMonths: experience.durationMonths,
|
||||
isFullTime: experience.isFullTime,
|
||||
duties: experience.duties
|
||||
}}
|
||||
mutationHook={mutationHook}
|
||||
verbose={verbose}
|
||||
requiredFields={[]} // Work experience: only show header when not verbose
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DocumentVerificationCard } from './DocumentVerificationCard';
|
||||
import { useUpdateCandidateLanguage } from '@/hooks/backoffice/useUpdateCandidateLanguage';
|
||||
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
|
||||
|
||||
type LanguageVerificationCardProps = {
|
||||
candidateId: string;
|
||||
language: any;
|
||||
documents?: any[];
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export function LanguageVerificationCard({ candidateId, language, documents = [], verbose = false }: LanguageVerificationCardProps) {
|
||||
const mutationHook = useUpdateCandidateLanguage(candidateId);
|
||||
const verificationDocuments = documents
|
||||
.map((document, index) => ({
|
||||
documentId: document.documentId,
|
||||
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
|
||||
label: document.fileName || `File ${index + 1}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<DocumentVerificationCard
|
||||
candidateId={candidateId}
|
||||
documents={verificationDocuments}
|
||||
category={CandidateDocumentTypes.LANGUAGE}
|
||||
aiExtractedData={{
|
||||
level: language.aiLevel,
|
||||
issuingBody: language.aiIssuingBody,
|
||||
issueDate: language.aiIssueDate,
|
||||
certifiedSkills: language.aiCertifiedSkills
|
||||
}}
|
||||
finalData={{
|
||||
level: language.level,
|
||||
issuingBody: language.issuingBody,
|
||||
issueDate: language.issueDate,
|
||||
certifiedSkills: language.certifiedSkills
|
||||
}}
|
||||
mutationHook={mutationHook}
|
||||
verbose={verbose}
|
||||
requiredFields={[
|
||||
'level'
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DocumentVerificationCard } from './DocumentVerificationCard';
|
||||
import { useUpdateCandidateLicense } from '@/hooks/backoffice/useUpdateCandidateLicense';
|
||||
import { CandidateDocumentTypes, DocumentStatuses } from '@heygermany/sdk';
|
||||
|
||||
type LicenseVerificationCardProps = {
|
||||
candidateId: string;
|
||||
license: any;
|
||||
documents?: any[];
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export function LicenseVerificationCard({ candidateId, license, documents = [], verbose = false }: LicenseVerificationCardProps) {
|
||||
const mutationHook = useUpdateCandidateLicense(candidateId);
|
||||
const verificationDocuments = documents
|
||||
.map((document, index) => ({
|
||||
documentId: document.documentId,
|
||||
documentStatus: document.documentStatus ?? DocumentStatuses.UNVERIFIED,
|
||||
label: document.fileName || `File ${index + 1}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<DocumentVerificationCard
|
||||
candidateId={candidateId}
|
||||
documents={verificationDocuments}
|
||||
category={CandidateDocumentTypes.LICENSE}
|
||||
aiExtractedData={{
|
||||
licenseNumber: license.aiLicenseNumber,
|
||||
issuingAuthority: license.aiIssuingAuthority,
|
||||
issueDate: license.aiIssueDate,
|
||||
expiryDate: license.aiExpiryDate,
|
||||
status: license.aiStatus,
|
||||
licenseType: license.aiLicenseType
|
||||
}}
|
||||
finalData={{
|
||||
licenseNumber: license.licenseNumber,
|
||||
issuingAuthority: license.issuingAuthority,
|
||||
issueDate: license.issueDate,
|
||||
expiryDate: license.expiryDate,
|
||||
status: license.status,
|
||||
licenseType: license.licenseType
|
||||
}}
|
||||
mutationHook={mutationHook}
|
||||
verbose={verbose}
|
||||
requiredFields={[]} // License: none (just need to verify its valid)
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Divider, Group, Paper, Stack, Table, Text, Textarea, Title } from '@pikku/mantine/core';
|
||||
import { CandidateLifecycleNotifications } from '@heygermany/sdk';
|
||||
import dayjs from 'dayjs';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
type LifecycleNotificationsCardProps = {
|
||||
lifecycleNotifications?: CandidateLifecycleNotifications | null
|
||||
analysisIssuesDraft: string
|
||||
onAnalysisIssuesDraftChange: (value: string) => void
|
||||
onSaveAnalysisIssues: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
type ReminderCategory = 'initial' | 'invalid'
|
||||
type ReminderType = 'day_1' | 'day_3' | 'day_6'
|
||||
|
||||
type ReminderRow = {
|
||||
type: ReminderType
|
||||
sentAt: string | null
|
||||
mailgunMessageId: string | null
|
||||
errorMessage: string | null
|
||||
lastErrorAt: string | null
|
||||
}
|
||||
|
||||
const REMINDER_TYPES: ReminderRow[] = [
|
||||
{ type: 'day_1', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
|
||||
{ type: 'day_3', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
|
||||
{ type: 'day_6', sentAt: null, mailgunMessageId: null, errorMessage: null, lastErrorAt: null },
|
||||
]
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const normalizeLifecycleNotifications = (value: unknown): CandidateLifecycleNotifications | null => {
|
||||
if (isPlainObject(value)) {
|
||||
return value as CandidateLifecycleNotifications
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(value)
|
||||
return isPlainObject(parsedValue) ? parsedValue as CandidateLifecycleNotifications : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getString = (value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim()
|
||||
return normalizedValue.length > 0 ? normalizedValue : null
|
||||
}
|
||||
|
||||
const normalizeMessage = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedValue = value.replace(/\s+/g, ' ').trim()
|
||||
return normalizedValue.length > 0 ? normalizedValue : null
|
||||
}
|
||||
|
||||
const displayValue = (value: string | null) => value || '-'
|
||||
|
||||
const formatTimestamp = (value: string | null) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const parsedValue = dayjs(value)
|
||||
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm') : value
|
||||
}
|
||||
|
||||
const getReminderKey = (category: ReminderCategory, type: ReminderType) =>
|
||||
`${category}.${type}`
|
||||
|
||||
const normalizeKey = (value: string) => value.toLowerCase().replace(/[_\-.]/g, '')
|
||||
|
||||
const getReminderValue = (
|
||||
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
|
||||
category: ReminderCategory,
|
||||
type: ReminderType
|
||||
) => {
|
||||
const directValue = lifecycleNotifications?.[getReminderKey(category, type)]
|
||||
|
||||
if (directValue !== undefined) {
|
||||
return directValue
|
||||
}
|
||||
|
||||
const normalizedReminderKey = normalizeKey(getReminderKey(category, type))
|
||||
|
||||
return Object.entries(lifecycleNotifications ?? {}).find(([key]) => (
|
||||
normalizeKey(key) === normalizedReminderKey
|
||||
))?.[1]
|
||||
}
|
||||
|
||||
const getStringFromKeys = (record: Record<string, unknown>, keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
const value = getString(record[key])
|
||||
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getReminderRows = (
|
||||
lifecycleNotifications: CandidateLifecycleNotifications | null | undefined,
|
||||
category: ReminderCategory
|
||||
) =>
|
||||
REMINDER_TYPES.map((baseRow) => {
|
||||
const reminderValue = getReminderValue(lifecycleNotifications, category, baseRow.type)
|
||||
|
||||
if (!isPlainObject(reminderValue)) {
|
||||
return baseRow
|
||||
}
|
||||
|
||||
return {
|
||||
...baseRow,
|
||||
sentAt: getStringFromKeys(reminderValue, ['sent_at', 'sentAt']),
|
||||
mailgunMessageId: getStringFromKeys(reminderValue, ['mailgun_message_id', 'mailgunMessageId']),
|
||||
errorMessage: normalizeMessage(getStringFromKeys(reminderValue, ['last_error', 'lastError'])),
|
||||
lastErrorAt: getStringFromKeys(reminderValue, ['last_error_at', 'lastErrorAt']),
|
||||
}
|
||||
})
|
||||
|
||||
const renderReminderSection = (
|
||||
title: string,
|
||||
rows: ReminderRow[]
|
||||
) => (
|
||||
<Paper withBorder radius="sm" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600}>{asI18n(title)}</Text>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
withTableBorder
|
||||
withColumnBorders={false}
|
||||
striped={false}
|
||||
highlightOnHover={false}
|
||||
style={{ tableLayout: 'fixed', minWidth: 980 }}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 120 }}>notification_type</Table.Th>
|
||||
<Table.Th style={{ width: 180 }}>sent_at</Table.Th>
|
||||
<Table.Th style={{ width: 280 }}>mailgun_message_id</Table.Th>
|
||||
<Table.Th>last_error</Table.Th>
|
||||
<Table.Th style={{ width: 180 }}>last_error_at</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => (
|
||||
<Table.Tr key={row.type}>
|
||||
<Table.Td style={{ verticalAlign: 'top' }}>
|
||||
<Text size="sm" style={{ fontFamily: 'monospace' }}>
|
||||
{asI18n(row.type)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ verticalAlign: 'top' }}>
|
||||
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
|
||||
{asI18n(displayValue(row.sentAt) || '-')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ verticalAlign: 'top' }}>
|
||||
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
|
||||
{asI18n(displayValue(row.mailgunMessageId) || '-')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ verticalAlign: 'top' }}>
|
||||
<Text size="sm" c={row.errorMessage ? 'red' : 'dimmed'} style={{ whiteSpace: 'normal', overflowWrap: 'anywhere', lineHeight: 1.4 }}>
|
||||
{asI18n(displayValue(row.errorMessage) || '-')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ verticalAlign: 'top' }}>
|
||||
<Text size="sm" style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
|
||||
{asI18n(displayValue(row.lastErrorAt) || '-')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
export const LifecycleNotificationsCard: React.FunctionComponent<LifecycleNotificationsCardProps> = ({
|
||||
lifecycleNotifications,
|
||||
analysisIssuesDraft,
|
||||
onAnalysisIssuesDraftChange,
|
||||
onSaveAnalysisIssues,
|
||||
isSaving,
|
||||
}) => {
|
||||
const t = useI18n()
|
||||
const normalizedLifecycleNotifications = normalizeLifecycleNotifications(lifecycleNotifications)
|
||||
const initialRows = getReminderRows(normalizedLifecycleNotifications, 'initial')
|
||||
const invalidRows = getReminderRows(normalizedLifecycleNotifications, 'invalid')
|
||||
|
||||
const analysisUpdatedAt =
|
||||
isPlainObject(normalizedLifecycleNotifications?.analysis)
|
||||
? getString(normalizedLifecycleNotifications.analysis.updated_at ?? normalizedLifecycleNotifications.analysis.updatedAt)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Paper shadow="sm" p="lg" radius="md" withBorder mb="md">
|
||||
<Stack gap="md">
|
||||
<Stack gap={4}>
|
||||
<Title order={4}>{t('backoffice.lifecycle.title' as any)}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('backoffice.lifecycle.description' as any)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{renderReminderSection(t('backoffice.lifecycle.initial' as any), initialRows)}
|
||||
{renderReminderSection(t('backoffice.lifecycle.invalid' as any), invalidRows)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Paper withBorder radius="sm" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>{t('backoffice.lifecycle.customissues' as any)}</Text>
|
||||
{analysisUpdatedAt ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">{t('backoffice.lifecycle.updated' as any)}</Text>
|
||||
<Text size="xs" c="dimmed">{asI18n(formatTimestamp(analysisUpdatedAt))}</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('backoffice.lifecycle.helptext' as any)}
|
||||
</Text>
|
||||
<Textarea
|
||||
minRows={4}
|
||||
autosize
|
||||
value={analysisIssuesDraft}
|
||||
onChange={(event) => onAnalysisIssuesDraftChange(event.currentTarget.value)}
|
||||
placeholder={t('backoffice.lifecycle.placeholder' as any)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
loading={isSaving}
|
||||
onClick={onSaveAnalysisIssues}
|
||||
>
|
||||
{t('backoffice.lifecycle.save' as any)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Paper, Text, Box, Stack, Group } from '@pikku/mantine/core';
|
||||
import { GetCandidateResult } from '@heygermany/sdk';
|
||||
import React from 'react';
|
||||
import { asI18n, type I18nNode } from '@pikku/react';
|
||||
|
||||
const renderFieldValue = (value: any, key: string): I18nNode => {
|
||||
let displayValue: string;
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
displayValue = value ? 'Yes' : 'No';
|
||||
} else if (value === null || value === undefined) {
|
||||
displayValue = 'N/A';
|
||||
} else if (key === 'languageLevel') {
|
||||
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
|
||||
} else if (key === 'qualificationStatusHomeCountry') {
|
||||
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
|
||||
} else if (key === 'qualificationStatusGermany') {
|
||||
displayValue = (value as string).replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
|
||||
} else if (key === 'languageAssessmentType') {
|
||||
displayValue = value === 'certificate' ? 'Certificate' : value === 'self_assessment' ? 'Self Assessment' : 'N/A';
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
const getColor = (val: string) => {
|
||||
if (val === 'Verified') return 'green';
|
||||
if (val === 'Unverified') return 'yellow';
|
||||
if (val === 'Missing') return 'red';
|
||||
if (val === 'Yes') return 'green';
|
||||
if (val === 'No') return 'red';
|
||||
if (val === 'N/A') return 'dimmed';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return <Text span c={getColor(displayValue)}>{asI18n(displayValue)}</Text>;
|
||||
};
|
||||
|
||||
const renderRow = (label: string, value: I18nNode) => (
|
||||
<Group gap={4} wrap="wrap" align="baseline">
|
||||
<Text fw={500} size="sm">{asI18n(label)}</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export const QualificationAssessment: React.FunctionComponent<{ candidateResult?: GetCandidateResult }> = ({ candidateResult }) => {
|
||||
if (!candidateResult) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper p="md" radius="sm" withBorder my="lg" shadow='lg'>
|
||||
<Stack gap="sm">
|
||||
{renderRow('Qualification Status Home Country:', renderFieldValue(candidateResult.qualificationStatusHomeCountry, 'qualificationStatusHomeCountry'))}
|
||||
{renderRow('Qualification Status Germany:', renderFieldValue(candidateResult.qualificationStatusGermany, 'qualificationStatusGermany'))}
|
||||
{renderRow('Nursing Related Degree:', renderFieldValue(candidateResult.nursingRelatedDegree, 'nursingRelatedDegree'))}
|
||||
{renderRow('Accredited University:', renderFieldValue(candidateResult.accreditedUniversity, 'accreditedUniversity'))}
|
||||
{renderRow('Accredited Study Program:', renderFieldValue(candidateResult.accreditedStudyProgram, 'accreditedStudyProgram'))}
|
||||
|
||||
<Box>
|
||||
{renderRow('Structured Training:', renderFieldValue(candidateResult.structuredTraining?.overallStatus, 'structuredTraining'))}
|
||||
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Stack gap="xs">
|
||||
{renderRow('Duration:', renderFieldValue(candidateResult.structuredTraining?.hasDuration, 'hasDuration'))}
|
||||
{renderRow('Nursing Focus:', renderFieldValue(candidateResult.structuredTraining?.isNursingFocus, 'isNursingFocus'))}
|
||||
{renderRow('Theory & Practice:', renderFieldValue(
|
||||
candidateResult.structuredTraining?.hasTheoryHours && candidateResult.structuredTraining?.hasPracticeHours,
|
||||
'theoryAndPractice'
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{renderRow('License:', null)}
|
||||
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Stack gap="xs">
|
||||
{renderRow('Mandatory:', renderFieldValue(candidateResult.licenseMandatory, 'licenseMandatory'))}
|
||||
{renderRow('Uploaded:', renderFieldValue(candidateResult.docs?.license, 'licenseUploaded'))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{renderRow('Language:', null)}
|
||||
<Box ml="xl" mt="xs" p="xs" style={{ borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Stack gap="xs">
|
||||
{renderRow('Level:', renderFieldValue(candidateResult.languageLevel, 'languageLevel'))}
|
||||
{renderRow('Assessment Type:', renderFieldValue(candidateResult.languageAssessmentType, 'languageAssessmentType'))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{renderRow('CV Uploaded:', renderFieldValue(candidateResult.docs?.cv, 'cvUploaded'))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
apps/website/components/common/EnumSelect.tsx
Normal file
25
apps/website/components/common/EnumSelect.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { Select, SelectProps } from '@pikku/mantine/core';
|
||||
|
||||
interface EnumSelectProps extends Omit<SelectProps, 'data'> {
|
||||
enumObject: Record<string, string>;
|
||||
enumName: string;
|
||||
label?: any;
|
||||
placeholder?: any;
|
||||
}
|
||||
|
||||
export const EnumSelect: React.FC<EnumSelectProps> = ({
|
||||
enumObject,
|
||||
enumName,
|
||||
...selectProps
|
||||
}) => {
|
||||
const t = useI18n()
|
||||
const options = t.getEnumOptions(enumObject, enumName);
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...(selectProps as any)}
|
||||
data={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
224
apps/website/components/common/ExpandableSteps.tsx
Normal file
224
apps/website/components/common/ExpandableSteps.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, ReactNode } from 'react'
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
rem,
|
||||
Collapse,
|
||||
useMantineTheme,
|
||||
Flex,
|
||||
} from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export interface ExpandableStep {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
content: () => ReactNode
|
||||
}
|
||||
|
||||
interface ExpandableStepsProps {
|
||||
steps: ExpandableStep[]
|
||||
defaultExpanded?: number[]
|
||||
singleExpand?: boolean
|
||||
containerSize?: string
|
||||
showTitle?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function ExpandableSteps({
|
||||
steps,
|
||||
defaultExpanded = [0],
|
||||
singleExpand = true,
|
||||
containerSize = 'xl',
|
||||
showTitle = false,
|
||||
title,
|
||||
}: ExpandableStepsProps) {
|
||||
const theme = useMantineTheme()
|
||||
const [expandedSteps, setExpandedSteps] = useState(() => new Set(defaultExpanded))
|
||||
const contentRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [heights, setHeights] = useState<number[]>([])
|
||||
|
||||
const CIRCLE_SIZE = 38
|
||||
const GAP = 8
|
||||
|
||||
useEffect(() => {
|
||||
const observers: ResizeObserver[] = []
|
||||
|
||||
contentRefs.current.forEach((el, i) => {
|
||||
if (!el) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
setHeights((prev) => {
|
||||
const copy = [...prev]
|
||||
copy[i] = el.offsetHeight
|
||||
return copy
|
||||
})
|
||||
})
|
||||
observer.observe(el)
|
||||
observers.push(observer)
|
||||
})
|
||||
|
||||
return () => {
|
||||
observers.forEach((observer) => observer.disconnect())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const calculateLineHeight = (index: number): number => {
|
||||
if (index === steps.length - 1) return 0
|
||||
const contentHeight = heights[index] ?? 0
|
||||
return contentHeight + GAP * 2
|
||||
}
|
||||
|
||||
const toggleStep = (index: number) => {
|
||||
if (singleExpand) {
|
||||
setExpandedSteps(() => new Set([index]))
|
||||
} else {
|
||||
setExpandedSteps((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index)
|
||||
} else {
|
||||
newSet.add(index)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size='xl' py="xl" mt="xl">
|
||||
<Stack
|
||||
gap="lg"
|
||||
mt="xl"
|
||||
w="100%"
|
||||
maw={{ base: rem(300), md: rem(800) }}
|
||||
mx="auto"
|
||||
>
|
||||
{showTitle && title && (
|
||||
<Text fz="xxll" fw={700} ta='center' mb='xl'>
|
||||
{asI18n(title)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const isActive = expandedSteps.has(index)
|
||||
const lineHeight =
|
||||
isActive && index !== steps.length - 1
|
||||
? calculateLineHeight(index)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={step.title}
|
||||
pos="relative"
|
||||
display="flex"
|
||||
pl={{ base: 0, md: 30 }}
|
||||
mb={8}
|
||||
mih={40}
|
||||
c={isActive ? theme.colors.primary[6] : theme.colors.gray[9]}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Box
|
||||
w={CIRCLE_SIZE}
|
||||
pos="relative"
|
||||
display="flex"
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={CIRCLE_SIZE}
|
||||
h={CIRCLE_SIZE}
|
||||
bg="white"
|
||||
c={
|
||||
isActive ? theme.colors.primary[6] : theme.colors.gray[9]
|
||||
}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${theme.colors.gray[4]}`,
|
||||
fontSize: rem(16),
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transform: `translateY(${rem(4)})`,
|
||||
zIndex: 2,
|
||||
}}
|
||||
onClick={() => toggleStep(index)}
|
||||
>
|
||||
{index + 1}
|
||||
</Box>
|
||||
|
||||
{lineHeight > 0 && (
|
||||
<Box
|
||||
w={2}
|
||||
h={lineHeight}
|
||||
bg={`${theme.colors.gray[4]}`}
|
||||
mt={GAP}
|
||||
mb={GAP}
|
||||
style={{
|
||||
transition:
|
||||
'height 300ms cubic-bezier(.4,2,.6,1), background-color 250ms ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flex={1} me={10} onClick={() => toggleStep(index)}>
|
||||
<Flex
|
||||
align="flex-start"
|
||||
gap="xs"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
mb={6}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
variant="transparent"
|
||||
color={isActive ? 'primary' : `${theme.colors.gray[8]}`}
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{step.icon}
|
||||
</ThemeIcon>
|
||||
<Text
|
||||
fw={700}
|
||||
fz={25}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
ta={'left'}
|
||||
c={isActive ? 'primary' : `${theme.colors.gray[8]}`}
|
||||
>
|
||||
{asI18n(step.title)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Collapse in={isActive} transitionDuration={250}>
|
||||
<Container
|
||||
ref={(el) => {
|
||||
contentRefs.current[index] = el
|
||||
}}
|
||||
id={`step-${index}-content`}
|
||||
p={0}
|
||||
mb="md"
|
||||
>
|
||||
{step.content()}
|
||||
</Container>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
218
apps/website/components/company/CandidateProfileAvatar.tsx
Normal file
218
apps/website/components/company/CandidateProfileAvatar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { Box } from '@pikku/mantine/core'
|
||||
import { IconUser } from '@tabler/icons-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const countryCodeToEmoji: Record<string, string> = {
|
||||
AFG: '🇦🇫',
|
||||
ALB: '🇦🇱',
|
||||
DZA: '🇩🇿',
|
||||
AND: '🇦🇩',
|
||||
AGO: '🇦🇴',
|
||||
ARG: '🇦🇷',
|
||||
ARM: '🇦🇲',
|
||||
AUS: '🇦🇺',
|
||||
AUT: '🇦🇹',
|
||||
AZE: '🇦🇿',
|
||||
BHR: '🇧🇭',
|
||||
BGD: '🇧🇩',
|
||||
BLR: '🇧🇾',
|
||||
BEL: '🇧🇪',
|
||||
BIH: '🇧🇦',
|
||||
BOL: '🇧🇴',
|
||||
BRA: '🇧🇷',
|
||||
BGR: '🇧🇬',
|
||||
KHM: '🇰🇭',
|
||||
CMR: '🇨🇲',
|
||||
CAN: '🇨🇦',
|
||||
CHL: '🇨🇱',
|
||||
CHN: '🇨🇳',
|
||||
COL: '🇨🇴',
|
||||
CRI: '🇨🇷',
|
||||
HRV: '🇭🇷',
|
||||
CUB: '🇨🇺',
|
||||
CYP: '🇨🇾',
|
||||
CZE: '🇨🇿',
|
||||
DNK: '🇩🇰',
|
||||
ECU: '🇪🇨',
|
||||
EGY: '🇪🇬',
|
||||
SLV: '🇸🇻',
|
||||
EST: '🇪🇪',
|
||||
ETH: '🇪🇹',
|
||||
FIN: '🇫🇮',
|
||||
FRA: '🇫🇷',
|
||||
GEO: '🇬🇪',
|
||||
DEU: '🇩🇪',
|
||||
GHA: '🇬🇭',
|
||||
GRC: '🇬🇷',
|
||||
GTM: '🇬🇹',
|
||||
HND: '🇭🇳',
|
||||
HUN: '🇭🇺',
|
||||
ISL: '🇮🇸',
|
||||
IND: '🇮🇳',
|
||||
IDN: '🇮🇩',
|
||||
IRN: '🇮🇷',
|
||||
IRQ: '🇮🇶',
|
||||
IRL: '🇮🇪',
|
||||
ISR: '🇮🇱',
|
||||
ITA: '🇮🇹',
|
||||
JPN: '🇯🇵',
|
||||
JOR: '🇯🇴',
|
||||
KAZ: '🇰🇿',
|
||||
KEN: '🇰🇪',
|
||||
KWT: '🇰🇼',
|
||||
KGZ: '🇰🇬',
|
||||
LAO: '🇱🇦',
|
||||
LVA: '🇱🇻',
|
||||
LBN: '🇱🇧',
|
||||
LBY: '🇱🇾',
|
||||
LTU: '🇱🇹',
|
||||
LUX: '🇱🇺',
|
||||
MYS: '🇲🇾',
|
||||
MEX: '🇲🇽',
|
||||
MDA: '🇲🇩',
|
||||
MNG: '🇲🇳',
|
||||
MNE: '🇲🇪',
|
||||
MAR: '🇲🇦',
|
||||
NPL: '🇳🇵',
|
||||
NLD: '🇳🇱',
|
||||
NZL: '🇳🇿',
|
||||
NIC: '🇳🇮',
|
||||
NGA: '🇳🇬',
|
||||
MKD: '🇲🇰',
|
||||
NOR: '🇳🇴',
|
||||
OMN: '🇴🇲',
|
||||
PAK: '🇵🇰',
|
||||
PAN: '🇵🇦',
|
||||
PRY: '🇵🇾',
|
||||
PER: '🇵🇪',
|
||||
PHL: '🇵🇭',
|
||||
POL: '🇵🇱',
|
||||
PRT: '🇵🇹',
|
||||
QAT: '🇶🇦',
|
||||
ROU: '🇷🇴',
|
||||
RUS: '🇷🇺',
|
||||
SAU: '🇸🇦',
|
||||
SRB: '🇷🇸',
|
||||
SGP: '🇸🇬',
|
||||
SVK: '🇸🇰',
|
||||
SVN: '🇸🇮',
|
||||
ZAF: '🇿🇦',
|
||||
KOR: '🇰🇷',
|
||||
ESP: '🇪🇸',
|
||||
LKA: '🇱🇰',
|
||||
SDN: '🇸🇩',
|
||||
SWE: '🇸🇪',
|
||||
CHE: '🇨🇭',
|
||||
SYR: '🇸🇾',
|
||||
TWN: '🇹🇼',
|
||||
TJK: '🇹🇯',
|
||||
THA: '🇹🇭',
|
||||
TUN: '🇹🇳',
|
||||
TUR: '🇹🇷',
|
||||
TKM: '🇹🇲',
|
||||
UGA: '🇺🇬',
|
||||
UKR: '🇺🇦',
|
||||
ARE: '🇦🇪',
|
||||
GBR: '🇬🇧',
|
||||
USA: '🇺🇸',
|
||||
URY: '🇺🇾',
|
||||
UZB: '🇺🇿',
|
||||
VEN: '🇻🇪',
|
||||
VNM: '🇻🇳',
|
||||
YEM: '🇾🇪',
|
||||
ZMB: '🇿🇲',
|
||||
ZWE: '🇿🇼',
|
||||
}
|
||||
|
||||
const getCountryFlag = (countryCode: string | null): string => {
|
||||
if (!countryCode) return '🌍'
|
||||
|
||||
return countryCodeToEmoji[countryCode.toUpperCase()] || '🌍'
|
||||
}
|
||||
|
||||
type CandidateProfileAvatarProps = {
|
||||
countryCode: string | null
|
||||
imageBorderWidth: number
|
||||
badgeBorderWidth: number
|
||||
badgeOffset: number
|
||||
badgeSize: number
|
||||
iconSize: number
|
||||
name: string
|
||||
profileImageUrl?: string | null
|
||||
size: number
|
||||
}
|
||||
|
||||
export function CandidateProfileAvatar({
|
||||
countryCode,
|
||||
imageBorderWidth,
|
||||
badgeBorderWidth,
|
||||
badgeOffset,
|
||||
badgeSize,
|
||||
iconSize,
|
||||
name,
|
||||
profileImageUrl,
|
||||
size,
|
||||
}: CandidateProfileAvatarProps) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setImageFailed(false)
|
||||
}, [profileImageUrl])
|
||||
|
||||
const showImage = Boolean(profileImageUrl) && !imageFailed
|
||||
|
||||
return (
|
||||
<Box pos="relative" style={{ width: size, height: size, flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
border: `${imageBorderWidth}px solid var(--mantine-color-gray-3)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{showImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={profileImageUrl!}
|
||||
alt={`${name} profile image`}
|
||||
onError={() => setImageFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconUser size={iconSize} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: badgeOffset,
|
||||
right: badgeOffset,
|
||||
fontSize: `${Math.round(badgeSize * 0.58)}px`,
|
||||
width: badgeSize,
|
||||
height: badgeSize,
|
||||
backgroundColor: 'white',
|
||||
border: `${badgeBorderWidth}px solid var(--mantine-color-gray-3)`,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{getCountryFlag(countryCode)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
383
apps/website/components/home/HomePage.tsx
Normal file
383
apps/website/components/home/HomePage.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
'use client'
|
||||
|
||||
import Link from '@/framework/link'
|
||||
import Image from '@/framework/image'
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
Grid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Group,
|
||||
Space,
|
||||
} from '@pikku/mantine/core'
|
||||
import { IconCheck } from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { HomeResultsPreview } from '@/components/home/HomeResultsPreview'
|
||||
import React from 'react'
|
||||
import { useParams } from '@/framework/navigation'
|
||||
import { asI18n, type I18nString } from '@pikku/react'
|
||||
|
||||
const FeatureCard: React.FunctionComponent<{
|
||||
title: I18nString
|
||||
description: I18nString
|
||||
}> = ({ title, description }) => {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group align="center" gap="xs" mb={{ base: 0, lg: 'xs' }}>
|
||||
<Box
|
||||
w={32}
|
||||
h={32}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid'
|
||||
}}
|
||||
c={{ base: 'white', lg: 'black' }}
|
||||
bd={{ base: 'white', lg: 'black' }}
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
</Box>
|
||||
<Title
|
||||
order={2}
|
||||
style={{
|
||||
fontSize: 'clamp(1.25rem, 2vw, 2.25rem)',
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
c={{ base: 'white', lg: 'gray.8' }}
|
||||
>
|
||||
{asI18n(title)}
|
||||
</Title>
|
||||
</Group>
|
||||
<Text
|
||||
ml={44}
|
||||
ta="left"
|
||||
c={{ base: 'white', lg: 'gray.6' }}
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
{asI18n(description)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const Features: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const features = [
|
||||
{ title: t('website.features.fast.title'), description: t('website.features.fast.description') },
|
||||
{ title: t('website.features.free.title'), description: t('website.features.free.description') },
|
||||
{ title: t('website.features.easy.title'), description: t('website.features.easy.description') },
|
||||
]
|
||||
|
||||
return (
|
||||
<Grid gutter={{ base: 'sm', lg: 'lg' }} p={{ base: 'sm', lg: 'lg' }}>
|
||||
{features.map((feature) => (
|
||||
<Grid.Col key={feature.title} span={{ base: 12, md: 4 }}>
|
||||
<FeatureCard
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
const HeroSection: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const { lang } = useParams<{ lang: string }>()
|
||||
const isRtl = t.locale === 'ar'
|
||||
|
||||
return (
|
||||
<Box pos="relative" mih={{ base: '40rem', lg: 0 }}>
|
||||
<Container size="xl" h="100%" style={{ position: 'relative', zIndex: 20 }}>
|
||||
<Flex
|
||||
direction={{ base: 'column', lg: 'row' }}
|
||||
justify={{ base: 'center', md: 'normal' }}
|
||||
h="100%"
|
||||
>
|
||||
<Box maw={{ base: '100%', lg: '50%' }}>
|
||||
<Box
|
||||
py={{ base: 'md', lg: 'xl', xl: '4rem' }}
|
||||
ta="start"
|
||||
>
|
||||
<Title
|
||||
order={1}
|
||||
c={{ base: 'white', lg: 'gray.9' }}
|
||||
mb={{ base: 'sm', lg: 'lg', xl: '3.5rem' }}
|
||||
mt={{ base: 'md' }}
|
||||
style={{
|
||||
fontSize: 'clamp(2rem, 4vw, 3rem)',
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'block' }}>{t('website.hero.title')}</span>
|
||||
</Title>
|
||||
|
||||
<Box mt={{ base: 'md', lg: '2.5rem' }}>
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
align="center"
|
||||
justify={{ base: 'flex-start', lg: 'center' }}
|
||||
fw={700}
|
||||
c={{ base: 'white', lg: 'black' }}
|
||||
style={{
|
||||
fontSize: 'clamp(2.5rem, 6vw, 3rem)',
|
||||
lineHeight: 1.26,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{t('website.hero.checkqualification')}
|
||||
</Flex>
|
||||
<Flex
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
justify={{ base: 'flex-start', lg: 'center' }}
|
||||
mb={{ base: '2rem', lg: '2.5rem' }}
|
||||
mih={{ base: 0, lg: 80 }}
|
||||
>
|
||||
<Flex align="center" justify={{ base: 'flex-start', lg: 'center' }} mb="xs" pos="relative">
|
||||
<Text
|
||||
fw={700}
|
||||
me="md"
|
||||
c={{ base: 'white', lg: 'black' }}
|
||||
style={{
|
||||
fontSize: 'clamp(2.5rem, 6vw, 3rem)',
|
||||
lineHeight: 1.26
|
||||
}}
|
||||
>
|
||||
{t('website.hero.in')}
|
||||
</Text>
|
||||
<Box pos="relative" display="inline-block">
|
||||
<Box
|
||||
component="span"
|
||||
pos="absolute"
|
||||
display="flex"
|
||||
style={{
|
||||
top: '1.75em',
|
||||
left: '-0.25em',
|
||||
width: 'calc(100% + 0.5em)',
|
||||
justifyContent: 'center',
|
||||
zIndex: 5
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="0.57em"
|
||||
viewBox="0 0 250 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<rect y="4" width="250" height="6" rx="2" fill="#ebb305" />
|
||||
</svg>
|
||||
</Box>
|
||||
<Text
|
||||
component="span"
|
||||
fw={700}
|
||||
pos="relative"
|
||||
c={{ base: 'white', lg: 'black' }}
|
||||
style={{
|
||||
fontSize: 'clamp(2.5rem, 6vw, 3rem)',
|
||||
lineHeight: 1.26,
|
||||
zIndex: 3
|
||||
}}
|
||||
>
|
||||
{t('website.hero.months')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Space w={{ base: '1rem', lg: '1.75rem' }} />
|
||||
<Flex align="center" justify={{ base: 'flex-start', lg: 'center' }} style={{ position: 'relative', top: -2, whiteSpace: 'nowrap' }}>
|
||||
<Box component="span">
|
||||
<svg
|
||||
width="42"
|
||||
height="37"
|
||||
viewBox="0 0 42 37"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: 'clamp(28px, 4vw, 42px)', height: 'auto' }}
|
||||
>
|
||||
<path d="M25.772 8.644C26.19 8.19 26.159 7.485 25.702 7.069L18.252 0.293C17.794 -0.123 17.084 -0.092 16.666 0.362C16.247 0.816 16.278 1.522 16.736 1.937L23.358 7.961L17.295 14.54C16.876 14.995 16.908 15.7 17.365 16.116C17.822 16.532 18.532 16.5 18.951 16.046Z M2.795 34.253C1.714 29.036 2.066 22.916 5.271 18.019C8.441 13.175 14.554 9.301 25.49 8.822L25.391 6.594C13.93 7.096 7.052 11.208 3.389 16.803C-0.238 22.346 -0.56 29.12 0.597 34.703Z" fill="#ebb305" transform="translate(7.348 4.299) rotate(33 13 17.25)" />
|
||||
</svg>
|
||||
</Box>
|
||||
<Text
|
||||
fw={700}
|
||||
ml="xs"
|
||||
c="#ebb305"
|
||||
style={{
|
||||
fontSize: 'clamp(2.5rem, 6vw, 3rem)',
|
||||
lineHeight: 1.26,
|
||||
letterSpacing: '-0.01em'
|
||||
}}
|
||||
>
|
||||
{t('website.hero.days')}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
mb='sm'
|
||||
c={{ base: 'rgba(255, 255, 255, 0.9)', lg: 'gray.7' }}
|
||||
style={{
|
||||
fontSize: 'clamp(1rem, 1.5vw, 1.125rem)',
|
||||
lineHeight: 1.5,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{t('website.hero.subtitle')}
|
||||
</Text>
|
||||
<Text
|
||||
mb="lg"
|
||||
c={{ base: 'rgba(255, 255, 255, 0.9)', lg: 'gray.7' }}
|
||||
style={{
|
||||
fontSize: 'clamp(1rem, 1.5vw, 1.125rem)',
|
||||
lineHeight: 1.5,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{t('website.hero.description')}
|
||||
</Text>
|
||||
<Features />
|
||||
<Flex align="center" justify="center" gap="md">
|
||||
<Link href={`/${lang}/jobs/apply`}>
|
||||
<Button type="button" m="md" mb="2.5rem">
|
||||
{t('website.hero.cta')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
hiddenFrom="base"
|
||||
visibleFrom="lg"
|
||||
style={{
|
||||
width: '50%',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/home_nurse.webp"
|
||||
alt="Nurse reviewing qualifications for a healthcare role in Germany"
|
||||
placeholder="blur"
|
||||
priority
|
||||
sizes="(max-width: 62em) 0vw, 50vw"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
clipPath: isRtl ? 'none' : 'polygon(12% 0, 100% 0%, 100% 100%, 0 100%)',
|
||||
transform: isRtl ? 'scaleX(-1)' : 'none'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
hiddenFrom="lg"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
bg="rgba(0, 0, 0, 0.85)"
|
||||
style={{ zIndex: 10 }}
|
||||
/>
|
||||
<Image
|
||||
src="/home_nurse.webp"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
placeholder="blur"
|
||||
sizes="100vw"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const CallToActionSection: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const { lang } = useParams<{ lang: string }>()
|
||||
return (
|
||||
<Box component="section">
|
||||
<Container size="xl" w="100%" py="5rem">
|
||||
<Stack align="center">
|
||||
<Title
|
||||
order={2}
|
||||
fw={700}
|
||||
mb="md"
|
||||
ta="center"
|
||||
style={{
|
||||
fontSize: 'clamp(1.5rem, 3vw, 1.875rem)',
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
{t('website.cta.title')}
|
||||
</Title>
|
||||
<Container size="sm" px={0}>
|
||||
<Text
|
||||
mb="md"
|
||||
ta="left"
|
||||
style={{
|
||||
fontSize: 'clamp(0.875rem, 1.5vw, 1rem)',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{t('website.cta.description')}
|
||||
</Text>
|
||||
</Container>
|
||||
<Flex align="center" justify="center">
|
||||
<Stack pt="md" align="center">
|
||||
<Link href={`/${lang}/jobs/apply`}>
|
||||
<Button>{t('website.cta.button')}</Button>
|
||||
</Link>
|
||||
<Text
|
||||
ta="center"
|
||||
c="dimmed"
|
||||
mt="xs"
|
||||
style={{
|
||||
fontSize: 'clamp(0.75rem, 1.2vw, 0.875rem)',
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{t('website.cta.disclaimer')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Box mt="2.5rem" mb="4rem" w="100%">
|
||||
<HomeResultsPreview />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<CallToActionSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
169
apps/website/components/home/HomeResultsPreview.tsx
Normal file
169
apps/website/components/home/HomeResultsPreview.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { QualificationCardStatus } from '@heygermany/sdk'
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from '@pikku/mantine/core'
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconIdBadge2,
|
||||
IconSchool,
|
||||
IconWorld,
|
||||
} from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { Qualification } from '@/components/jobs/results/Qualification'
|
||||
import { QualificationSectionStatusCard } from '@/components/jobs/results/QualificationSectionStatusCard'
|
||||
import { type I18nString } from '@pikku/react'
|
||||
|
||||
type PreviewSection = {
|
||||
key: 'education' | 'license' | 'language' | 'additional'
|
||||
label: I18nString
|
||||
status: QualificationCardStatus
|
||||
color: 'education' | 'license' | 'language' | 'additional'
|
||||
summaryIcon: ReactNode
|
||||
sectionIcon?: ReactNode
|
||||
qualification?: {
|
||||
title: string
|
||||
description: string
|
||||
status: QualificationCardStatus
|
||||
requirements: string[]
|
||||
steps?: string[]
|
||||
progress?: number
|
||||
}
|
||||
}
|
||||
|
||||
const buildPreviewSections = (t: ReturnType<typeof useI18n>): PreviewSection[] => [
|
||||
{
|
||||
key: 'education',
|
||||
label: t('enums.qualificationsections.education'),
|
||||
status: 'completed',
|
||||
color: 'education',
|
||||
summaryIcon: <IconSchool size={18} />,
|
||||
sectionIcon: <IconSchool size={34} />,
|
||||
qualification: {
|
||||
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'),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'license',
|
||||
label: t('enums.qualificationsections.license'),
|
||||
status: 'required',
|
||||
color: 'license',
|
||||
summaryIcon: <IconIdBadge2 size={18} />,
|
||||
sectionIcon: <IconIdBadge2 size={34} />,
|
||||
qualification: {
|
||||
title: t('jobs.qualifications.license.nursing.title'),
|
||||
description: t('jobs.qualifications.license.nursing.description'),
|
||||
status: 'required',
|
||||
requirements: [t('jobs.qualifications.license.nursing.requirements.license')],
|
||||
steps: [t('jobs.qualifications.license.nursing.steps.obtain')],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
label: t('enums.qualificationsections.language'),
|
||||
status: 'in-progress',
|
||||
color: 'language',
|
||||
summaryIcon: <IconWorld size={18} />,
|
||||
sectionIcon: <IconWorld size={34} />,
|
||||
qualification: {
|
||||
title: t('jobs.qualifications.language.proficiency.title'),
|
||||
description: t('jobs.qualifications.language.proficiency.description'),
|
||||
status: 'in-progress',
|
||||
progress: 75,
|
||||
requirements: [
|
||||
t('jobs.qualifications.language.proficiency.requirements.b2'),
|
||||
t('jobs.qualifications.language.proficiency.requirements.certificate'),
|
||||
],
|
||||
steps: [
|
||||
t('jobs.qualifications.language.proficiency.steps.improve'),
|
||||
t('jobs.qualifications.language.proficiency.steps.getcertificate'),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'additional',
|
||||
label: t('enums.qualificationsections.additional'),
|
||||
status: 'required',
|
||||
color: 'additional',
|
||||
summaryIcon: <IconBriefcase size={18} />,
|
||||
},
|
||||
]
|
||||
|
||||
export function HomeResultsPreview() {
|
||||
const t = useI18n()
|
||||
const sections = buildPreviewSections(t)
|
||||
const detailedSections = sections.filter(
|
||||
(section): section is PreviewSection & { qualification: NonNullable<PreviewSection['qualification']>; sectionIcon: ReactNode } =>
|
||||
Boolean(section.qualification && section.sectionIcon)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box bg="white" px={{ base: 'md', md: 'xl' }} py={{ base: 'lg', md: 'xl' }}>
|
||||
<Flex gap="md" justify="center" wrap="wrap">
|
||||
{sections.map((section) => (
|
||||
<QualificationSectionStatusCard
|
||||
key={section.key}
|
||||
label={section.label}
|
||||
status={section.status}
|
||||
color={`var(--mantine-color-${section.color}-6)`}
|
||||
icon={section.summaryIcon}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
bg="gray.0"
|
||||
px={{ base: 'md', md: 'xl' }}
|
||||
pt={{ base: 'xl', md: '3rem' }}
|
||||
pb={{ base: '3rem', md: '4.5rem' }}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing={{ base: 0, md: 'xl' }}>
|
||||
{detailedSections.map((section, index) => (
|
||||
<Box
|
||||
key={section.key}
|
||||
display="flex"
|
||||
h={{ base: 'auto', md: '100%' }}
|
||||
pt={{ base: index === 0 ? 0 : 'xl', md: 0 }}
|
||||
style={{ flexDirection: 'column' }}
|
||||
>
|
||||
<Group align="center" gap="xs" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon color={section.color} radius="md" size="xl" variant="transparent">
|
||||
{section.sectionIcon}
|
||||
</ThemeIcon>
|
||||
<Text c="gray.9" fw={700} size="xl">
|
||||
{section.label}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Qualification
|
||||
title={section.qualification.title}
|
||||
description={section.qualification.description}
|
||||
status={section.qualification.status}
|
||||
requirements={section.qualification.requirements}
|
||||
steps={section.qualification.steps}
|
||||
progress={section.qualification.progress}
|
||||
fillHeight={false}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
165
apps/website/components/jobs/DemoCandidateResultPage.tsx
Normal file
165
apps/website/components/jobs/DemoCandidateResultPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import type { CandidateResults } from '@heygermany/sdk'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { useSearchParams } from '@/framework/navigation'
|
||||
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 = useSearchParams()
|
||||
const candidateName = searchParams.get('name') || 'Maria Santos'
|
||||
const candidateResults = buildDemoCandidateResults(t, candidateName)
|
||||
|
||||
return (
|
||||
<CandidateResult
|
||||
candidateResults={candidateResults}
|
||||
ctaPublicMode={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
193
apps/website/components/jobs/form/BasicInfo.tsx
Normal file
193
apps/website/components/jobs/form/BasicInfo.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { GermanStates } from '@heygermany/sdk'
|
||||
import { MultiSelect, Radio, Stack, Title, Group, Box, Input, Alert } from '@pikku/mantine/core'
|
||||
import { DateInput } from '@mantine/dates'
|
||||
import { IconInfoCircle } from '@tabler/icons-react'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { CountrySearch } from './CountrySearch'
|
||||
import { SUPPORTED_COUNTRIES } from '@/config'
|
||||
|
||||
export const BasicInfo: React.FunctionComponent = () => {
|
||||
const t = useI18n()
|
||||
const { control, formState: { errors } } = useFormContext()
|
||||
|
||||
const countryEducation = useWatch({ control, name: 'countryEducation' })
|
||||
const isUnsupportedCountry = countryEducation && !SUPPORTED_COUNTRIES.includes(countryEducation)
|
||||
|
||||
const germanStates = Object.values(GermanStates)
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t('jobs.apply.steps.basicinfo')}</Title>
|
||||
|
||||
{/* German States Multi-Select */}
|
||||
<Box>
|
||||
<Controller
|
||||
name="stateWorkPreference"
|
||||
control={control}
|
||||
rules={{ required: t('jobs.basicinfo.validation.staterequired') }}
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
label={t('jobs.basicinfo.worklocation.question')}
|
||||
value={field.value || []}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.includes('ALL')) {
|
||||
// If ALL is selected, select all states
|
||||
if (!field.value?.includes('ALL')) {
|
||||
field.onChange(germanStates)
|
||||
} else {
|
||||
// If ALL is already selected and user clicks it again, deselect all
|
||||
field.onChange([])
|
||||
}
|
||||
} else {
|
||||
field.onChange(newValue)
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ value: 'ALL', label: t('jobs.basicinfo.worklocation.selectall')},
|
||||
...germanStates.map(state => ({ value: state, label: state }))
|
||||
]}
|
||||
placeholder={t('jobs.basicinfo.worklocation.placeholder')}
|
||||
searchable
|
||||
clearable
|
||||
error={errors.stateWorkPreference?.message as string}
|
||||
w="100%"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Open to Work */}
|
||||
<Box>
|
||||
<Input.Wrapper label={t('jobs.basicinfo.jointalentpool.question')}>
|
||||
<Controller
|
||||
name="joinTalentPool"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Radio.Group
|
||||
value={field.value?.toString()}
|
||||
onChange={(value) => field.onChange(value === 'true' ? true : false)}
|
||||
error={errors.joinTalentPool?.message as string}
|
||||
>
|
||||
<Group gap="lg" mb="xs">
|
||||
<Radio value="true" label={t('jobs.basicinfo.yes')} />
|
||||
<Radio value="false" label={t('jobs.basicinfo.no')} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
)}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Box>
|
||||
|
||||
{/* Living in Germany */}
|
||||
<Box>
|
||||
<Input.Wrapper label={t('jobs.basicinfo.livingingermany.question')}>
|
||||
<Controller
|
||||
name="livingInGermany"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Radio.Group
|
||||
value={field.value?.toString()}
|
||||
onChange={(value) => field.onChange(value === 'true' ? true : false)}
|
||||
error={errors.livingInGermany?.message as string}
|
||||
>
|
||||
<Group gap="lg" mb="xs">
|
||||
<Radio value="true" label={t('jobs.basicinfo.yes')} />
|
||||
<Radio value="false" label={t('jobs.basicinfo.no')} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
)}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Box>
|
||||
|
||||
{/* EU Passport */}
|
||||
<Box>
|
||||
<Input.Wrapper label={t('jobs.basicinfo.eupassport.question')}>
|
||||
<Controller
|
||||
name="euPassport"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => (value === true || value === false) || t('jobs.basicinfo.validation.optionrequired')
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Radio.Group
|
||||
value={field.value?.toString()}
|
||||
onChange={(value) => field.onChange(value === 'true' ? true : false)}
|
||||
error={errors.euPassport?.message as string}
|
||||
>
|
||||
<Group gap="lg" mb="xs">
|
||||
<Radio value="true" label={t('jobs.basicinfo.yes')} />
|
||||
<Radio value="false" label={t('jobs.basicinfo.no')} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
)}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Box>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<Box>
|
||||
<Controller
|
||||
name="dateOfBirth"
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('jobs.basicinfo.validation.dateofbirthrequired')
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<DateInput
|
||||
label={t('jobs.basicinfo.dateofbirth.question')}
|
||||
placeholder={t('jobs.basicinfo.dateofbirth.placeholder')}
|
||||
value={field.value ? new Date(field.value) : null}
|
||||
onChange={field.onChange}
|
||||
error={errors.dateOfBirth?.message as string}
|
||||
clearable
|
||||
weekendDays={[]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Education Country */}
|
||||
<Box>
|
||||
<Controller
|
||||
name="countryEducation"
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('jobs.basicinfo.validation.countryrequired'),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<CountrySearch
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
label={t('jobs.basicinfo.education.question')}
|
||||
placeholder={t('jobs.basicinfo.education.placeholder')}
|
||||
error={errors.countryEducation?.message as string}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isUnsupportedCountry && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title={t('jobs.basicinfo.unsupportedcountry.title')}
|
||||
color="blue"
|
||||
mt="sm"
|
||||
>
|
||||
{t('jobs.basicinfo.unsupportedcountry.message')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
37
apps/website/components/jobs/form/CandidateSection.tsx
Normal file
37
apps/website/components/jobs/form/CandidateSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { Box, Stack, Title, Text, Flex, Container } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
interface CandidateSectionProps {
|
||||
title: string
|
||||
example: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const CandidateSection: React.FunctionComponent<CandidateSectionProps> = ({
|
||||
title,
|
||||
example,
|
||||
description,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<Flex gap="md" direction={{ base: 'column', md: 'row' }}>
|
||||
<Stack flex={1}>
|
||||
<Title order={2}>{asI18n(title)}</Title>
|
||||
|
||||
<Text size="xl" mb="md">
|
||||
{asI18n(example)}
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
{asI18n(description)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box flex={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
43
apps/website/components/jobs/form/CountrySearch.tsx
Normal file
43
apps/website/components/jobs/form/CountrySearch.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { Select } from '@pikku/mantine/core'
|
||||
import { useGetCountries } from '@/hooks/useGetCountries'
|
||||
|
||||
interface CountrySearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
label?: any
|
||||
placeholder?: any
|
||||
}
|
||||
|
||||
export const CountrySearch: React.FunctionComponent<CountrySearchProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
label,
|
||||
placeholder
|
||||
}) => {
|
||||
const { data: countriesMap = {}, isLoading } = useGetCountries()
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
return Object.entries(countriesMap).map(([iso3, name]) => ({
|
||||
value: iso3,
|
||||
label: name
|
||||
}))
|
||||
}, [countriesMap])
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label as any}
|
||||
value={value}
|
||||
data={countryOptions}
|
||||
onChange={(selectedValue) => onChange(selectedValue || '')}
|
||||
placeholder={placeholder as any}
|
||||
searchable
|
||||
error={error as any}
|
||||
disabled={isLoading && countryOptions.length === 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
apps/website/components/jobs/form/Done.tsx
Normal file
21
apps/website/components/jobs/form/Done.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { Stack, Title, Text, Container, Paper } from "@pikku/mantine/core"
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
|
||||
export const Done: React.FunctionComponent<{ name: string }> = ({ name }) => {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Container p={0} size='md' mt='10%'>
|
||||
<Paper p={{ base: '4rem', lg: '8rem' }} withBorder radius='lg'>
|
||||
<Stack>
|
||||
<Title order={2}>
|
||||
{t('jobs.done.title', { name })}
|
||||
</Title>
|
||||
<Text size="xl" lineBreaks>{t('jobs.done.body')}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
144
apps/website/components/jobs/form/GermanLanguage.tsx
Normal file
144
apps/website/components/jobs/form/GermanLanguage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { Controller, useFormContext } from 'react-hook-form'
|
||||
import { Checkbox, Box, Text, Group, Stack, Title } from '@pikku/mantine/core'
|
||||
import type { DB } from '@heygermany/sdk'
|
||||
import {
|
||||
ApplicationCandidateData,
|
||||
ApplicationCandidateLanguage,
|
||||
CandidateDocumentTypes,
|
||||
LanguageLevels,
|
||||
} from '@heygermany/sdk'
|
||||
import { FileUpload } from '../../ui/FileUpload'
|
||||
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
|
||||
import { useEffect } from 'react'
|
||||
import { CandidateSection } from './CandidateSection'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { EnumSelect } from '@/components/common/EnumSelect'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const GermanLanguage: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
|
||||
const t = useI18n()
|
||||
const { control, trigger, clearErrors, setValue, formState: { errors }, watch } = useFormContext<ApplicationCandidateLanguage>()
|
||||
const languageCertificateProvided = watch('languageCertificateProvided')
|
||||
const selfAssessment = watch('languageSelfAssessmentLevel')
|
||||
|
||||
useEffect(() => {
|
||||
if (languageCertificateProvided === null) {
|
||||
setValue('languageCertificateProvided', true)
|
||||
} else if (languageCertificateProvided === true) {
|
||||
setValue('languageSelfAssessmentLevel', null)
|
||||
}
|
||||
clearErrors()
|
||||
}, [languageCertificateProvided])
|
||||
|
||||
useEffect(() => {
|
||||
if (selfAssessment) {
|
||||
clearErrors()
|
||||
}
|
||||
}, [selfAssessment])
|
||||
|
||||
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LANGUAGE, trigger)
|
||||
|
||||
const validateLanguages = (files?: any[], selfAssessment?: DB.LanguageLevel | null, certificateProvided?: boolean | null): boolean | string => {
|
||||
if (certificateProvided) {
|
||||
if (!files || files.length === 0) {
|
||||
return t('jobs.germanlanguage.validation.certificaterequired')
|
||||
}
|
||||
if (files.length > 5) {
|
||||
return t('jobs.germanlanguage.validation.maxfiles')
|
||||
}
|
||||
} else {
|
||||
if (!selfAssessment) {
|
||||
return t('jobs.germanlanguage.validation.selfassessmentrequired')
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<CandidateSection
|
||||
title={t('jobs.germanlanguage.title')}
|
||||
example={t('jobs.germanlanguage.example')}
|
||||
description={t('jobs.germanlanguage.description')}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Controller
|
||||
name="languageCertificateProvided"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Group gap="xs" mb="sm">
|
||||
<Checkbox
|
||||
id="language-certificate-not-provided"
|
||||
checked={field.value !== true}
|
||||
onChange={() => field.onChange(!field.value)}
|
||||
/>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ cursor: 'pointer' }}
|
||||
component="label"
|
||||
htmlFor="language-certificate-not-provided"
|
||||
>
|
||||
{t('jobs.germanlanguage.nocertificate')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
|
||||
{languageCertificateProvided && (
|
||||
<Controller
|
||||
name="languageCertificates"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: () => validateLanguages(candidate.languageCertificates, undefined, languageCertificateProvided)
|
||||
}}
|
||||
render={() => (
|
||||
<FileUpload
|
||||
files={candidate.languageCertificates.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
|
||||
uploadFiles={uploadFiles}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!languageCertificateProvided && (
|
||||
<Box>
|
||||
<Title order={3} size="lg" mb="md">{t('jobs.germanlanguage.selfassessment.title')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t('jobs.germanlanguage.selfassessment.description')}
|
||||
</Text>
|
||||
|
||||
<Controller
|
||||
name="languageSelfAssessmentLevel"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => validateLanguages(undefined, value, languageCertificateProvided)
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Box w="fit-content">
|
||||
<EnumSelect
|
||||
label={t('jobs.germanlanguage.selfassessment.levellabel')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
enumName='LanguageLevel'
|
||||
enumObject={LanguageLevels}
|
||||
placeholder={t('jobs.germanlanguage.selfassessment.placeholder')}
|
||||
error={errors.languageSelfAssessmentLevel?.message}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{errors.languageCertificates && (
|
||||
<Text size="sm" c="error">
|
||||
{asI18n(errors.languageCertificates?.message ?? '')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</CandidateSection>
|
||||
)
|
||||
}
|
||||
27
apps/website/components/jobs/form/HelpAffix.tsx
Normal file
27
apps/website/components/jobs/form/HelpAffix.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
|
||||
import { IconMailQuestion } from '@tabler/icons-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const HelpAffix: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div style={{ position: 'absolute', bottom: 20, right: 20 }} >
|
||||
<Tooltip label={asI18n('Need help? Contact us')} position="left">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href="mailto:info@hey-germany.com"
|
||||
size={50}
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
style={{
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
>
|
||||
<IconMailQuestion size={28} stroke={2} color="white" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
apps/website/components/jobs/form/Invalid.tsx
Normal file
75
apps/website/components/jobs/form/Invalid.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { Stack, Title, Container, Paper, Button, Text, List } from "@pikku/mantine/core"
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { pikku } from '@/pikku/http'
|
||||
import { ApplicantStatuses } from '@heygermany/sdk'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Invalid: React.FunctionComponent<{ name: string, candidateId: string }> = ({ name, candidateId }) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const resetApplicationMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await pikku().patch('/candidate/:candidateId', {
|
||||
candidateId,
|
||||
status: ApplicantStatuses.INITIAL,
|
||||
submittedAt: null
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['candidate'] })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Container p={0} size='md' mt={{ base: '2rem', md: '3rem' }}>
|
||||
<Paper p={{ base: '1.5rem', md: '2rem', lg: '2.5rem' }} withBorder radius='lg' bg='gray.0'>
|
||||
<Stack gap='lg'>
|
||||
<Title order={2} c='gray.7' lh={1.2}>
|
||||
{asI18n(`Hi ${name},`)}
|
||||
</Title>
|
||||
|
||||
<Stack gap='xs'>
|
||||
<Text size='lg' c='gray.7' lh={1.35}>
|
||||
{asI18n('Thanks for uploading your documents!')}
|
||||
</Text>
|
||||
<Text size='lg' c='gray.7' lh={1.35}>
|
||||
{asI18n('We've reviewed your files, but unfortunately, one or more of them couldn't be processed for your personal evaluation.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap='xs'>
|
||||
<Text size='lg' fw={600} c='gray.7' lh={1.3}>
|
||||
{asI18n('This can happen if:')}
|
||||
</Text>
|
||||
<List spacing={2} size='lg' c='gray.7' withPadding style={{ lineHeight: 1.3 }}>
|
||||
<List.Item>{asI18n('The document is incomplete or blurry')}</List.Item>
|
||||
<List.Item>{asI18n('The content is unclear or unreadable')}</List.Item>
|
||||
<List.Item>{asI18n('The file you uploaded is not related to the document we requested')}</List.Item>
|
||||
</List>
|
||||
</Stack>
|
||||
|
||||
<Text size='lg' c='gray.7' lh={1.35}>
|
||||
{asI18n('Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation.')}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
onClick={() => resetApplicationMutation.mutate()}
|
||||
loading={resetApplicationMutation.isPending}
|
||||
disabled={resetApplicationMutation.isPending}
|
||||
size="md"
|
||||
mt='xs'
|
||||
maw={280}
|
||||
mx='auto'
|
||||
variant='filled'
|
||||
color='yellow.5'
|
||||
c='black'
|
||||
>
|
||||
{asI18n('Upload documents again')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
95
apps/website/components/jobs/form/License.tsx
Normal file
95
apps/website/components/jobs/form/License.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Controller, useFormContext } from 'react-hook-form'
|
||||
import { Checkbox, Box, Text, Group } from '@pikku/mantine/core'
|
||||
import type { DB } from '@heygermany/sdk'
|
||||
import { ApplicationCandidateData, ApplicationCandidateLicense, CandidateDocumentTypes } from '@heygermany/sdk'
|
||||
import { FileUpload } from '../../ui/FileUpload'
|
||||
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
|
||||
import { useEffect } from 'react'
|
||||
import { CandidateSection } from './CandidateSection'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const License: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
|
||||
const t = useI18n()
|
||||
const { control, trigger, formState: { errors }, watch, setValue } = useFormContext<ApplicationCandidateLicense>()
|
||||
|
||||
const licenseProvided = watch('licenseProvided')
|
||||
|
||||
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.LICENSE, trigger)
|
||||
|
||||
const validateLicenses = (files: any[]): boolean | string => {
|
||||
if (!files || files.length === 0) {
|
||||
return t('jobs.license.validation.required')
|
||||
}
|
||||
|
||||
if (files.length > 5) {
|
||||
return t('jobs.license.validation.maxfiles')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseProvided === null) {
|
||||
setValue('licenseProvided', true)
|
||||
}
|
||||
}, [licenseProvided])
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseProvided === false) {
|
||||
trigger()
|
||||
}
|
||||
}, [licenseProvided])
|
||||
|
||||
return (
|
||||
<CandidateSection
|
||||
title={t('jobs.license.title')}
|
||||
example={t('jobs.license.example')}
|
||||
description={t('jobs.license.description')}
|
||||
>
|
||||
<Box>
|
||||
<Controller
|
||||
name="licenseProvided"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Group gap="xs" mb="lg">
|
||||
<Checkbox
|
||||
id="license-not-provided"
|
||||
checked={field.value === false}
|
||||
onChange={(e) => field.onChange(!e.currentTarget.checked)}
|
||||
/>
|
||||
<Text size="sm" style={{ cursor: 'pointer' }} component="label" htmlFor="license-not-provided">
|
||||
{t('jobs.license.nolicense')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
|
||||
{licenseProvided && (
|
||||
<Controller
|
||||
name="licenses"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: () => validateLicenses(candidate.licenses)
|
||||
}}
|
||||
render={() => (
|
||||
<FileUpload
|
||||
files={candidate.licenses.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
|
||||
uploadFiles={uploadFiles}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errors.licenses && (
|
||||
<Text size="sm" c="error" mt="xs">
|
||||
{asI18n(errors.licenses?.message ?? '')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</CandidateSection>
|
||||
)
|
||||
}
|
||||
58
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal file
58
apps/website/components/jobs/form/ProfessionalExperience.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Controller, useFormContext } from 'react-hook-form'
|
||||
import type { DB } from '@heygermany/sdk'
|
||||
import { ApplicationCandidateData, ApplicationCandidateWorkExperience, CandidateDocumentTypes } from '@heygermany/sdk'
|
||||
import { FileUpload } from '../../ui/FileUpload'
|
||||
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
|
||||
import { CandidateSection } from './CandidateSection'
|
||||
import { Box, Text } from '@pikku/mantine/core'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const ProfessionalExperience: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
|
||||
const t = useI18n()
|
||||
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateWorkExperience>()
|
||||
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.WORK_EXPERIENCE, trigger)
|
||||
|
||||
const validateWorkExperience = (files: any[]): boolean | string => {
|
||||
if (files && files.length > 5) {
|
||||
return t('jobs.professionalexperience.validation.maxfiles')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<CandidateSection
|
||||
title={t('jobs.professionalexperience.title')}
|
||||
example={t('jobs.professionalexperience.example')}
|
||||
description={t('jobs.professionalexperience.description')}
|
||||
>
|
||||
<Box>
|
||||
<Controller
|
||||
name="workExperience"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: () => validateWorkExperience(candidate.workExperience)
|
||||
}}
|
||||
render={() => (
|
||||
<Box mt="xs">
|
||||
<FileUpload
|
||||
files={candidate.workExperience.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
|
||||
uploadFiles={uploadFiles}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
{errors.workExperience && (
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{asI18n(errors.workExperience?.message ?? '')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</CandidateSection>
|
||||
)
|
||||
}
|
||||
182
apps/website/components/jobs/form/StartApplication.tsx
Normal file
182
apps/website/components/jobs/form/StartApplication.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import Link from "@/framework/link"
|
||||
import { Button, Checkbox, Stack, TextInput, Text, Anchor, Alert } from "@pikku/mantine/core"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { requestMagicLink } from "@/lib/auth"
|
||||
import { pikku } from "@/pikku/http"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useI18n } from "@/context/i18n-provider"
|
||||
import { useRouter, useParams } from "@/framework/navigation"
|
||||
import { useState } from "react"
|
||||
import { asI18n } from "@pikku/react"
|
||||
|
||||
export const StartApplication: React.FunctionComponent<{ initializing: boolean }> = ({ initializing }) => {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const lang = params.lang as string
|
||||
const t = useI18n()
|
||||
const [emailExists, setEmailExists] = useState(false)
|
||||
const [existingEmail, setExistingEmail] = useState('')
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
fullName: '',
|
||||
termsAndPrivacyAccepted: false
|
||||
}
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
if (data.termsAndPrivacyAccepted && data.fullName && data.email) {
|
||||
try {
|
||||
await pikku().post('/candidate', {
|
||||
name: data.fullName,
|
||||
email: data.email
|
||||
});
|
||||
(window as any).fbq?.('trackCustom', 'StartApplication', {
|
||||
source: 'check_my_qualification_button',
|
||||
page: '/en/jobs/apply'
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['candidate'] });
|
||||
router.push(`/${lang}/jobs/application`);
|
||||
} catch (error: any) {
|
||||
if (error.status === 409) {
|
||||
setEmailExists(true)
|
||||
setExistingEmail(data.email)
|
||||
setHasError(false)
|
||||
throw error
|
||||
}
|
||||
setHasError(true)
|
||||
setEmailExists(false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sendMagicLinkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await requestMagicLink(existingEmail)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack component="form" gap="md" onSubmit={handleSubmit(data => mutation.mutateAsync(data))}>
|
||||
<Controller
|
||||
name="fullName"
|
||||
control={control}
|
||||
rules={{ required: t('jobs.startapplication.validation.fullnamerequired') }}
|
||||
disabled={initializing}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id="fullName"
|
||||
label={t('jobs.startapplication.fullname')}
|
||||
error={errors.fullName?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('jobs.startapplication.validation.emailrequired'),
|
||||
pattern: {
|
||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: t('jobs.startapplication.validation.emailinvalid')
|
||||
}
|
||||
}}
|
||||
disabled={initializing}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t('jobs.startapplication.email')}
|
||||
autoComplete="email"
|
||||
error={errors.email?.message}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="termsAndPrivacyAccepted"
|
||||
control={control}
|
||||
disabled={initializing}
|
||||
rules={{ required: t('jobs.startapplication.validation.termsrequired') }}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={field.disabled}
|
||||
error={errors.termsAndPrivacyAccepted?.message}
|
||||
label={
|
||||
<Text size="sm">
|
||||
{t('jobs.startapplication.termstext.prefix')}{asI18n(' ')}
|
||||
<Anchor component={Link} href={`/${lang}/legal/terms-and-conditions`} size="sm">
|
||||
{t('jobs.startapplication.termstext.terms')}
|
||||
</Anchor>{asI18n(' ')}
|
||||
{t('jobs.startapplication.termstext.and')}{asI18n(' ')}
|
||||
<Anchor component={Link} href={`/${lang}/legal/privacy`} size="sm">
|
||||
{t('jobs.startapplication.termstext.privacy')}
|
||||
</Anchor>{asI18n(' ')}
|
||||
{t('jobs.startapplication.termstext.suffix')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{hasError && (
|
||||
<Alert color="red" title={t('jobs.startapplication.error.title')}>
|
||||
<Text size="sm">
|
||||
{t('jobs.startapplication.error.message')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{emailExists && (
|
||||
<Alert color="blue" title={t('jobs.startapplication.emailexists.title')}>
|
||||
<Text size="sm" mb="md">
|
||||
{t('jobs.startapplication.emailexists.message')}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => sendMagicLinkMutation.mutate()}
|
||||
loading={sendMagicLinkMutation.isPending}
|
||||
disabled={sendMagicLinkMutation.isSuccess}
|
||||
>
|
||||
{sendMagicLinkMutation.isSuccess
|
||||
? t('jobs.startapplication.emailexists.sent')
|
||||
: t('jobs.startapplication.emailexists.button')}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
loading={mutation.isPending}
|
||||
disabled={initializing || mutation.isPending}
|
||||
px="xl"
|
||||
>
|
||||
{t('jobs.startapplication.checkqualification')}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('jobs.startapplication.freetext')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
97
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal file
97
apps/website/components/jobs/form/TrainingCertificates.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { Controller, useFormContext } from 'react-hook-form'
|
||||
import type { DB } from '@heygermany/sdk'
|
||||
import { ApplicationCandidateData, ApplicationCandidateEducation, CandidateDocumentTypes } from '@heygermany/sdk'
|
||||
import { FileUpload } from '../../ui/FileUpload'
|
||||
import { useUploadCandidateFiles } from '@/hooks/useUploadCandidateFiles'
|
||||
import { CandidateSection } from './CandidateSection'
|
||||
import { Alert, Box, List, Text, ThemeIcon } from '@pikku/mantine/core'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { IconCheck, IconInfoCircle } from '@tabler/icons-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const TrainingCertificates: React.FunctionComponent<{ candidate: ApplicationCandidateData }> = ({ candidate }) => {
|
||||
const t = useI18n()
|
||||
const { control, trigger, formState: { errors } } = useFormContext<ApplicationCandidateEducation>()
|
||||
const { uploadFiles, deleteFile } = useUploadCandidateFiles(candidate.candidateId, CandidateDocumentTypes.EDUCATION, trigger)
|
||||
const uploadedEducationDocuments = [...candidate.education]
|
||||
const i18n = (key: string) => t(key as any)
|
||||
const educationCopy = {
|
||||
title: i18n('jobs.educationdocuments.title'),
|
||||
example: i18n('jobs.educationdocuments.example'),
|
||||
description: i18n('jobs.educationdocuments.description'),
|
||||
validationRequired: i18n('jobs.educationdocuments.validation.required'),
|
||||
validationMaxFiles: i18n('jobs.educationdocuments.validation.maxfiles'),
|
||||
checklistTitle: i18n('jobs.educationdocuments.checklist.title'),
|
||||
checklist: [
|
||||
i18n('jobs.educationdocuments.checklist.item1'),
|
||||
i18n('jobs.educationdocuments.checklist.item2'),
|
||||
i18n('jobs.educationdocuments.checklist.item3')
|
||||
]
|
||||
}
|
||||
|
||||
const validateEducation = (files: DB.CandidateDocument[]): string | boolean => {
|
||||
if (!files || files.length === 0) {
|
||||
return educationCopy.validationRequired
|
||||
} else if (files.length > 10) {
|
||||
return educationCopy.validationMaxFiles
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<CandidateSection
|
||||
title={educationCopy.title}
|
||||
example={educationCopy.example}
|
||||
description={educationCopy.description}
|
||||
>
|
||||
<Box>
|
||||
{educationCopy.checklist.length > 0 && (
|
||||
<Alert
|
||||
color="blue"
|
||||
mb="md"
|
||||
title={asI18n(educationCopy.checklistTitle)}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<List size="sm" spacing="xs">
|
||||
{educationCopy.checklist.map((item) => (
|
||||
<List.Item
|
||||
key={item}
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" color="blue.6">
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{asI18n(item)}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="education"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: () => validateEducation(uploadedEducationDocuments)
|
||||
}}
|
||||
render={() => (
|
||||
<Box mt="xs">
|
||||
<FileUpload
|
||||
files={uploadedEducationDocuments.map((file: DB.CandidateDocument) => ({ id: file.documentId, fileName: file.fileName }))}
|
||||
uploadFiles={uploadFiles}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
{errors.education && (
|
||||
<Text size="sm" c="error" mt="xs">{asI18n(errors.education.message ?? '')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</CandidateSection>
|
||||
)
|
||||
}
|
||||
231
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal file
231
apps/website/components/jobs/results/ApplicantProfile.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
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 { useParams, useSearchParams } from '@/framework/navigation'
|
||||
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()
|
||||
const searchParams = useSearchParams()
|
||||
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
|
||||
141
apps/website/components/jobs/results/CTASection.tsx
Normal file
141
apps/website/components/jobs/results/CTASection.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
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,63 @@
|
||||
'use client'
|
||||
|
||||
import { Stack, Text, List, Divider } from '@pikku/mantine/core'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { mKey } from '@/i18n/messages'
|
||||
import { compareRecognitionOptionKeys } from '@/framework/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,42 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
101
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal file
101
apps/website/components/jobs/results/DocumentsChecklist.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
67
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal file
67
apps/website/components/jobs/results/GermanLanguageTips.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
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 '@/framework/content/paid-course-providers'
|
||||
import { freeCourseLinks as freeLinks } from '@/framework/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>
|
||||
)
|
||||
}
|
||||
206
apps/website/components/jobs/results/GetHelp.tsx
Normal file
206
apps/website/components/jobs/results/GetHelp.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
58
apps/website/components/jobs/results/HowToApply.tsx
Normal file
58
apps/website/components/jobs/results/HowToApply.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
233
apps/website/components/jobs/results/NextSteps.tsx
Normal file
233
apps/website/components/jobs/results/NextSteps.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
152
apps/website/components/jobs/results/Qualification.tsx
Normal file
152
apps/website/components/jobs/results/Qualification.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
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,82 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
189
apps/website/components/jobs/results/QualificationsSection.tsx
Normal file
189
apps/website/components/jobs/results/QualificationsSection.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
963
apps/website/components/pilot/PilotLandingPage.module.css
Normal file
963
apps/website/components/pilot/PilotLandingPage.module.css
Normal file
@@ -0,0 +1,963 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.page {
|
||||
--background: hsl(210 20% 98%);
|
||||
--foreground: hsl(210 50% 10%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(210 50% 10%);
|
||||
--primary: hsl(199 89% 28%);
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(160 60% 40%);
|
||||
--muted: hsl(210 20% 95%);
|
||||
--muted-foreground: hsl(210 15% 45%);
|
||||
--accent: hsl(36 100% 55%);
|
||||
--border: hsl(210 20% 90%);
|
||||
--radius: 0.75rem;
|
||||
--shadow-card: 0 4px 24px -4px hsl(199 89% 28% / 0.08);
|
||||
--shadow-card-hover: 0 12px 40px -8px hsl(199 89% 28% / 0.15);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--primary) 90%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--primary-foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.navbarInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.navLinks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.navLinks button,
|
||||
.mobileMenu button,
|
||||
.mobileToggle,
|
||||
.headerButton,
|
||||
.heroButton,
|
||||
.submitButton,
|
||||
.heroOutlineButton {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.navLinks button:not(.headerButton),
|
||||
.mobileMenu button:not(.headerButton),
|
||||
.mobileToggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--primary-foreground) 80%, transparent);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.navLinks button:not(.headerButton):hover,
|
||||
.mobileMenu button:not(.headerButton):hover,
|
||||
.mobileToggle:hover {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.heroButton,
|
||||
.headerButton,
|
||||
.submitButton,
|
||||
.heroOutlineButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.heroButton,
|
||||
.headerButton,
|
||||
.submitButton {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.heroButton:hover,
|
||||
.headerButton:hover,
|
||||
.submitButton:hover {
|
||||
background: color-mix(in srgb, var(--accent) 90%, transparent);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgb(0 0 0 / 0.1),
|
||||
0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
height: 2.25rem;
|
||||
padding-inline: 0.75rem;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.heroButton {
|
||||
height: clamp(3rem, 6vh, 3.5rem);
|
||||
padding-inline: clamp(1.5rem, 2.5vw, 2.5rem);
|
||||
border-radius: calc(var(--radius) + 0.25rem);
|
||||
font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
height: 2.875rem;
|
||||
padding-inline: 2rem;
|
||||
border-radius: calc(var(--radius) + 0.25rem);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroOutlineButton {
|
||||
border: 2px solid color-mix(in srgb, var(--primary-foreground) 30%, transparent);
|
||||
height: clamp(3rem, 6vh, 3.5rem);
|
||||
padding-inline: clamp(1.5rem, 2.5vw, 2.5rem);
|
||||
border-radius: calc(var(--radius) + 0.25rem);
|
||||
background: color-mix(in srgb, var(--primary-foreground) 10%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.heroOutlineButton:hover {
|
||||
background: color-mix(in srgb, var(--primary-foreground) 20%, transparent);
|
||||
}
|
||||
|
||||
.mobileToggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileHeaderActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroMedia {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.heroImage {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heroOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--primary) 95%, transparent) 0%,
|
||||
color-mix(in srgb, var(--primary) 85%, transparent) 50%,
|
||||
color-mix(in srgb, var(--primary) 60%, transparent) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 100svh;
|
||||
padding-top: clamp(5.5rem, 9vh, 8rem);
|
||||
padding-bottom: clamp(4rem, 8vh, 7rem);
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 46rem;
|
||||
display: grid;
|
||||
gap: clamp(0.875rem, 1.75vh, 1.5rem);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.heroBadge {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
padding: 0.375rem 1rem;
|
||||
margin-bottom: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin: 0;
|
||||
color: var(--primary-foreground);
|
||||
font-size: clamp(2rem, 1.3rem + 2.5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.heroTitle span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--primary-foreground) 80%, transparent);
|
||||
font-size: clamp(1rem, 0.92rem + 0.5vw, 1.2rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
margin-top: clamp(0.25rem, 1vh, 0.75rem);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(0.75rem, 1.2vw, 1rem);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scrollIndicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: clamp(1rem, 3vh, 2rem);
|
||||
z-index: 1;
|
||||
color: color-mix(in srgb, var(--primary-foreground) 50%, transparent);
|
||||
transform: translateX(-50%);
|
||||
animation: pilot-bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pilot-bounce {
|
||||
0%, 100% { transform: translateX(-50%) translateY(0); }
|
||||
50% { transform: translateX(-50%) translateY(10px); }
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 5rem 0 7rem;
|
||||
}
|
||||
|
||||
.sectionMuted {
|
||||
background: color-mix(in srgb, var(--muted) 50%, transparent);
|
||||
}
|
||||
|
||||
.sectionFaq {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.sectionHeaderCentered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionHeaderCentered .sectionEyebrow,
|
||||
.sectionHeaderCentered h2,
|
||||
.sectionHeaderCentered p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionHeaderCentered p {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.sectionEyebrow,
|
||||
.registerEyebrow {
|
||||
color: var(--secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sectionHeader h2,
|
||||
.registerCopy h2,
|
||||
.successWrap h2 {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sectionHeader p,
|
||||
.registerCopy p,
|
||||
.successWrap p {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 42rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.problemGrid,
|
||||
.offerGrid,
|
||||
.benefitGrid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.problemGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.problemCard,
|
||||
.offerCard,
|
||||
.faqItem,
|
||||
.formCard,
|
||||
.successWrap {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.problemCard {
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
transition:
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.problemCard:hover,
|
||||
.offerCard:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.problemIcon,
|
||||
.offerIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--primary) 10%, white);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.problemIcon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.problemCard h3,
|
||||
.offerCard h3,
|
||||
.benefitItem h3,
|
||||
.formCard h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.problemCard p,
|
||||
.offerCard p,
|
||||
.benefitItem p,
|
||||
.faqAnswer p,
|
||||
.formHint,
|
||||
.footerCopyright {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.offerGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.offerCard {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
transition:
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.offerStep {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
color: color-mix(in srgb, var(--primary) 5%, white);
|
||||
font-size: 3.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.offerIcon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.offerCard p {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.offerHighlights {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.offerHighlights li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.offerHighlights svg {
|
||||
flex: none;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.benefitGrid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.benefitItem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.benefitIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--secondary) 10%, white);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.benefitItem h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.faqList {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.faqItem {
|
||||
border-radius: 1rem;
|
||||
padding: 0 1.5rem;
|
||||
box-shadow: 0 1px 2px hsl(210 50% 10% / 0.04);
|
||||
}
|
||||
|
||||
.faqItem summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 0;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--card-foreground);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.faqItem summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faqItem[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.faqItem summary svg {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.faqAnswer {
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.faqAnswer p + p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.registerSection {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.registerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.registerCopy h2 {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.registerCopy p {
|
||||
color: color-mix(in srgb, var(--primary-foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.registerEyebrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.registerBenefits {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
margin: 2rem 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.registerBenefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
color: color-mix(in srgb, var(--primary-foreground) 90%, transparent);
|
||||
}
|
||||
|
||||
.registerBenefits svg {
|
||||
flex: none;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.formWrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.formCard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.75rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.formCard label {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.formCard label span {
|
||||
color: var(--foreground);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.formCard input,
|
||||
.formCard select,
|
||||
.formCard textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.formCard input:focus,
|
||||
.formCard select:focus,
|
||||
.formCard textarea:focus {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.formCard textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.heroButton:disabled,
|
||||
.headerButton:disabled,
|
||||
.submitButton:disabled,
|
||||
.heroOutlineButton:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.formHint {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
color: hsl(0 72% 42%);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.successWrap {
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.successWrap h2 {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.successWrap p {
|
||||
color: color-mix(in srgb, var(--primary-foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--secondary) 20%, transparent);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: var(--foreground);
|
||||
padding: 2.25rem 0;
|
||||
}
|
||||
|
||||
.footerCenter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerLogoList {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.35rem 2.75rem;
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.25rem;
|
||||
border: 1px solid color-mix(in srgb, var(--background) 16%, transparent);
|
||||
border-radius: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.footerLogoPair {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem 1rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footerLogoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footerLogoImage {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
height: clamp(2.2rem, 3.6vw, 3.1rem);
|
||||
}
|
||||
|
||||
.footerLinks {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footerLinks a {
|
||||
color: color-mix(in srgb, var(--background) 78%, transparent);
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: color 180ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footerLinks a:hover,
|
||||
.footerLinks a:focus-visible {
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.footerMetaText {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--background) 60%, transparent);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footerCopyright {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--background) 40%, transparent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.problemGrid,
|
||||
.offerGrid,
|
||||
.benefitGrid,
|
||||
.registerGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.benefitGrid {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.navLinks {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobileHeaderButton {
|
||||
height: 2.25rem;
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mobileToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
|
||||
.mobileMenu .heroButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
padding-top: clamp(5.5rem, 13vh, 7rem);
|
||||
padding-bottom: clamp(4rem, 8vh, 5rem);
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.heroActions,
|
||||
.fieldGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heroButton,
|
||||
.heroOutlineButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 4.5rem 0 5rem;
|
||||
}
|
||||
|
||||
.footerLogoList {
|
||||
gap: 0.55rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.footerLogoPair {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.footerLogoImage {
|
||||
height: clamp(0.8rem, 4vw, 1.55rem);
|
||||
}
|
||||
|
||||
.footerMetaText {
|
||||
font-size: clamp(0.58rem, 2.4vw, 0.82rem);
|
||||
}
|
||||
|
||||
.footerLinks {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.footerLinks a {
|
||||
font-size: clamp(0.42rem, 1.9vw, 0.66rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.brand {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mobileHeaderButton {
|
||||
padding-inline: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 860px) {
|
||||
.heroInner {
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 3.75rem;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(1.9rem, 1.35rem + 2vw, 3rem);
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: clamp(0.975rem, 0.9rem + 0.35vw, 1.0625rem);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.heroButton,
|
||||
.heroOutlineButton {
|
||||
height: 3rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.scrollIndicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) {
|
||||
.heroInner {
|
||||
padding-top: 4.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.heroBadge {
|
||||
padding: 0.3rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(1.75rem, 1.3rem + 1.6vw, 2.6rem);
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.heroButton,
|
||||
.heroOutlineButton {
|
||||
height: 2.875rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.heroCopy {
|
||||
max-width: 48rem;
|
||||
}
|
||||
}
|
||||
769
apps/website/components/pilot/PilotLandingPage.tsx
Normal file
769
apps/website/components/pilot/PilotLandingPage.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
'use client'
|
||||
|
||||
import Image from '@/framework/image'
|
||||
import Link from '@/framework/link'
|
||||
import { useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowDown,
|
||||
IconArrowsLeftRight,
|
||||
IconBolt,
|
||||
IconChevronDown,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconCurrencyEuro,
|
||||
IconHeart,
|
||||
IconMenu2,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconTrendingDown,
|
||||
IconUserCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import styles from './PilotLandingPage.module.css'
|
||||
|
||||
const facilityTypes = [
|
||||
{ value: 'krankenhaus', label: 'Krankenhaus' },
|
||||
{ value: 'altenpflege', label: 'Altenpflegeeinrichtung' },
|
||||
{ value: 'ambulant', label: 'Ambulanter Pflegedienst' },
|
||||
{ value: 'reha', label: 'Reha-Klinik' },
|
||||
{ value: 'sonstige', label: 'Sonstige' },
|
||||
]
|
||||
|
||||
const nursesNeededRanges = [
|
||||
{ value: '1-5', label: '1–5' },
|
||||
{ value: '6-15', label: '6–15' },
|
||||
{ value: '16-30', label: '16–30' },
|
||||
{ value: '30+', label: 'Mehr als 30' },
|
||||
]
|
||||
|
||||
const problems = [
|
||||
{
|
||||
icon: IconClock,
|
||||
title: 'Langwierige Prozesse',
|
||||
description:
|
||||
'Bürokratie, Anerkennungsverfahren und Visaprozesse kosten wertvolle Zeit. Für zusätzliche Recruiting-Aufgaben fehlen im laufenden Betrieb oft die Ressourcen.',
|
||||
},
|
||||
{
|
||||
icon: IconCurrencyEuro,
|
||||
title: 'Hohe Agenturkosten',
|
||||
description:
|
||||
'Vermittlungsagenturen verlangen hohe Gebühren ohne nachhaltige Lösungen. Unklare Kommunikation kann zu falschen Erwartungen und mehr Fluktuation führen.',
|
||||
},
|
||||
{
|
||||
icon: IconAlertTriangle,
|
||||
title: 'Agenturabhängigkeit',
|
||||
description:
|
||||
'Ohne eigene Prozesse bleiben Sie dauerhaft auf externe Vermittler angewiesen. Sie geben Kontrolle über Ihr Recruiting aus der Hand.',
|
||||
},
|
||||
]
|
||||
|
||||
const offers = [
|
||||
{
|
||||
icon: IconUserCheck,
|
||||
step: '01',
|
||||
title: 'Vorqualifizierte Pflegefachkräfte',
|
||||
description:
|
||||
'Wir verschaffen Ihnen direkten Zugang zu vorqualifizierten internationalen Pflegefachkräften. Unser Fokus liegt auf Kandidatinnen und Kandidaten in Deutschland, ergänzt durch qualifizierte Fachkräfte aus Drittstaaten.',
|
||||
highlights: [
|
||||
'Geprüfte Abschlüsse und Lizenzen',
|
||||
'Deutschkenntnisse mindestens B1',
|
||||
'ggfs. Defizitbescheid',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: IconArrowsLeftRight,
|
||||
step: '02',
|
||||
title: 'Full-Service Recruiting',
|
||||
description:
|
||||
'Vom ersten Kontakt bis zur erfolgreichen Integration: Wir begleiten den gesamten Prozess – von der Anerkennung bis zum Onboarding. Auf Wunsch unterstützen wir Sie auch gezielt in einzelnen Prozessschritten.',
|
||||
highlights: [
|
||||
'Unterstützung im Anerkennungsverfahren',
|
||||
'Begleitung bei behördlichen und administrativen Prozessen',
|
||||
'Onboarding und Integration in Ihrer Einrichtung',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: IconSettings,
|
||||
step: '03',
|
||||
title: 'Digitale Prozesse aufbauen',
|
||||
description:
|
||||
'Das Besondere: Während wir Sie operativ unterstützen, entwickeln wir gemeinsam mit Ihnen die Strukturen, mit denen Sie künftig selbst internationale Pflegekräfte rekrutieren und integrieren.',
|
||||
highlights: [
|
||||
'Klare, praxistaugliche Abläufe für Ihr Team',
|
||||
'Entlastung durch vereinfachte und digital unterstützte Prozesse',
|
||||
'Weniger Abhängigkeit von externen Vermittlern',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: IconTrendingDown,
|
||||
title: 'Kosten senken',
|
||||
description:
|
||||
'Deutlich günstiger als klassische Vermittlungsagenturen – und langfristig noch wirtschaftlicher durch geringere Fluktuation.',
|
||||
},
|
||||
{
|
||||
icon: IconBolt,
|
||||
title: 'Schneller besetzen',
|
||||
description:
|
||||
'Vermeiden Sie Verzögerungen bei den Behörden durch vollständige und fehlerfreie Anträge.',
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
title: 'Unabhängig werden',
|
||||
description:
|
||||
'Durch eigene Prozesse reduzieren Sie dauerhaft die Abhängigkeit von externen Dienstleistern.',
|
||||
},
|
||||
{
|
||||
icon: IconHeart,
|
||||
title: 'Nachhaltig integrieren',
|
||||
description:
|
||||
'Mehr Kontrolle über Auswahl, Kommunikation und Onboarding sorgt für realistische Erwartungen und stärkt die langfristige Bindung Ihrer Mitarbeitenden.',
|
||||
},
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Für wen ist HeyGermany gedacht?',
|
||||
answers: [
|
||||
'HeyGermany richtet sich an Pflegeeinrichtungen in Deutschland – stationäre Pflegeeinrichtungen, ambulante Pflegedienste, Langzeitpflege, Reha-Kliniken – die internationale Pflegefachkräfte gewinnen und langfristig unabhängig rekrutieren und nachhaltig integrieren möchten.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Was unterscheidet HeyGermany von klassischen Vermittlungsagenturen?',
|
||||
answers: [
|
||||
'Wir vermitteln nicht nur Pflegefachkräfte, sondern bauen gemeinsam mit Ihnen klare, praxistaugliche Recruiting-Strukturen auf, mit denen Sie künftig selbst international rekrutieren können. Sie werden Schritt für Schritt durch alle notwendigen Abläufe geführt und durch die Digitalisierung manueller Prozesse sparen Sie Zeit und personelle Ressourcen.',
|
||||
'So werden Sie unabhängiger von externen Agenturen und reduzieren dauerhaft Ihre Kosten.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Welche Qualifikationen bringen die Pflegefachkräfte mit?',
|
||||
answers: [
|
||||
'Alle Pflegefachkräfte sind fachlich vorqualifiziert, verfügen über Deutschkenntnisse und werden gezielt auf den Einsatz in Deutschland vorbereitet.',
|
||||
'In unserem Talentpool befinden sich sowohl Fachkräfte mit bereits vorliegendem Defizitbescheid als auch Kandidat:innen ohne gestellten Anerkennungsantrag, bei denen nach sorgfältiger Prüfung eine hohe Wahrscheinlichkeit für die Anerkennung als Pflegefachmann/Pflegefachfrau besteht.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Was bedeutet es, Pilot-Partner zu werden?',
|
||||
answers: [
|
||||
'Als Pilot-Partner gestalten Sie unser Angebot aktiv mit und profitieren von exklusivem Zugang zu Pflegekräften, vergünstigten Konditionen und direktem Austausch mit unserem Gründerteam.',
|
||||
'Für eine erfolgreiche Zusammenarbeit ist ein regelmäßiger, unkomplizierter Austausch wichtig. So können wir Ihre bestehenden Abläufe verstehen und gezielt Prozesse entwickeln, die Sie im Alltag spürbar entlasten. Dabei halten wir den Abstimmungsaufwand für Sie so gering wie möglich.'
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Welche Kosten kommen auf mich zu?',
|
||||
answers: [
|
||||
'Wohlfahrtsorganisationen, gemeinnützigen Trägern und Einrichtungen in Brandenburg bieten wir die Teilnahme an unserem Pilotprogramm kostenfrei an.',
|
||||
'Für alle weiteren Pilotpartner stellen wir unseren Service zum Selbstkostenpreis bereit. Dieser liegt aktuell bei einem festen, reduzierten Preis von ca. 3.500 € pro erfolgreicher Vermittlung. Zusätzlich fallen ausschließlich tatsächlich entstehende externe Kosten an, etwa für Visa, Behörden oder Übersetzungen, transparent und ohne versteckte Gebühren.',
|
||||
'In der Pilotphase liegt unser Fokus darauf, die größten Herausforderungen im Recruiting internationaler Pflegefachkräfte zu verstehen und gezielt digitale Lösungen zu entwickeln. Es geht uns derzeit nicht um Gewinnmaximierung, sondern darum, Prozesse nachhaltig zu verbessern.'
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Warum gibt es HeyGermany?',
|
||||
answers: [
|
||||
'HeyGermany versteht sich als soziales Impact-Projekt. Wir wollen eine Brücke zwischen internationalen Bewerber:innen und deutschen Pflegeeinrichtungen bauen, um zwei der größten Herausforderungen unserer Zeit gemeinsam zu bewältigen: den Fachkräftemangel in der Pflege und die erfolgreiche Arbeitsmarktintegration internationaler Fachkräfte.',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const footerLogos = [
|
||||
{
|
||||
src: '/footer-logos/hwr-logo-alt.svg',
|
||||
alt: 'HWR Berlin logo mark',
|
||||
width: 40,
|
||||
height: 66,
|
||||
},
|
||||
{
|
||||
src: '/footer-logos/hwr-type.svg',
|
||||
alt: 'HWR Berlin wordmark',
|
||||
width: 247,
|
||||
height: 65,
|
||||
},
|
||||
{
|
||||
src: '/footer-logos/sib-logo.png',
|
||||
alt: 'Startup Incubator Berlin logo',
|
||||
width: 1215,
|
||||
height: 314,
|
||||
},
|
||||
{
|
||||
src: '/footer-logos/berlin-logo.webp',
|
||||
alt: 'Berlin logo',
|
||||
width: 866,
|
||||
height: 650,
|
||||
},
|
||||
{
|
||||
src: '/footer-logos/eu-funded-by-eu.png',
|
||||
alt: 'Kofinanziert von der Europäischen Union',
|
||||
width: 3955,
|
||||
height: 788,
|
||||
},
|
||||
] as const
|
||||
|
||||
const [hwrMarkLogo, hwrWordmarkLogo, sibLogo, berlinLogo, euLogo] = footerLogos
|
||||
|
||||
type LeadFormState = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
facilityName: string
|
||||
facilityType: string
|
||||
nursesNeededRange: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const initialFormState: LeadFormState = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
facilityName: '',
|
||||
facilityType: '',
|
||||
nursesNeededRange: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
const getApiBaseUrl = () => {
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
) {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
return 'https://api.hey-germany.com'
|
||||
}
|
||||
|
||||
export const PilotLandingPage = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [formState, setFormState] = useState<LeadFormState>(initialFormState)
|
||||
|
||||
const scrollTo = (id: string) => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = event.currentTarget
|
||||
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formState),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Lead submission failed')
|
||||
}
|
||||
|
||||
setSubmitted(true)
|
||||
setFormState(initialFormState)
|
||||
} catch (error) {
|
||||
console.error('Lead submission failed', error)
|
||||
setErrorMessage('Die Anfrage konnte gerade nicht übermittelt werden. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<nav className={styles.navbar}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.navbarInner}>
|
||||
<span className={styles.brand}>HeyGermany</span>
|
||||
|
||||
<div className={styles.navLinks}>
|
||||
<button type="button" onClick={() => scrollTo('angebot')}>
|
||||
Angebot
|
||||
</button>
|
||||
<button type="button" onClick={() => scrollTo('register')}>
|
||||
Registrieren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
onClick={() => scrollTo('register')}
|
||||
>
|
||||
Pilot-Partner werden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.headerButton} ${styles.mobileHeaderButton}`}
|
||||
onClick={() => scrollTo('register')}
|
||||
>
|
||||
Pilot-Partner werden
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mobileToggle}
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label="Menü umschalten"
|
||||
>
|
||||
{open ? <IconX size={22} /> : <IconMenu2 size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className={styles.mobileMenu}>
|
||||
<button type="button" onClick={() => scrollTo('angebot')}>
|
||||
Angebot
|
||||
</button>
|
||||
<button type="button" onClick={() => scrollTo('register')}>
|
||||
Registrieren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
onClick={() => scrollTo('register')}
|
||||
>
|
||||
Pilot-Partner werden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroMedia}>
|
||||
<Image
|
||||
src="/images/pilot-hero-nurses.jpg"
|
||||
alt="Internationale Pflegekräfte in einem deutschen Krankenhaus"
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
<div className={styles.heroOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.container} ${styles.heroInner}`}>
|
||||
<div className={styles.heroCopy}>
|
||||
<span className={styles.heroBadge}>🚀 Jetzt als Pilot-Partner bewerben</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Internationale Pflegekräfte
|
||||
<br />
|
||||
<span>ohne Agenturen</span> gewinnen.
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
Erhalten Sie direkten Zugang zu vorqualifizierten internationalen Pflegefachkräften und
|
||||
bauen Sie gleichzeitig eigene, nachhaltige Strukturen für Rekrutierung und Integration auf.
|
||||
</p>
|
||||
<p className={styles.heroDescription}>
|
||||
Unsere Lösung vereinfacht und automatisiert die Prozesse der Erwerbsmigration genau dort,
|
||||
wo sie für Sie den größten Mehrwert schafft. So besetzen Sie Stellen schneller, reduzieren
|
||||
Kosten und machen sich unabhängig von Agenturen. Dabei entwickeln wir eine passgenaue
|
||||
Lösung, individuell abgestimmt auf die Bedarfe Ihrer Einrichtung.
|
||||
</p>
|
||||
|
||||
<div className={styles.heroActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroButton}
|
||||
onClick={() => scrollTo('register')}
|
||||
>
|
||||
Pilot-Partner werden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroOutlineButton}
|
||||
onClick={() => scrollTo('angebot')}
|
||||
>
|
||||
Mehr erfahren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.scrollIndicator} aria-hidden="true">
|
||||
<IconArrowDown size={24} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.sectionHeader} ${styles.sectionHeaderCentered}`}>
|
||||
<span className={styles.sectionEyebrow}>Das Problem</span>
|
||||
<h2>Pflege in der Krise – und kein Ende in Sicht</h2>
|
||||
<p>
|
||||
Deutsche Pflegeeinrichtungen stehen vor enormen Herausforderungen bei der Gewinnung
|
||||
qualifizierter Fachkräfte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.problemGrid}>
|
||||
{problems.map(({ icon: Icon, title, description }) => (
|
||||
<article key={title} className={styles.problemCard}>
|
||||
<div className={styles.problemIcon}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="angebot" className={`${styles.section} ${styles.sectionMuted}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.sectionHeader} ${styles.sectionHeaderCentered}`}>
|
||||
<span className={styles.sectionEyebrow}>Unser Angebot</span>
|
||||
<h2>Mehr als nur Vermittlung</h2>
|
||||
<p>
|
||||
HeyGermany verbindet Fachkräftegewinnung mit dem Aufbau Ihrer eigenen Recruiting-Kompetenz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.offerGrid}>
|
||||
{offers.map(({ icon: Icon, step, title, description, highlights }) => (
|
||||
<article key={step} className={styles.offerCard}>
|
||||
<span className={styles.offerStep}>{step}</span>
|
||||
<div className={styles.offerIcon}>
|
||||
<Icon size={28} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<ul className={styles.offerHighlights}>
|
||||
{highlights.map((highlight) => (
|
||||
<li key={highlight}>
|
||||
<IconCircleCheck size={16} />
|
||||
<span>{highlight}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.sectionHeader} ${styles.sectionHeaderCentered}`}>
|
||||
<span className={styles.sectionEyebrow}>Ihre Vorteile</span>
|
||||
<h2>Warum Pflegeeinrichtungen HeyGermany wählen</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.benefitGrid}>
|
||||
{benefits.map(({ icon: Icon, title, description }) => (
|
||||
<article key={title} className={styles.benefitItem}>
|
||||
<div className={styles.benefitIcon}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.section} ${styles.sectionFaq}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.sectionHeader} ${styles.sectionHeaderCentered}`}>
|
||||
<span className={styles.sectionEyebrow}>FAQ</span>
|
||||
<h2>Häufig gestellte Fragen</h2>
|
||||
<p>Hier finden Sie Antworten auf die wichtigsten Fragen rund um HeyGermany.</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.faqList}>
|
||||
{faqs.map((faq, index) => (
|
||||
<details key={faq.question} className={styles.faqItem} name="pilot-faq">
|
||||
<summary>
|
||||
<span>{faq.question}</span>
|
||||
<IconChevronDown size={18} />
|
||||
</summary>
|
||||
<div className={styles.faqAnswer}>
|
||||
{faq.answers.map((answer) => (
|
||||
<p key={`${index}-${answer}`}>{answer}</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="register" className={`${styles.section} ${styles.registerSection}`}>
|
||||
<div className={styles.container}>
|
||||
{submitted ? (
|
||||
<div className={styles.successWrap}>
|
||||
<div className={styles.successIcon}>
|
||||
<IconCircleCheck size={40} />
|
||||
</div>
|
||||
<h2>Bewerbung eingegangen!</h2>
|
||||
<p>
|
||||
Vielen Dank für Ihr Interesse. Unser Team wird sich innerhalb von 48 Stunden bei Ihnen
|
||||
melden, um die nächsten Schritte zu besprechen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.registerGrid}>
|
||||
<div className={styles.registerCopy}>
|
||||
<span className={styles.registerEyebrow}>Limitierte Plätze</span>
|
||||
<h2>Jetzt als Pilot-Partner bewerben</h2>
|
||||
<p>
|
||||
Wir suchen ausgewählte Pflegeeinrichtungen in Deutschland, die als Pilot-Partner unser
|
||||
Projekt mitgestalten möchten. Als Pilot-Partner profitieren Sie von:
|
||||
</p>
|
||||
<ul className={styles.registerBenefits}>
|
||||
{[
|
||||
'Exklusiver Zugang zu vorqualifizierten Pflegekräften',
|
||||
'Langfristig vergünstigte Konditionen',
|
||||
'Direkter Draht zu unserem Gründerteam',
|
||||
'Mitgestaltung der Lösung nach Ihren Bedürfnissen',
|
||||
].map((benefit) => (
|
||||
<li key={benefit}>
|
||||
<IconCircleCheck size={20} />
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.formWrap}>
|
||||
<form onSubmit={handleSubmit} className={styles.formCard}>
|
||||
<h3>Kontaktdaten</h3>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<label>
|
||||
<span>Vorname *</span>
|
||||
<input
|
||||
required
|
||||
name="firstName"
|
||||
value={formState.firstName}
|
||||
onChange={handleChange}
|
||||
placeholder="Max"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Nachname *</span>
|
||||
<input
|
||||
required
|
||||
name="lastName"
|
||||
value={formState.lastName}
|
||||
onChange={handleChange}
|
||||
placeholder="Mustermann"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<label>
|
||||
<span>E-Mail *</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange}
|
||||
placeholder="max@pflegeeinrichtung.de"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Telefon</span>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formState.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+49 123 456789"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<label>
|
||||
<span>Name der Einrichtung *</span>
|
||||
<input
|
||||
required
|
||||
name="facilityName"
|
||||
value={formState.facilityName}
|
||||
onChange={handleChange}
|
||||
placeholder="Pflegezentrum Musterstadt"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Art der Einrichtung *</span>
|
||||
<select
|
||||
required
|
||||
name="facilityType"
|
||||
value={formState.facilityType}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{facilityTypes.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Wie viele Pflegekräfte suchen Sie aktuell?</span>
|
||||
<select
|
||||
name="nursesNeededRange"
|
||||
value={formState.nursesNeededRange}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{nursesNeededRanges.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Nachricht (optional)</span>
|
||||
<textarea
|
||||
name="message"
|
||||
rows={3}
|
||||
value={formState.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Erzählen Sie uns kurz von Ihren Herausforderungen..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? <p className={styles.errorMessage}>{errorMessage}</p> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Wird gesendet ...' : 'Jetzt als Pilot-Partner bewerben'}
|
||||
</button>
|
||||
|
||||
<p className={styles.formHint}>
|
||||
Ihre Daten werden vertraulich behandelt. Wir melden uns innerhalb von 48 Stunden.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.footerCenter}>
|
||||
<p className={styles.footerMetaText}>
|
||||
HeyGermany | Internationale Pflegekräfte für Deutschland
|
||||
</p>
|
||||
|
||||
<div className={styles.footerLogoList} aria-label="Partner und Förderlogos">
|
||||
<div className={styles.footerLogoPair}>
|
||||
<div className={styles.footerLogoItem}>
|
||||
<Image
|
||||
src={hwrMarkLogo.src}
|
||||
alt={hwrMarkLogo.alt}
|
||||
width={hwrMarkLogo.width}
|
||||
height={hwrMarkLogo.height}
|
||||
sizes="(max-width: 640px) 42vw, (max-width: 1024px) 28vw, 220px"
|
||||
className={styles.footerLogoImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerLogoItem}>
|
||||
<Image
|
||||
src={hwrWordmarkLogo.src}
|
||||
alt={hwrWordmarkLogo.alt}
|
||||
width={hwrWordmarkLogo.width}
|
||||
height={hwrWordmarkLogo.height}
|
||||
sizes="(max-width: 640px) 42vw, (max-width: 1024px) 28vw, 220px"
|
||||
className={styles.footerLogoImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerLogoItem}>
|
||||
<Image
|
||||
src={sibLogo.src}
|
||||
alt={sibLogo.alt}
|
||||
width={sibLogo.width}
|
||||
height={sibLogo.height}
|
||||
sizes="(max-width: 640px) 42vw, (max-width: 1024px) 28vw, 220px"
|
||||
className={styles.footerLogoImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerLogoItem}>
|
||||
<Image
|
||||
src={berlinLogo.src}
|
||||
alt={berlinLogo.alt}
|
||||
width={berlinLogo.width}
|
||||
height={berlinLogo.height}
|
||||
sizes="(max-width: 640px) 42vw, (max-width: 1024px) 28vw, 220px"
|
||||
className={styles.footerLogoImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerLogoItem}>
|
||||
<Image
|
||||
src={euLogo.src}
|
||||
alt={euLogo.alt}
|
||||
width={euLogo.width}
|
||||
height={euLogo.height}
|
||||
sizes="(max-width: 640px) 42vw, (max-width: 1024px) 28vw, 220px"
|
||||
className={styles.footerLogoImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.footerLinks} aria-label="Rechtliches und Kontakt">
|
||||
<Link href="/de/legal/imprint">Impressum</Link>
|
||||
<Link href="/de/legal/terms-and-conditions">Nutzungsbedingungen</Link>
|
||||
<Link href="/de/legal/privacy">Datenschutzhinweis</Link>
|
||||
<a href="mailto:info@hey-germany.com">Kontakt</a>
|
||||
</nav>
|
||||
|
||||
<p className={styles.footerCopyright}>
|
||||
© {new Date().getFullYear()} HeyGermany. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
66
apps/website/components/ui/CSSArrowSeparator.tsx
Normal file
66
apps/website/components/ui/CSSArrowSeparator.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Box } from '@pikku/mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
interface CSSArrowSeparatorProps {
|
||||
/**
|
||||
* Color of the arrow (default: dimmed)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Size of the arrow in pixels (default: 20)
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* Margin around the component (default: 'lg')
|
||||
*/
|
||||
margin?: string | number;
|
||||
}
|
||||
|
||||
export const CSSArrowSeparator: React.FC<CSSArrowSeparatorProps> = ({
|
||||
color = 'var(--mantine-color-dimmed)',
|
||||
size = 20,
|
||||
margin = 'lg'
|
||||
}) => {
|
||||
return (
|
||||
<Box style={{ margin }}>
|
||||
{/* Top horizontal line */}
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
backgroundColor: color,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Arrow pointing down */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${size}px solid transparent`,
|
||||
borderRight: `${size}px solid transparent`,
|
||||
borderTop: `${size}px solid ${color}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Bottom horizontal line */}
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
backgroundColor: color,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
68
apps/website/components/ui/Emphasize.tsx
Normal file
68
apps/website/components/ui/Emphasize.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Text } from "@pikku/mantine/core"
|
||||
import { asI18n } from "@pikku/react"
|
||||
import React from "react"
|
||||
|
||||
interface EmphasizeProps {
|
||||
text: string;
|
||||
primary?: boolean
|
||||
renderInlineStyles?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
function stripInlineTags(text: string) {
|
||||
return text
|
||||
.replace(/<\/?(strong|i|em)>/g, '')
|
||||
.replace(/<br\s*\/?>/g, '\n')
|
||||
.replace(/<\/br>/g, '')
|
||||
}
|
||||
|
||||
export function Emphasize({
|
||||
text,
|
||||
primary = true,
|
||||
renderInlineStyles = true,
|
||||
...textProps
|
||||
}: EmphasizeProps) {
|
||||
if (!renderInlineStyles) {
|
||||
return <Text {...textProps}>{asI18n(stripInlineTags(text))}</Text>
|
||||
}
|
||||
|
||||
// Split text by <strong> and <i> tags while preserving the tags
|
||||
const parts = text.split(/(<strong>.*?<\/strong>|<i>.*?<\/i>|<em>.*?<\/em>|<br>.*?<\/br>)/g);
|
||||
|
||||
return (
|
||||
<Text {...textProps}>
|
||||
{parts.map((part, i) => {
|
||||
if ((part.startsWith('<strong>') && part.endsWith('</strong>')) || (part.startsWith('<em>') && part.endsWith('</em>'))){
|
||||
// Extract content between <strong> tags
|
||||
const content = part.replace(/<\/?strong>/g, '').replace(/<\/?em>/g, '');
|
||||
return (
|
||||
<Text
|
||||
key={i}
|
||||
component="strong"
|
||||
fw={700}
|
||||
span
|
||||
c={primary ? 'primary' : undefined}
|
||||
>
|
||||
{asI18n(content)}
|
||||
</Text>
|
||||
);
|
||||
} else if (part.startsWith('<i>') && part.endsWith('</i>')) {
|
||||
// Extract content between <i> tags
|
||||
const content = part.replace(/<\/?i>/g, '');
|
||||
return (
|
||||
<Text key={i} fs="italic" span>
|
||||
{asI18n(content)}
|
||||
</Text>
|
||||
);
|
||||
} else if (part.startsWith('<br>') && part.endsWith('</br>')) {
|
||||
return (
|
||||
<br key={i}/>
|
||||
);
|
||||
} else {
|
||||
// Regular text
|
||||
return <React.Fragment key={i}>{asI18n(part)}</React.Fragment>;
|
||||
}
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
172
apps/website/components/ui/FileUpload.tsx
Normal file
172
apps/website/components/ui/FileUpload.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Dropzone, MIME_TYPES, FileRejection } from '@mantine/dropzone';
|
||||
import { Button, Box, Text, Stack, Group, ActionIcon, Paper, Alert } from '@pikku/mantine/core';
|
||||
import { IconX, IconPlus } from '@tabler/icons-react';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import { asI18n } from '@pikku/react';
|
||||
|
||||
type UploadedFile = {
|
||||
id: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export type UploadFile = (files: File[]) => Promise<void>
|
||||
export type DeleteFile = (id: string) => Promise<void>
|
||||
|
||||
interface FileUploadProps {
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
maxFileSize?: number;
|
||||
chooseLabel?: string;
|
||||
className?: string;
|
||||
files: UploadedFile[]
|
||||
uploadFiles: UploadFile
|
||||
deleteFile: DeleteFile
|
||||
}
|
||||
|
||||
export const FileUpload: React.FunctionComponent<FileUploadProps> = ({
|
||||
multiple = true,
|
||||
accept = ".pdf,.jpg,.jpeg,.png",
|
||||
maxFileSize = 210000000,
|
||||
chooseLabel,
|
||||
files = [],
|
||||
uploadFiles,
|
||||
deleteFile
|
||||
}) => {
|
||||
const t = useI18n()
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [rejectionError, setRejectionError] = useState<string | null>(null);
|
||||
|
||||
const defaultChooseLabel = chooseLabel ? asI18n(chooseLabel) : t('jobs.fileupload.choosefile')
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
setRejectionError(null); // Clear any previous errors
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadFiles(files)
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error)
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
const handleReject = (rejectedFiles: FileRejection[]) => {
|
||||
if (rejectedFiles.length > 0) {
|
||||
const firstError = rejectedFiles[0].errors[0];
|
||||
let errorMessage: string;
|
||||
|
||||
switch (firstError.code) {
|
||||
case 'file-invalid-type':
|
||||
errorMessage = t('jobs.fileupload.error.file-invalid-type');
|
||||
break;
|
||||
case 'file-too-large':
|
||||
errorMessage = t('jobs.fileupload.error.file-too-large');
|
||||
break;
|
||||
case 'file-too-small':
|
||||
errorMessage = t('jobs.fileupload.error.file-too-small');
|
||||
break;
|
||||
case 'too-many-files':
|
||||
errorMessage = t('jobs.fileupload.error.too-many-files');
|
||||
break;
|
||||
default:
|
||||
errorMessage = firstError.message;
|
||||
}
|
||||
|
||||
setRejectionError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (file: UploadedFile) => {
|
||||
try {
|
||||
await deleteFile(file.id)
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const acceptTypes = accept.split(',').map(type => {
|
||||
switch (type.trim()) {
|
||||
case '.pdf': return MIME_TYPES.pdf;
|
||||
case '.jpg':
|
||||
case '.jpeg': return MIME_TYPES.jpeg;
|
||||
case '.png': return MIME_TYPES.png;
|
||||
default: return type.trim();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper p="xl" withBorder>
|
||||
<Box pos="relative">
|
||||
<Dropzone
|
||||
multiple={multiple}
|
||||
accept={acceptTypes}
|
||||
maxSize={maxFileSize}
|
||||
onDrop={handleUpload}
|
||||
onReject={handleReject}
|
||||
loading={uploading}
|
||||
bd='none'
|
||||
bg='transparent'
|
||||
p={0}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
size="md"
|
||||
loading={uploading}
|
||||
leftSection={<IconPlus size={16} />}
|
||||
>
|
||||
{defaultChooseLabel}
|
||||
</Button>
|
||||
{!files || files.length === 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="md" fw={500}>{t('jobs.fileupload.dragdroptext')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('jobs.fileupload.acceptedformats')}
|
||||
<br />
|
||||
{t('jobs.fileupload.maxsize')}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Dropzone>
|
||||
</Box>
|
||||
|
||||
{files && files.length > 0 && (
|
||||
<Stack gap="sm" mt="md">
|
||||
{files.map((file) => (
|
||||
<Group key={file.id} justify="space-between" align="center">
|
||||
<Text
|
||||
fw={500}
|
||||
c="dark"
|
||||
maw={250}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{asI18n(file.fileName)}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(file)}
|
||||
aria-label={t('jobs.fileupload.delete')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{rejectionError && (
|
||||
<Alert color="red" mt="md" onClose={() => setRejectionError(null)} withCloseButton>
|
||||
{asI18n(rejectionError)}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/ui/FooterBar.tsx
Normal file
53
apps/website/components/ui/FooterBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import Image from "@/framework/image"
|
||||
import Link from '@/framework/link'
|
||||
import { Stack, Container, Group, Anchor, Box } from '@pikku/mantine/core'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { useParams } from '@/framework/navigation'
|
||||
|
||||
export default function FooterBar() {
|
||||
const t = useI18n()
|
||||
const params = useParams()
|
||||
const lang = params.lang as string || 'en'
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
bg="white"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
|
||||
>
|
||||
<Container size="lg" w="100%">
|
||||
<Stack gap="lg" py="xl">
|
||||
<Box
|
||||
pos="relative"
|
||||
h={75}
|
||||
w="100%"
|
||||
style={{ aspectRatio: '4/2' }}
|
||||
>
|
||||
<Image
|
||||
src="/brands.png"
|
||||
alt="Partner Logos"
|
||||
fill
|
||||
style={{ objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="center" gap="lg" pt="sm">
|
||||
<Anchor component={Link} href={`/${lang}/legal/imprint`} size="sm">
|
||||
{t('layout.footer.imprint')}
|
||||
</Anchor>
|
||||
<Anchor component={Link} href={`/${lang}/legal/terms-and-conditions`} size="sm">
|
||||
{t('layout.footer.termsofuse')}
|
||||
</Anchor>
|
||||
<Anchor component={Link} href={`/${lang}/legal/privacy`} size="sm">
|
||||
{t('layout.footer.privacynotice')}
|
||||
</Anchor>
|
||||
<Anchor href="mailto:info@hey-germany.com" size="sm">
|
||||
{t('layout.footer.contact')}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
60
apps/website/components/ui/LanguageSelector.tsx
Normal file
60
apps/website/components/ui/LanguageSelector.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import Image from '@/framework/image'
|
||||
import { Select, Group, Text } from '@pikku/mantine/core'
|
||||
import { useParams, usePathname, useRouter } from '@/framework/navigation'
|
||||
import { LANGUAGES } from '@/config'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
|
||||
export default function LanguageSelector() {
|
||||
const t = useI18n()
|
||||
const params = useParams<{ lang?: string }>()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
// Extract lang from params or pathname (for routes like /en/legal/imprint)
|
||||
const lang = params.lang || pathname.split('/')[1]
|
||||
|
||||
const selectedLanguage = LANGUAGES.find(language => language.value === lang)!
|
||||
|
||||
const switchLanguage = (to: string | null) => {
|
||||
if (to && to !== lang) {
|
||||
// Store language preference in cookie
|
||||
document.cookie = `NEXT_LOCALE=${to}; path=/; max-age=31536000; SameSite=Lax`
|
||||
|
||||
// Create new path with updated language
|
||||
const newPath = pathname.replace(`/${lang}`, `/${to}`)
|
||||
|
||||
// Navigate to new language
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
size='sm'
|
||||
w={140}
|
||||
value={lang}
|
||||
data={LANGUAGES}
|
||||
onChange={switchLanguage}
|
||||
leftSection={<Image src={selectedLanguage.flag} alt={selectedLanguage.label} width={18} height={18} />}
|
||||
renderOption={({ option }) => {
|
||||
const language = LANGUAGES.find(lang => lang.value === option.value)
|
||||
if (language) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Image
|
||||
src={language.flag}
|
||||
alt={language.label}
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<Text visibleFrom='md'>{t(`language.${language.value}` as any)}</Text>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
47
apps/website/components/ui/Logo.tsx
Normal file
47
apps/website/components/ui/Logo.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import Image from '@/framework/image'
|
||||
import { Group, Text, Badge } from '@pikku/mantine/core'
|
||||
import Link from '@/framework/link'
|
||||
import { useParams, usePathname } from '@/framework/navigation'
|
||||
import { DEFAULT_LOCALE } from '@/config'
|
||||
import { useI18n } from '@/context/i18n-provider'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showBeta?: boolean
|
||||
type?: string
|
||||
}
|
||||
|
||||
export default function Logo({ size = 'md', showBeta = true, type }: LogoProps) {
|
||||
const t = useI18n()
|
||||
const dimensions = {
|
||||
sm: { width: 24, height: 24, textSize: 'lg' as const },
|
||||
md: { width: 32, height: 32, textSize: 'xl' as const },
|
||||
lg: { width: 40, height: 40, textSize: 'xl' as const }
|
||||
}
|
||||
|
||||
const { width, height, textSize } = dimensions[size]
|
||||
const pathname = usePathname()
|
||||
const { lang } = useParams<{ lang?: string }>()
|
||||
const homeHref = lang ? `/${lang}` : pathname.startsWith('/pilot') ? '/de' : `/${DEFAULT_LOCALE}`
|
||||
|
||||
return (
|
||||
<Group gap="xs" align="center">
|
||||
<Link href={homeHref}>
|
||||
<Image
|
||||
src="/heygermany-logo-small.png"
|
||||
alt="HeyGermany logo"
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</Link>
|
||||
<Text size={textSize} fw={500}>
|
||||
{t('logo.wordmark')}
|
||||
</Text>
|
||||
{type && <Badge variant="light" color="primary">{asI18n(type)}</Badge>}
|
||||
{!type && showBeta && <Text size="xs" c="gray.8">{t('layout.topbar.beta')}</Text>}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
0
apps/website/components/ui/NavBar.module.css
Normal file
0
apps/website/components/ui/NavBar.module.css
Normal file
66
apps/website/components/ui/NavBar.tsx
Normal file
66
apps/website/components/ui/NavBar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Icon2fa,
|
||||
IconBellRinging,
|
||||
IconDatabaseImport,
|
||||
IconFingerprint,
|
||||
IconKey,
|
||||
IconLogout,
|
||||
IconReceipt2,
|
||||
IconSettings,
|
||||
IconSwitchHorizontal,
|
||||
} from '@tabler/icons-react';
|
||||
import { Code, Group } from '@pikku/mantine/core';
|
||||
import classes from './NavbarSimple.module.css';
|
||||
|
||||
const data = [
|
||||
{ link: '', label: 'Notifications', icon: IconBellRinging },
|
||||
{ link: '', label: 'Billing', icon: IconReceipt2 },
|
||||
{ link: '', label: 'Security', icon: IconFingerprint },
|
||||
{ link: '', label: 'SSH Keys', icon: IconKey },
|
||||
{ link: '', label: 'Databases', icon: IconDatabaseImport },
|
||||
{ link: '', label: 'Authentication', icon: Icon2fa },
|
||||
{ link: '', label: 'Other Settings', icon: IconSettings },
|
||||
];
|
||||
|
||||
export function NavbarSimple() {
|
||||
const [active, setActive] = useState('Billing');
|
||||
|
||||
const links = data.map((item) => (
|
||||
<a
|
||||
className={classes.link}
|
||||
data-active={item.label === active || undefined}
|
||||
href={item.link}
|
||||
key={item.label}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setActive(item.label);
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={1.5} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
));
|
||||
|
||||
return (
|
||||
<nav className={classes.navbar}>
|
||||
<div className={classes.navbarMain}>
|
||||
<Group className={classes.header} justify="space-between">
|
||||
</Group>
|
||||
{links}
|
||||
</div>
|
||||
|
||||
<div className={classes.footer}>
|
||||
<a href="#" className={classes.link} onClick={(event) => event.preventDefault()}>
|
||||
<IconSwitchHorizontal className={classes.linkIcon} stroke={1.5} />
|
||||
<span>Change account</span>
|
||||
</a>
|
||||
|
||||
<a href="#" className={classes.link} onClick={(event) => event.preventDefault()}>
|
||||
<IconLogout className={classes.linkIcon} stroke={1.5} />
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
24
apps/website/components/ui/ThemeToggle.module.css
Normal file
24
apps/website/components/ui/ThemeToggle.module.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@mixin dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
@mixin light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
25
apps/website/components/ui/ThemeToggle.tsx
Normal file
25
apps/website/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import cx from 'clsx';
|
||||
import { ActionIcon, Group, useComputedColorScheme, useMantineColorScheme } from '@pikku/mantine/core';
|
||||
import { useI18n } from '@/context/i18n-provider';
|
||||
import classes from './ThemeToggle.module.css';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const t = useI18n();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
|
||||
return (
|
||||
<Group justify="center">
|
||||
<ActionIcon
|
||||
onClick={() => setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light')}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t('theme.toggle')}
|
||||
>
|
||||
<IconSun className={cx(classes.icon, classes.light)} stroke={1.5} />
|
||||
<IconMoon className={cx(classes.icon, classes.dark)} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
27
apps/website/components/ui/TopBar.tsx
Normal file
27
apps/website/components/ui/TopBar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { Box, Container, Flex } from '@pikku/mantine/core'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
import Logo from './Logo'
|
||||
|
||||
export default function TopBar() {
|
||||
return (
|
||||
<Box
|
||||
dir='ltr'
|
||||
bg="white"
|
||||
pos="sticky"
|
||||
top={0}
|
||||
style={{ zIndex: 50, boxShadow: 'var(--mantine-shadow-md)' }}
|
||||
>
|
||||
<Container size="xl" py="md">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Logo />
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<LanguageSelector />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user