chore: heygermany customer project
This commit is contained in:
@@ -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
|
||||
14
apps/website/framework/content/free-course-links.ts
Normal file
14
apps/website/framework/content/free-course-links.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
61
apps/website/framework/content/paid-course-providers.ts
Normal file
61
apps/website/framework/content/paid-course-providers.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
161
apps/website/framework/head.ts
Normal file
161
apps/website/framework/head.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
50
apps/website/framework/image.tsx
Normal file
50
apps/website/framework/image.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
apps/website/framework/link.tsx
Normal file
18
apps/website/framework/link.tsx
Normal 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
|
||||
12
apps/website/framework/localized-layout.tsx
Normal file
12
apps/website/framework/localized-layout.tsx
Normal 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>
|
||||
}
|
||||
33
apps/website/framework/navigation.ts
Normal file
33
apps/website/framework/navigation.ts
Normal 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
|
||||
}
|
||||
1
apps/website/framework/redirect.ts
Normal file
1
apps/website/framework/redirect.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { redirect } from '@tanstack/react-router'
|
||||
109
apps/website/framework/root-document.tsx
Normal file
109
apps/website/framework/root-document.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
apps/website/framework/website-providers.tsx
Normal file
24
apps/website/framework/website-providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user