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

This commit is contained in:
e2e
2026-07-11 10:31:48 +02:00
commit 81ea8ef5d1
394 changed files with 38048 additions and 0 deletions

33
apps/website/lib/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createAuthClient } from 'better-auth/client'
import { magicLinkClient } from 'better-auth/client/plugins'
import { apiUrl } from './env'
// Better Auth mounts at /api/auth (default basePath); the client appends the
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
// emits /api/sign-in/email and 404s on both sandbox and deploy. The base MUST be
// absolute (apiUrl() never returns a bare relative path) or the client throws
// "Invalid base URL". The backend wires the magicLink plugin, so the client does
// too — candidates sign in by magic link, staff (company/backoffice) by password.
export const authClient = createAuthClient({
baseURL: `${apiUrl()}/auth`,
plugins: [magicLinkClient()],
})
export async function signInWithPassword(email: string, password: string): Promise<void> {
const { error } = await authClient.signIn.email({ email, password })
if (error) throw new Error(error.message || 'Invalid credentials')
}
export async function requestMagicLink(email: string): Promise<void> {
const { error } = await authClient.signIn.magicLink({ email })
if (error) throw new Error(error.message || 'Unable to send magic link')
}
export async function verifyMagicLink(token: string): Promise<void> {
const { error } = await authClient.magicLink.verify({ query: { token } })
if (error) throw new Error(error.message || 'Verification failed')
}
export async function signOut(): Promise<void> {
await authClient.signOut()
}

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"
}
]

23
apps/website/lib/env.ts Normal file
View File

@@ -0,0 +1,23 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time and Vite replaces the
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
// in the bundle. We fall back to the current origin + /api, which is correct
// because the fabric dispatcher routes every /api/* path to the API on the same
// host. (VITE_API_BASE_URL is accepted as a legacy alias for local dev.)
export function apiUrl(): string {
if (typeof window !== 'undefined' && (window as Window & { __E2E_API_URL?: string }).__E2E_API_URL) {
return (window as Window & { __E2E_API_URL?: string }).__E2E_API_URL as string
}
const buildTime = import.meta.env.VITE_API_URL ?? import.meta.env.VITE_API_BASE_URL
if (import.meta.env.SSR) {
// SSR context: the auth client is only consumed in the browser. Return the
// build-time var when available or a placeholder so SSR never throws.
return buildTime ?? '/__api'
}
// Client: build-time var (local dev) or derive from the current origin. Better
// Auth's client requires an absolute base URL, so never return a bare relative
// '/api' here — '/api/auth' throws "Invalid base URL".
return buildTime ?? `${window.location.origin}/api`
}

