chore: heygermany customer project
This commit is contained in:
33
apps/website/lib/auth.ts
Normal file
33
apps/website/lib/auth.ts
Normal 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()
|
||||
}
|
||||
3
apps/website/lib/content/compare-recognition-options.ts
Normal file
3
apps/website/lib/content/compare-recognition-options.ts
Normal 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
|
||||
14
apps/website/lib/content/free-course-links.ts
Normal file
14
apps/website/lib/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/lib/content/paid-course-providers.ts
Normal file
61
apps/website/lib/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"
|
||||
}
|
||||
]
|
||||
23
apps/website/lib/env.ts
Normal file
23
apps/website/lib/env.ts
Normal 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
161
apps/website/lib/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,
|
||||
}
|
||||
}
|
||||
3
apps/website/lib/health.ts
Normal file
3
apps/website/lib/health.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getHealthResponse() {
|
||||
return new Response('OK')
|
||||
}
|
||||
12
apps/website/lib/localized-layout.tsx
Normal file
12
apps/website/lib/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>
|
||||
}
|
||||
269
apps/website/lib/request-routing.ts
Normal file
269
apps/website/lib/request-routing.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/robots.ts
Normal file
15
apps/website/lib/robots.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
109
apps/website/lib/root-document.tsx
Normal file
109
apps/website/lib/root-document.tsx
Normal 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
225
apps/website/lib/seo.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
94
apps/website/lib/sitemap.ts
Normal file
94
apps/website/lib/sitemap.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
22
apps/website/lib/website-providers.tsx
Normal file
22
apps/website/lib/website-providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user