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:36:03 +02:00
commit 1e78e21c36
398 changed files with 38345 additions and 0 deletions

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