161
apps/website/lib/head.ts Normal file
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,3 @@
export function getHealthResponse() {
return new Response('OK')
}

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,269 @@
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import type { UserSession } from '@heygermany/functions/src/application-types.d.js'
import type { DB, UserRole, UserType } from '@heygermany/sdk'
import { UserTypes } from '@heygermany/sdk'
import type { SupportedLocale } from '@heygermany/sdk/config'
import { LOCALES, DEFAULT_LOCALE } from '@/config'
type RequestSnapshot = {
url: string
pathname: string
search: string
headers: Headers
cookies: Map<string, string>
}
type RequestRoutingResult =
| {
type: 'redirect'
location: string
}
| {
type: 'continue'
locale: string
}
const LOCALE_EXEMPT_PREFIXES = ['/pilot']
const LOCALE_EXEMPT_LOCALES: Record<string, SupportedLocale> = {
'/pilot': 'de',
}
const getApiBase = (request: RequestSnapshot) => {
return process.env.VITE_API_BASE_URL || process.env.API_URL || new URL(request.url).origin
}
function shouldBypassRouting(pathname: string): boolean {
return (
pathname.startsWith('/api/') ||
pathname === '/api' ||
pathname.startsWith('/_build/') ||
pathname === '/_build' ||
pathname.startsWith('/_serverFn/') ||
pathname === '/_serverFn' ||
pathname.startsWith('/@') ||
pathname.startsWith('/src/') ||
pathname.startsWith('/node_modules/') ||
/\.[a-z0-9]+$/i.test(pathname)
)
}
const getCookieHeader = (cookies: Map<string, string>) => {
return Array.from(cookies.entries())
.map(([name, value]) => `${name}=${value}`)
.join('; ')
}
const getBetterAuthSession = async (
request: RequestSnapshot
): Promise<UserSession | null> => {
let response: Response
try {
response = await fetch(`${getApiBase(request)}/api/auth/get-session`, {
method: 'GET',
headers: {
cookie: getCookieHeader(request.cookies),
},
})
} catch (error) {
// The session lookup must never crash SSR — a transient backend outage or
// unreachable API should degrade to "logged out", not 500 the whole page.
console.warn('[request-routing] get-session fetch failed, treating as no session:', error)
return null
}
if (!response.ok) {
return null
}
const data = (await response.json()) as {
user?: {
id: string
name: string
role: UserRole
type: UserType
} | null
} | null
if (!data?.user) {
return null
}
return {
userId: data.user.id,
name: data.user.name,
role: data.user.role,
type: data.user.type,
}
}
function getLocale(request: RequestSnapshot): string {
try {
const cookieLocale = request.cookies.get('NEXT_LOCALE')
if (cookieLocale && LOCALES.includes(cookieLocale as SupportedLocale)) {
return cookieLocale
}
const headers = Object.fromEntries(request.headers.entries()) as Negotiator.Headers
const languages = new Negotiator({ headers }).languages()
const validLanguages = languages.filter((lang) => {
try {
Intl.getCanonicalLocales(lang)
return true
} catch {
return false
}
})
if (validLanguages.length === 0) {
return DEFAULT_LOCALE
}
return match(validLanguages, LOCALES, DEFAULT_LOCALE)
} catch (error) {
console.error('[Middleware] Error in locale detection:', error)
return DEFAULT_LOCALE
}
}
function hasLocale(pathname: string): boolean {
return LOCALES.some((locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`)
}
function isLocaleExemptPath(pathname: string): boolean {
return LOCALE_EXEMPT_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
}
function getLocaleExemptPathLocale(pathname: string): SupportedLocale | null {
for (const [prefix, locale] of Object.entries(LOCALE_EXEMPT_LOCALES)) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return locale
}
}
return null
}
function getLocaleFromPath(pathname: string): string | null {
for (const locale of LOCALES) {
if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) {
return locale
}
}
return null
}
function removeLocalePrefix(pathname: string): string {
const locale = getLocaleFromPath(pathname)
if (locale) {
return pathname.slice(`/${locale}`.length) || '/'
}
return pathname
}
function requiresAuth(pathname: string): { required: boolean; type?: DB.UserType } {
const pathWithoutLocale = removeLocalePrefix(pathname)
if (pathWithoutLocale.includes('/auth/login')) {
return { required: false }
}
if (pathWithoutLocale.startsWith('/backoffice')) {
return { required: true, type: UserTypes.BACKOFFICE }
}
if (pathWithoutLocale.startsWith('/company')) {
return { required: true, type: UserTypes.COMPANY }
}
if (pathWithoutLocale.startsWith('/jobs/application')) {
return { required: true, type: UserTypes.CANDIDATE }
}
return { required: false }
}
const toAbsoluteUrl = (request: RequestSnapshot, location: string) => {
return new URL(location, request.url).toString()
}
export const getResolvedLocale = (pathname: string) => {
return getLocaleFromPath(pathname) || getLocaleExemptPathLocale(pathname) || DEFAULT_LOCALE
}
export async function resolveRequestRouting(
request: RequestSnapshot
): Promise<RequestRoutingResult> {
if (shouldBypassRouting(request.pathname)) {
return {
type: 'continue',
locale: getResolvedLocale(request.pathname),
}
}
const fullUrl = request.pathname + request.search
if (!hasLocale(request.pathname) && !isLocaleExemptPath(request.pathname)) {
const locale = getLocale(request)
return {
type: 'redirect',
location: toAbsoluteUrl(request, `/${locale}${request.pathname}${request.search}`),
}
}
const userSession = await getBetterAuthSession(request)
const authCheck = requiresAuth(request.pathname)
if (authCheck.required) {
const currentLocale = getLocaleFromPath(request.pathname) || DEFAULT_LOCALE
if (userSession?.type !== UserTypes.BACKOFFICE) {
if (!userSession || userSession.type !== authCheck.type) {
let loginPath = ''
if (authCheck.type === UserTypes.BACKOFFICE) {
loginPath = `/${currentLocale}/backoffice/auth/login`
} else if (authCheck.type === UserTypes.COMPANY) {
loginPath = `/${currentLocale}/company/auth/login`
} else if (authCheck.type === UserTypes.CANDIDATE) {
loginPath = `/${currentLocale}/jobs/apply`
}
const returnUrl = encodeURIComponent(fullUrl)
return {
type: 'redirect',
location: toAbsoluteUrl(request, `${loginPath}?return=${returnUrl}`),
}
}
}
}
if (userSession) {
const pathWithoutLocale = removeLocalePrefix(request.pathname)
const currentLocale = getLocaleFromPath(request.pathname) || DEFAULT_LOCALE
if (pathWithoutLocale.includes('/auth/login')) {
let dashboardPath = ''
if (userSession.type === UserTypes.BACKOFFICE) {
dashboardPath = `/${currentLocale}/backoffice/candidates`
} else if (userSession.type === UserTypes.COMPANY) {
dashboardPath = `/${currentLocale}/company/candidates`
} else if (userSession.type === UserTypes.CANDIDATE) {
dashboardPath = `/${currentLocale}/jobs/application`
}
if (dashboardPath) {
return {
type: 'redirect',
location: toAbsoluteUrl(request, dashboardPath),
}
}
}
}
return {
type: 'continue',
locale: getResolvedLocale(request.pathname),
}
}

View File

@@ -0,0 +1,15 @@
import { SITE_URL } from '@/lib/seo'
export function getRobotsConfig() {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/'],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
}
}

View File

@@ -0,0 +1,109 @@
import type { ReactNode } from 'react'
import { ColorSchemeScript, mantineHtmlProps } from '@pikku/mantine/core'
import { WebsiteProviders } from '@/lib/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>
)
}

225
apps/website/lib/seo.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { SupportedLocale } from '@heygermany/sdk/config'
import { DEFAULT_LOCALE, LOCALES } from '@/config'
import { mTextIn } from '@/i18n/messages'
type MessageKey = string
export const SITE_NAME = 'HeyGermany'
export const BRAND_ALT_NAME = 'Hey Germany'
export const CONTACT_EMAIL = 'info@hey-germany.com'
const viteEnv = (import.meta as ImportMeta & {
env?: Record<string, string | undefined>
}).env
export const SITE_URL =
viteEnv?.VITE_SITE_URL || process.env.VITE_SITE_URL || 'https://hey-germany.com'
export const DEFAULT_SITE_TITLE = `${SITE_NAME} | Nursing Career and Qualification Check in Germany`
export const DEFAULT_SITE_DESCRIPTION =
'HeyGermany helps internationally qualified nurses check their qualifications and take the next step toward a nursing career in Germany.'
export const DEFAULT_OG_IMAGE = '/heygermany-logo.png'
export const NO_INDEX_METADATA = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
}
type CreatePageMetadataInput = {
locale: string
pathname?: string
title: string
description: string
imagePath?: string
index?: boolean
availableLocales?: readonly SupportedLocale[]
canonicalLocale?: SupportedLocale
}
function isSupportedLocale(locale: string): locale is SupportedLocale {
return LOCALES.includes(locale as SupportedLocale)
}
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/'
}
return pathname.startsWith('/') ? pathname : `/${pathname}`
}
function buildRobots(index: boolean) {
return index
? {
index: true,
follow: true,
}
: NO_INDEX_METADATA.robots
}
export function normalizeLocale(locale: string): SupportedLocale {
return isSupportedLocale(locale) ? locale : DEFAULT_LOCALE
}
export function getAbsoluteUrl(pathname: string): string {
return new URL(pathname, SITE_URL).toString()
}
export function getLocalizedPath(locale: string, pathname = '/'): string {
const normalizedLocale = normalizeLocale(locale)
const normalizedPath = normalizePath(pathname)
return normalizedPath === '/'
? `/${normalizedLocale}`
: `/${normalizedLocale}${normalizedPath}`
}
export function withSiteName(title: string): string {
return title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}`
}
export function getMessage(locale: string, key: MessageKey): string {
return mTextIn(normalizeLocale(locale), key)
}
function buildLanguageAlternates(
pathname: string,
locales: readonly SupportedLocale[]
): Record<string, string> | undefined {
if (locales.length < 2) {
return undefined
}
const alternates = Object.fromEntries(
locales.map((locale) => [locale, getAbsoluteUrl(getLocalizedPath(locale, pathname))])
) as Record<string, string>
alternates['x-default'] = getAbsoluteUrl(getLocalizedPath(DEFAULT_LOCALE, pathname))
return alternates
}
export function createPageMetadata({
locale,
pathname = '/',
title,
description,
imagePath = DEFAULT_OG_IMAGE,
index = true,
availableLocales = LOCALES,
canonicalLocale,
}: CreatePageMetadataInput) {
const resolvedLocale = normalizeLocale(locale)
const resolvedCanonicalLocale = canonicalLocale ?? resolvedLocale
const canonicalPath = getLocalizedPath(resolvedCanonicalLocale, pathname)
const resolvedTitle = withSiteName(title)
const imageUrl = getAbsoluteUrl(imagePath)
const languages = buildLanguageAlternates(pathname, availableLocales)
return {
title: resolvedTitle,
description,
alternates: {
canonical: getAbsoluteUrl(canonicalPath),
...(languages ? { languages } : {}),
},
openGraph: {
title: resolvedTitle,
description,
url: getAbsoluteUrl(canonicalPath),
siteName: SITE_NAME,
type: 'website',
images: [
{
url: imageUrl,
alt: resolvedTitle,
},
],
},
twitter: {
card: 'summary_large_image',
title: resolvedTitle,
description,
images: [imageUrl],
},
robots: buildRobots(index),
}
}
export function createHomepageMetadata(locale: string) {
const resolvedLocale = normalizeLocale(locale)
const homepageMetadata: Partial<
Record<SupportedLocale, { title: string; description: string }>
> = {
en: {
title: DEFAULT_SITE_TITLE,
description: DEFAULT_SITE_DESCRIPTION,
},
de: {
title: `${SITE_NAME} | Pflegekarriere und Qualifikationscheck in Deutschland`,
description:
'HeyGermany unterstützt internationale Pflegefachkräfte dabei, ihre Qualifikation für Deutschland zu prüfen und den nächsten Schritt in ihre Pflegekarriere zu gehen.',
},
}
const localizedBrandMetadata = homepageMetadata[resolvedLocale]
return createPageMetadata({
locale: resolvedLocale,
title: localizedBrandMetadata?.title ?? `${SITE_NAME} | ${getMessage(locale, 'website.hero.title')}`,
description: localizedBrandMetadata?.description ?? getMessage(locale, 'website.hero.subtitle'),
})
}
export function getOrganizationStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${SITE_URL}#organization`,
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: getAbsoluteUrl(DEFAULT_OG_IMAGE),
},
email: CONTACT_EMAIL,
description: DEFAULT_SITE_DESCRIPTION,
contactPoint: [
{
'@type': 'ContactPoint',
contactType: 'customer support',
email: CONTACT_EMAIL,
url: SITE_URL,
availableLanguage: LOCALES,
},
],
brand: {
'@type': 'Brand',
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
},
}
}
export function getWebsiteStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}#website`,
url: SITE_URL,
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
description: DEFAULT_SITE_DESCRIPTION,
publisher: {
'@id': `${SITE_URL}#organization`,
},
inLanguage: LOCALES,
}
}

