chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 09:21:21 +02:00
commit 59b4a7a404
398 changed files with 38340 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
name: Detect Changes
description: Detect changed files and handle [deploy-all] commit override
outputs:
api:
description: Whether API changed
value: ${{ steps.override.outputs.api }}
dbmigrate:
description: Whether DBMigrate changed
value: ${{ steps.override.outputs.dbmigrate }}
website:
description: Whether WebSite changed
value: ${{ steps.override.outputs.website }}
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: generated-code
path: ${{ github.workspace }}
- id: filter
uses: dorny/paths-filter@v3
with:
filters: |
api:
- 'backends/express/**'
- 'sql/**'
- 'packages/**'
website:
- 'apps/website/**'
- 'packages/**'
- id: override
shell: bash
run: |
COMMIT_MESSAGE="${{ github.event.head_commit.message }}"
FORCE_PUSH="${{ github.event.forced }}"
echo "Commit message: $COMMIT_MESSAGE"
echo "Forced push: $FORCE_PUSH"
# Temporarily always return true for all services
echo "api=true" >> $GITHUB_OUTPUT
echo "website=true" >> $GITHUB_OUTPUT
# if [ "$FORCE_PUSH" = "true" ] || echo "$COMMIT_MESSAGE" | grep -q "\[deploy-all\]"; then
# echo "api=true" >> $GITHUB_OUTPUT
# echo "website=true" >> $GITHUB_OUTPUT
# else
# echo "api=${{ steps.filter.outputs.api }}" >> $GITHUB_OUTPUT
# echo "website=${{ steps.filter.outputs.website }}" >> $GITHUB_OUTPUT
# fi

View File