View File

@@ -0,0 +1,94 @@
import { DEFAULT_LOCALE, LOCALES } from '../config'
const PUBLIC_LOCALIZED_PATHS = ['/', '/about', '/jobs/apply'] as const
const LEGAL_PATHS = [
'/legal/imprint',
'/legal/privacy',
'/legal/terms-and-conditions',
] as const
type SitemapPage = {
path: string
sitemap: {
lastmod: string
changefreq: 'weekly' | 'monthly' | 'yearly'
priority: number
alternateRefs?: Array<{
href: string
hreflang: string
}>
}
}
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/'
}
return pathname.startsWith('/') ? pathname : `/${pathname}`
}
function getLocalizedPath(locale: string, pathname = '/'): string {
const normalizedPath = normalizePath(pathname)
return normalizedPath === '/'
? `/${locale}`
: `/${locale}${normalizedPath}`
}
const getLocalizedChangeFrequency = (
pathname: (typeof PUBLIC_LOCALIZED_PATHS)[number]
): SitemapPage['sitemap']['changefreq'] => {
return pathname === '/' ? 'weekly' : 'monthly'
}
function getAlternateRefs(pathname: (typeof PUBLIC_LOCALIZED_PATHS)[number]) {
return [
...LOCALES.map((locale) => ({
href: getLocalizedPath(locale, pathname),
hreflang: locale,
})),
{
href: getLocalizedPath(DEFAULT_LOCALE, pathname),
hreflang: 'x-default',
},
]
}
export function getSitemapPages(lastModified = new Date()): SitemapPage[] {
const lastmod = lastModified.toISOString().split('T')[0]!
const localizedPages: SitemapPage[] = LOCALES.flatMap((locale) =>
PUBLIC_LOCALIZED_PATHS.map((pathname) => ({
path: getLocalizedPath(locale, pathname),
sitemap: {
lastmod,
changefreq: getLocalizedChangeFrequency(pathname),
priority: pathname === '/' ? 1 : 0.8,
alternateRefs: getAlternateRefs(pathname),
},
}))
)
const legalPages: SitemapPage[] = LEGAL_PATHS.map((pathname) => ({
path: getLocalizedPath(DEFAULT_LOCALE, pathname),
sitemap: {
lastmod,
changefreq: 'yearly',
priority: 0.3,
},
}))
return [
...localizedPages,
...legalPages,
{
path: '/pilot',
sitemap: {
lastmod,
changefreq: 'weekly',
priority: 0.8,
},
},
]
}

View File

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