@@ -0,0 +1,67 @@
name: Build and Push to ECR
description: Fully prepares, builds, and pushes a Docker image to AWS ECR.
inputs:
dockerfile:
description: 'Path to Dockerfile'
required: true
image-name:
description: 'ECR repo name (e.g. api or dbmigrate)'
required: true
aws-region:
description: 'AWS region'
required: true
aws-access-key-id:
description: 'AWS Access Key ID'
required: true
aws-secret-access-key:
description: 'AWS Secret Access Key'
required: true
github-sha:
description: 'Commit SHA to tag the image with'
default: ${{ github.sha }}
outputs:
ecr_image:
description: 'Final ECR image URI'
value: ${{ steps.build.outputs.ecr_image }}
runs:
using: composite
steps:
- name: Setup AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ inputs.aws-access-key-id }}
aws-secret-access-key: ${{ inputs.aws-secret-access-key }}
aws-region: ${{ inputs.aws-region }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
- name: Download generated files
uses: actions/download-artifact@v4
with:
name: generated-code
path: ${{ github.workspace }}
- name: List generated files
shell: bash
run: |
echo "Listing .gen.ts and .gen.d.ts files:"
pwd
find . -name "*.gen.ts" -o -name "*.gen.d.ts"
- name: Build and Push Docker Image
shell: bash
id: build
run: |
IMAGE_URI=${{ steps.ecr-login.outputs.registry }}/${{ inputs.image-name }}:${{ inputs.github-sha }}
docker build -t $IMAGE_URI -f ${{ inputs.dockerfile }} .
docker tag $IMAGE_URI ${{ steps.ecr-login.outputs.registry }}/${{ inputs.image-name }}:latest
docker push $IMAGE_URI
docker push ${{ steps.ecr-login.outputs.registry }}/${{ inputs.image-name }}:latest
# Safe base64 encode and output
IMAGE_URI_BASE64=$(echo -n "$IMAGE_URI" | base64 | tr -d '\n')
echo "ecr_image=$IMAGE_URI_BASE64" >> $GITHUB_OUTPUT

View File

@@ -0,0 +1,45 @@
name: 'Setup Environment'
description: 'Setup environment for the project'
runs:
using: 'composite'
steps:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache bun install
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules/.cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- run: bun install --frozen-lockfile
shell: bash
- run: bun run prebuild
shell: bash
- name: List generated files
shell: bash
run: |
echo "Listing .gen.ts and .gen.d.ts files:"
find . -name "*.gen.ts" -o -name "*.gen.d.ts"
- name: Save generated types
uses: actions/upload-artifact@v4
with:
name: generated-code
path: |
${{ github.workspace }}/packages/functions/.pikku
${{ github.workspace }}/packages/sdk/.generated
${{ github.workspace }}/packages/sdk/.pikku
${{ github.workspace }}/apps/website/pikku/*
${{ github.workspace }}/apps/website/public/locales
${{ github.workspace }}/apps/website/public/locales/*
include-hidden-files: true

26
.github/workflows/develop.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Develop
run-name: The develop workflow
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
push:
branches:
- develop
workflow_dispatch:
jobs:
setup:
name: Setup and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup environment
uses: ./.github/actions/setup-environment
- name: Run Tests
run: bun run test

52
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Main
run-name: The main release workflow
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
push:
branches:
- main
workflow_dispatch:
jobs:
setup:
name: Setup and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup environment
uses: ./.github/actions/setup-environment
- name: Run Tests
run: bun run test
website-build:
name: Build Website
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout code
uses: actions/checkout@v4
# If your build generates code artifacts in prior jobs, keep this;
# otherwise you can remove it.
- name: Download generated files
uses: actions/download-artifact@v4
with:
name: generated-code
path: ${{ github.workspace }}
continue-on-error: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- name: Build NextJS Standalone App
run: bun run build:website

244
.gitignore vendored Normal file
View File

@@ -0,0 +1,244 @@
meta.json
openapi.json
openapi.yml
*.gen.ts
locales
# Email locale sources are real committed source (drives EMAIL_LOCALES codegen), not build output.
!emails/locales/
!emails/locales/**
out
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.generated
*.hcl
pg_*
generated
schemas
generated/*
.terraform
.serverless_nextjs
tsconfig.tsbuildinfo
assets/locales
.prod_db_creds
function.zip
.public
.build
.deploy
.terraform
.serverless
serverless/index.js
serverless/index.js.map
.next
.uploads
.tmp
mobile/android/app/src/main/assets/fonts/
.DS_Store
*.css.d.ts
Pods
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
.env.local
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
yarn-error.log
.DS_Store
package-lock.json
dist
node_modules
stats.json
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
# BUCK
buck-out/
\.buckd/
*.keystore
!debug.keystore
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
# Bundle artifact
*.jsbundle
# CocoaPods
/ios/Pods/
VoxImplantDemo.xcworkspace
VoximplantDemo.xcworkspace
android/app/google-services.json
.editorconfig
# Pikku
.pikku
# Tracked pikku client SDK (generated, but committed so it exists in fresh clones)
!packages/sdk/pikku/
!packages/sdk/pikku/pikku-fetch.gen.ts
meta.json
.e2e-runtime
e2e/tests/reports

BIN
.pikku-runtime/dev.db Normal file

Binary file not shown.

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

10
CLAUDE.md Normal file
View File

@@ -0,0 +1,10 @@
# Repo Guidance
- Do not bypass `@pikku/mantine` i18n typing by wrapping final text in plain HTML elements such as `span` or `div`.
- Do not use wrapper-based "neutralization" to silence `I18nNode` errors.
- `Text` may contain plain numeric children like `{1}` when the value is a number, but do not brand digit literals like `'1'` as translation text.
- Fix the root issue instead:
- use `t('...')` for real UI copy
- improve shared component typings/helpers when needed
- use `asI18n(...)` only in an explicit, deliberate pass
- If the correct fix is not clear, stop and ask before introducing a workaround.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Vlandor Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# Hey Germany
Hey Germany is a platform that helps immigrants find job opportunities in Germany.
## Requirements
- **Node.js 18**
## Setup
To get started, run:
```bash
yarn install
yarn prebuild
```
Local SQLite defaults are defined in [packages/functions/src/local-db.ts](/Users/yasser/git/heygermany/packages/functions/src/local-db.ts:1).
## Apps
### `apps/cli`
This app tests the Pikku fetch functionality.
### `apps/webapp`
A Next.js App Router integrated with Pikku.
## Runtime
The backend runs through `pikku dev` locally and Fabric in production.
## Packages
### `functions`
Contains all the Pikku routes, functions, and services.
### `sdk`
Contains shared types used by both the backend and frontend.
## Database
SQLite migrations live in `db/sqlite`, preserved Postgres migrations live in `db/postgres`, and local migration runs should use `pikku db migrate`.
---
Feel free to explore each package and customize the starter to fit your project's needs. Happy coding!

39
apps/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin

15
apps/website/README.md Normal file
View File

@@ -0,0 +1,15 @@
This app runs on TanStack Start with Vite.
## Commands
```bash
yarn dev
yarn build
yarn start
```
## Structure
- `src/routes`: TanStack Start file routes
- `views`: route components and page-level React views
- `components`: shared UI and feature components

View 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);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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,
}))}
/>
);
};

View 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;
}

View 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') }
]}
/>
);
};

View 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>
</>
);
}

View File

@@ -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'
]}
/>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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'
]}
/>
);
}

View File

@@ -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)
/>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
}

View 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}
/>
);
};

View 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>
)
}

View 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>
)
}

View 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 />
</>
)
}

View 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>
</>
)
}

View 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} />
</>
)
}

View 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. Well 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, youll 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}
/>
)
}

View 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>
)
}

View File

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

View File

@@ -0,0 +1,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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;ve reviewed your files, but unfortunately, one or more of them couldn&apos;t be processed for your personal evaluation.')}
</Text>
</Stack>
<Stack gap='xs'>
<Text size='lg' fw={600} c='gray.7' lh={1.3}>
{asI18n('This can happen if:')}
</Text>
<List spacing={2} size='lg' c='gray.7' withPadding style={{ lineHeight: 1.3 }}>
<List.Item>{asI18n('The document is incomplete or blurry')}</List.Item>
<List.Item>{asI18n('The content is unclear or unreadable')}</List.Item>
<List.Item>{asI18n('The file you uploaded is not related to the document we requested')}</List.Item>
</List>
</Stack>
<Text size='lg' c='gray.7' lh={1.35}>
{asI18n('Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation.')}
</Text>
<Button
onClick={() => resetApplicationMutation.mutate()}
loading={resetApplicationMutation.isPending}
disabled={resetApplicationMutation.isPending}
size="md"
mt='xs'
maw={280}
mx='auto'
variant='filled'
color='yellow.5'
c='black'
>
{asI18n('Upload documents again')}
</Button>
</Stack>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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

View 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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
</>
)
}

View 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;
}
}

View 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: '15' },
{ value: '6-15', label: '615' },
{ value: '16-30', label: '1630' },
{ 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>
)
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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>
)
}
}}
/>
)
}

View 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>
)
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
)
}

4
apps/website/config.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Re-export shared configuration from SDK
*/
export * from '@heygermany/sdk/config'

View File

@@ -0,0 +1,77 @@
'use client'
import React, { createContext, useContext, useMemo } from 'react'
import type { I18nString } from '@pikku/react'
import type { SupportedLocale } from '@/config'
import { mKey } from '@/i18n/messages'
import { setRouteLocale, supportedLocales, defaultLocale } from '@/i18n/config'
export type GetNamespaced = (
key: string,
variables?: Record<string, any>
) => I18nString
export interface I18nContextType {
locale: SupportedLocale
get: GetNamespaced
getEnumOptions: (
e: Record<string, string>,
enumName: string
) => { value: string; label: I18nString }[]
}
// Resolve any dotted i18n key through Paraglide (mKey snake_cases it and calls
// the compiled message). Replaces the old i18next `t`.
const get: GetNamespaced = (key, variables) => mKey(key, variables)
const getEnumOptions = (enumObject: Record<string, string>, enumName: string) =>
Object.values(enumObject).map((value) => ({
value,
label: mKey(`enums.${enumName}.${value}`.toLowerCase()),
}))
export const I18nContext = createContext<I18nContextType>({
locale: defaultLocale as SupportedLocale,
get,
getEnumOptions,
})
// useI18n() returns a callable `t` (Paraglide-backed) augmented with the same
// helpers components used under i18next: `t('key')`, `t.getEnumOptions(...)`,
// `t.locale`.
export const useI18n = () => {
const ctx = useContext(I18nContext)
const t = (key: string, variables?: Record<string, any>): I18nString =>
mKey(key, variables)
return Object.assign(t, {
get: ctx.get,
getEnumOptions: ctx.getEnumOptions,
locale: ctx.locale,
})
}
interface I18nProviderProps {
children: React.ReactNode
lang?: string
}
const normalize = (lang: string): SupportedLocale => {
const short = lang.slice(0, 2).toLowerCase()
return (
supportedLocales.includes(short as (typeof supportedLocales)[number])
? short
: defaultLocale
) as SupportedLocale
}
export function I18nProvider({ children, lang = 'en' }: I18nProviderProps) {
const locale = normalize(lang)
// Set the active Paraglide locale before children resolve messages (SSR-safe:
// synchronous, ahead of the children's render in this pass).
setRouteLocale(locale as (typeof supportedLocales)[number])
const value = useMemo<I18nContextType>(
() => ({ locale, get, getEnumOptions }),
[locale]
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}

View File

@@ -0,0 +1,3 @@
// Recognition-comparison option keys (ordering/structure). Text fields live in
// i18n messages: jobs.compareoptionsrecognition.options.<key>.{name,description,duration}.
export const compareRecognitionOptionKeys = ["adaptation_program","knowledge_exam"] as const

View File

@@ -0,0 +1,14 @@
// Free German course links — external resources (brand name + URL are data,
// identical across locales) so they live here, not in i18n messages.
export interface FreeCourseLink { name: string; url: string }
export const freeCourseLinks: FreeCourseLink[] = [
{
"name": "Deutsche Welle",
"url": "https://learngerman.dw.com/en"
},
{
"name": "VHS-Lernportal",
"url": "https://www.vhs-lernportal.de/wws/9.php#/wws/kursangebot-lernende.php"
}
]

View File

@@ -0,0 +1,61 @@
// Paid German course providers — pure data (brand names + level ranges are
// identical across locales, so they live here, not in i18n messages).
export interface PaidCourseProvider { provider: string; levels: string; format: string }
export const paidCourseProviders: PaidCourseProvider[] = [
{
"provider": "Lingoda",
"levels": "A1-C2",
"format": "Online"
},
{
"provider": "Kerntraining",
"levels": "A1-C2",
"format": "Online"
},
{
"provider": "WBS Training",
"levels": "A1-C1",
"format": "Hybrid"
},
{
"provider": "GLS",
"levels": "A0-C2",
"format": "On-site Berlin"
},
{
"provider": "Sprachinstitut Berlin",
"levels": "B2-C2",
"format": "On-site Berlin"
},
{
"provider": "Goethe-Institut Berlin",
"levels": "A1-C2",
"format": "On-site Berlin"
},
{
"provider": "Kapitel Zwei",
"levels": "A1-C2",
"format": "On-site Berlin"
},
{
"provider": "Hartnackschule Berlin",
"levels": "A1-C2",
"format": "On-site Berlin"
},
{
"provider": "Sprachenatelier Berlin",
"levels": "A1-C2",
"format": "On-site Berlin"
},
{
"provider": "DIE NEUE SCHULE",
"levels": "A1-C2",
"format": "On-site Berlin"
},
{
"provider": "Deutsch-Raum Sprachschule",
"levels": "A1-C2",
"format": "On-site Berlin"
}
]

View File

@@ -0,0 +1,161 @@
type MetadataLike = {
title?: string
description?: string
manifest?: string
alternates?: {
canonical?: string
languages?: Record<string, string>
}
icons?: {
icon?: Array<{ url: string; sizes?: string; type?: string }>
apple?: Array<{ url: string; sizes?: string }>
}
openGraph?: {
title?: string
description?: string
url?: string
siteName?: string
type?: string
images?: Array<{ url: string; alt?: string }>
}
twitter?: {
card?: string
title?: string
description?: string
images?: string[]
}
robots?: {
index?: boolean
follow?: boolean
nocache?: boolean
googleBot?: {
index?: boolean
follow?: boolean
noimageindex?: boolean
}
}
}
const pushMeta = (
meta: Array<Record<string, string>>,
key: string,
value?: string
) => {
if (!value) return
meta.push({ [key]: value })
}
export function metadataToHead(metadata: MetadataLike) {
const meta: Array<Record<string, string>> = [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
]
const links: Array<Record<string, string>> = []
if (metadata.title) {
meta.push({ title: metadata.title })
meta.push({ property: 'og:title', content: metadata.title })
meta.push({ name: 'twitter:title', content: metadata.title })
}
pushMeta(meta, 'name', metadata.description ? 'description' : undefined)
if (metadata.description) {
meta.push({ name: 'description', content: metadata.description })
meta.push({ property: 'og:description', content: metadata.description })
meta.push({ name: 'twitter:description', content: metadata.description })
}
if (metadata.manifest) {
links.push({ rel: 'manifest', href: metadata.manifest })
}
for (const icon of metadata.icons?.icon ?? []) {
links.push({
rel: 'icon',
href: icon.url,
...(icon.sizes ? { sizes: icon.sizes } : {}),
...(icon.type ? { type: icon.type } : {}),
})
}
for (const icon of metadata.icons?.apple ?? []) {
links.push({
rel: 'apple-touch-icon',
href: icon.url,
...(icon.sizes ? { sizes: icon.sizes } : {}),
})
}
if (metadata.alternates?.canonical) {
links.push({
rel: 'canonical',
href: metadata.alternates.canonical,
})
}
for (const [hreflang, href] of Object.entries(
metadata.alternates?.languages ?? {}
)) {
links.push({
rel: 'alternate',
hrefLang: hreflang,
href,
})
}
if (metadata.openGraph?.url) {
meta.push({ property: 'og:url', content: metadata.openGraph.url })
}
if (metadata.openGraph?.siteName) {
meta.push({ property: 'og:site_name', content: metadata.openGraph.siteName })
}
if (metadata.openGraph?.type) {
meta.push({ property: 'og:type', content: metadata.openGraph.type })
}
for (const image of metadata.openGraph?.images ?? []) {
meta.push({ property: 'og:image', content: image.url })
if (image.alt) {
meta.push({ property: 'og:image:alt', content: image.alt })
}
}
if (metadata.twitter?.card) {
meta.push({ name: 'twitter:card', content: metadata.twitter.card })
}
for (const image of metadata.twitter?.images ?? []) {
meta.push({ name: 'twitter:image', content: image })
}
if (metadata.robots) {
const robots = [
metadata.robots.index === false ? 'noindex' : 'index',
metadata.robots.follow === false ? 'nofollow' : 'follow',
metadata.robots.nocache ? 'nocache' : undefined,
]
.filter(Boolean)
.join(', ')
if (robots) {
meta.push({ name: 'robots', content: robots })
}
if (metadata.robots.googleBot) {
const googleBot = [
metadata.robots.googleBot.index === false ? 'noindex' : 'index',
metadata.robots.googleBot.follow === false ? 'nofollow' : 'follow',
metadata.robots.googleBot.noimageindex ? 'noimageindex' : undefined,
]
.filter(Boolean)
.join(', ')
if (googleBot) {
meta.push({ name: 'googlebot', content: googleBot })
}
}
}
return {
meta,
links,
}
}

View File

@@ -0,0 +1,50 @@
import type { CSSProperties, ImgHTMLAttributes } from 'react'
type ImageSource = string | { src: string }
export type ImageProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
'src' | 'width' | 'height'
> & {
src: ImageSource
width?: number
height?: number
fill?: boolean
priority?: boolean
placeholder?: string
}
const resolveSource = (src: ImageSource) => {
return typeof src === 'string' ? src : src.src
}
export default function Image({
src,
fill,
priority,
style,
width,
height,
...rest
}: ImageProps) {
const resolvedStyle: CSSProperties = fill
? {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
...style,
}
: { ...style }
return (
<img
src={resolveSource(src)}
width={width}
height={height}
loading={priority ? 'eager' : rest.loading}
{...rest}
style={resolvedStyle}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { forwardRef, type AnchorHTMLAttributes } from 'react'
import { Link as RouterLink } from '@tanstack/react-router'
export type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ref) {
const { href, ...rest } = props
if (/^(https?:|mailto:|tel:)/.test(href)) {
return <a ref={ref} href={href} {...rest} />
}
return <RouterLink ref={ref} to={href as never} {...(rest as Record<string, unknown>)} />
})
export default Link

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { I18nProvider } from '@/context/i18n-provider'
export function LocalizedLayout({
lang,
children,
}: {
lang: string
children: ReactNode
}) {
return <I18nProvider lang={lang}>{children}</I18nProvider>
}

View File

@@ -0,0 +1,33 @@
'use client'
import {
useLocation,
useNavigate,
useParams as useRouteParams,
useRouter as useTanStackRouter,
} from '@tanstack/react-router'
type RouteParams = Record<string, string | string[] | undefined>
export const useRouter = () => {
const navigate = useNavigate()
const router = useTanStackRouter()
return {
push: (to: string) => navigate({ to }),
replace: (to: string) => navigate({ to, replace: true }),
refresh: () => router.invalidate(),
}
}
export const usePathname = () => useLocation({ select: (location) => location.pathname })
export const useSearchParams = () =>
useLocation({
select: (location) => new URLSearchParams(location.searchStr),
})
export const useParams = <T extends RouteParams = RouteParams>(): T => {
const params = useRouteParams({ strict: false })
return params as T
}

View File

@@ -0,0 +1 @@
export { redirect } from '@tanstack/react-router'

View File

@@ -0,0 +1,109 @@
import type { ReactNode } from 'react'
import { ColorSchemeScript, mantineHtmlProps } from '@pikku/mantine/core'
import { WebsiteProviders } from '@/framework/website-providers'
import {
getOrganizationStructuredData,
getWebsiteStructuredData,
} from '@/lib/seo'
type RootDocumentProps = {
children: ReactNode
lang: string
initialTheme: 'light' | 'dark' | 'auto'
gaId?: string
headContent?: ReactNode
bodyEnd?: ReactNode
}
const FACEBOOK_PIXEL_BOOTSTRAP = `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '1553398729104801');
fbq('track', 'PageView');
`
const getTextDirection = (lang: string) => (lang === 'ar' ? 'rtl' : 'ltr')
export function RootDocument({
children,
lang,
initialTheme,
gaId,
headContent,
bodyEnd,
}: RootDocumentProps) {
const dir = getTextDirection(lang)
const organizationStructuredData = getOrganizationStructuredData()
const websiteStructuredData = getWebsiteStructuredData()
return (
<html lang={lang} dir={dir} {...mantineHtmlProps} suppressHydrationWarning>
<head>
{headContent}
<ColorSchemeScript
defaultColorScheme={initialTheme}
suppressHydrationWarning
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(organizationStructuredData),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(websiteStructuredData),
}}
/>
<script
dangerouslySetInnerHTML={{ __html: FACEBOOK_PIXEL_BOOTSTRAP }}
/>
{gaId ? (
<>
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}');
`,
}}
/>
</>
) : null}
</head>
<body suppressHydrationWarning>
<noscript>
<img
height="1"
width="1"
style={{ display: 'none' }}
src="https://www.facebook.com/tr?id=1553398729104801&ev=PageView&noscript=1"
/>
</noscript>
<script
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
data-cbid="2a5d6922-1fcb-4fcc-969c-4db4b935a77d"
data-blockingmode="auto"
/>
<WebsiteProviders initialTheme={initialTheme} locale={lang}>
{children}
</WebsiteProviders>
{bodyEnd}
</body>
</html>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import type { ReactNode } from 'react'
import { QueryClientProvider, ThemeProvider } from '@heygermany/components'
export type WebsiteProvidersProps = {
children: ReactNode
initialTheme: 'light' | 'dark' | 'auto'
locale: string
}
export function WebsiteProviders({
children,
initialTheme,
locale,
}: WebsiteProvidersProps) {
return (
<QueryClientProvider>
<ThemeProvider initial={initialTheme} locale={locale}>
{children}
</ThemeProvider>
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,19 @@
import { signInWithPassword } from "@/lib/auth"
import { useMutation } from "@tanstack/react-query"
import { useParams, useRouter } from "@/framework/navigation"
export const useBackOfficeLogin = () => {
const router = useRouter()
const { lang } = useParams<{ lang?: string }>()
return useMutation({
mutationFn: async ({ email, password }: { email: string; password: string }) => {
return await signInWithPassword(email, password)
},
onSuccess: () => {
router.replace(`/${lang || 'en'}/backoffice/candidates`)
},
onError: (error) => {
console.error('BackOffice login failed:', error)
}
})
}

View File

@@ -0,0 +1,10 @@
import { pikku } from "@/pikku/http"
import { useQuery } from "@tanstack/react-query"
export const useGetBackOfficeCandidate = (candidateId: string) => {
return useQuery({
queryKey: ['backoffice', 'candidate', candidateId],
queryFn: async () => await pikku().get(`/backoffice/candidate/:candidateId`, { candidateId }),
enabled: !!candidateId
})
}

View File

@@ -0,0 +1,10 @@
import { pikku } from "@/pikku/http"
import { useQuery } from "@tanstack/react-query"
export const useGetBackOfficeCandidateResult = (candidateId: string) => {
return useQuery({
queryKey: ['backoffice', 'candidate', candidateId, 'result'],
queryFn: async () => await pikku().get(`/backoffice/candidate/:candidateId/result`, { candidateId }),
enabled: !!candidateId
})
}

View File

@@ -0,0 +1,9 @@
import { pikku } from "@/pikku/http"
import { useQuery } from "@tanstack/react-query"
export const useGetBackOfficeCandidates = () => {
return useQuery({
queryKey: ['backoffice', 'candidates'],
queryFn: async () => await pikku().get('/backoffice/candidate', {})
})
}

View File

@@ -0,0 +1,25 @@
import { pikku } from "@/pikku/http"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { UpdateBackOfficeCandidateInput } from '@heygermany/functions/.pikku/http/pikku-http-wirings-map.gen.d.js'
type BackOfficeCandidatePatch = Omit<UpdateBackOfficeCandidateInput, 'candidateId'> & {
analysisIssues?: string[]
}
export const useUpdateBackOfficeCandidate = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: BackOfficeCandidatePatch) =>
await pikku().patch("/backoffice/candidate/:candidateId", { candidateId, ...data }),
onSuccess: () => {
// Invalidate and refetch the candidate data
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
queryClient.invalidateQueries({
queryKey: ['candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,22 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useMutation, useQueryClient } from "@tanstack/react-query"
type EducationUpdatePayload = Partial<Omit<DB.CandidateEducation, 'candidateId' | 'educationId' | 'createdAt' | 'lastUpdatedAt'>>
export const useUpdateCandidateEducation = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: EducationUpdatePayload) =>
await pikku().patch("/backoffice/candidate/:candidateId/education", { candidateId, ...data }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
queryClient.invalidateQueries({
queryKey: ['candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,19 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useMutation, useQueryClient } from "@tanstack/react-query"
type ExperienceUpdatePayload = Partial<Omit<DB.CandidateExperience, 'candidateId' | 'experienceId' | 'createdAt' | 'lastUpdatedAt'>>
export const useUpdateCandidateExperience = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: ExperienceUpdatePayload) =>
await pikku().patch("/backoffice/candidate/:candidateId/experience", { candidateId, ...data }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,22 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useMutation, useQueryClient } from "@tanstack/react-query"
type LanguageUpdatePayload = Partial<Omit<DB.CandidateLanguage, 'candidateId' | 'languageId' | 'createdAt' | 'lastUpdatedAt'>>
export const useUpdateCandidateLanguage = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: LanguageUpdatePayload) =>
await pikku().patch("/backoffice/candidate/:candidateId/language", { candidateId, ...data }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
queryClient.invalidateQueries({
queryKey: ['candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,22 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useMutation, useQueryClient } from "@tanstack/react-query"
type LicenseUpdatePayload = Partial<Omit<DB.CandidateLicense, 'candidateId' | 'licenseId' | 'createdAt' | 'lastUpdatedAt'>>
export const useUpdateCandidateLicense = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: LicenseUpdatePayload) =>
await pikku().patch("/backoffice/candidate/:candidateId/license", { candidateId, ...data }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
queryClient.invalidateQueries({
queryKey: ['candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,27 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useMutation, useQueryClient } from "@tanstack/react-query"
type UpdateDocumentVerificationInput = {
documentId: string
documentStatus: DB.DocumentStatus
}
export const useUpdateDocumentVerification = (candidateId: string) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ documentId, documentStatus }: UpdateDocumentVerificationInput) =>
await pikku().patch("/backoffice/candidate/:candidateId/document/:documentId", {
candidateId,
documentId,
documentStatus
}),
onSuccess: () => {
// Invalidate and refetch the candidate data
queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
}
})
}

View File

@@ -0,0 +1,66 @@
import { pikku } from "@/pikku/http"
import type { DB } from '@heygermany/sdk'
import { useQueryClient } from "@tanstack/react-query"
export const useUploadBackOfficeCandidateFiles = (candidateId: string, category: DB.CandidateDocumentType) => {
const queryClient = useQueryClient()
const uploadFiles = async (files: File[]) => {
if (!files.length) {
return
}
const uploadUrls = await pikku().post('/candidate/:candidateId/document', {
candidateId,
documents: files.map((file) => ({
fileName: file.name,
size: file.size,
contentType: file.type,
category
}))
})
await Promise.all(uploadUrls.map(async ({ uploadUrl }: any, index: number) => {
const response = await fetch(uploadUrl, {
method: 'PUT',
body: files[index],
cache: 'no-cache',
mode: 'cors',
credentials: 'include',
headers: {
'Content-Type': files[index].type,
'Content-Disposition': 'inline'
}
})
if (!response.ok) {
throw new Error(`Failed to upload ${files[index].name}`)
}
}))
await Promise.all(uploadUrls.map(async ({ documentId }: any) => {
await pikku().patch('/candidate/:candidateId/document/:documentId', {
documentId,
candidateId,
uploaded: true
})
}))
await queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
}
const deleteFile = async (documentId: string) => {
await pikku().delete('/candidate/:candidateId/document/:documentId', {
candidateId,
documentId
})
await queryClient.invalidateQueries({
queryKey: ['backoffice', 'candidate', candidateId]
})
}
return { uploadFiles, deleteFile }
}

View File

@@ -0,0 +1,10 @@
import { ApplicationCandidateData } from "@heygermany/sdk"
import { useForm } from "react-hook-form"
export const useCandidateForm = (candidate: ApplicationCandidateData) => {
const methods = useForm<ApplicationCandidateData>({
defaultValues: candidate,
})
return methods
}

View File

@@ -0,0 +1,18 @@
import { pikku } from '@/pikku/http'
import type { ApplicationCandidateData } from '@heygermany/sdk'
import { useQuery } from '@tanstack/react-query'
export const useGetCandidate = (candidateId?: string) => {
return useQuery({
queryKey: ['candidate'],
queryFn: async (): Promise<ApplicationCandidateData> => {
if (!candidateId) {
return await pikku().get('/candidate')
} else {
return await pikku().get('/candidate/:candidateId', { candidateId })
}
},
staleTime: 5 * 60 * 1000, // 5 minutes,
retry: 0
})
}

View File

@@ -0,0 +1,20 @@
import { signInWithPassword } from "@/lib/auth"
import { useMutation } from "@tanstack/react-query"
import { useParams, useRouter } from "@/framework/navigation"
export const useCompanyLogin = () => {
const router = useRouter()
const { lang } = useParams<{ lang?: string }>()
return useMutation({
mutationFn: async ({ email, password }: { email: string; password: string }) => {
return await signInWithPassword(email, password)
},
onSuccess: () => {
router.replace(`/${lang || 'en'}/company/candidates`)
},
onError: (error) => {
console.error('Company login failed:', error)
}
})
}

View File

@@ -0,0 +1,10 @@
import { pikku } from "@/pikku/http"
import { useQuery } from "@tanstack/react-query"
export const useGetCompanyCandidate = (candidateId: string) => {
return useQuery({
queryKey: ['company', 'candidate', candidateId],
queryFn: async () => await pikku().get('/company/candidate/:candidateId', { candidateId }),
enabled: !!candidateId
})
}

View File

@@ -0,0 +1,9 @@
import { pikku } from "@/pikku/http"
import { useQuery } from "@tanstack/react-query"
export const useGetCompanyCandidates = () => {
return useQuery({
queryKey: ['company', 'candidates'],
queryFn: async () => await pikku().get('/company/candidate', {})
})
}

Some files were not shown because too many files have changed in this diff Show More