Bilder + GSC + Posthog

This commit is contained in:
2026-04-29 20:33:21 +02:00
committed by Timo Knuth
parent 32bd2c38e0
commit 6b6ce9d6ae
11 changed files with 411 additions and 234 deletions

View File

@@ -4,58 +4,68 @@ import path from 'path'
import { randomUUID } from 'crypto'
import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) {
// Auth check
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: 'File too large' }, { status: 413 })
}
// Only allow safe file types
const allowedTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
}
const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}`
const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(uploadPath, fileName), buffer)
return NextResponse.json({
storagePath: fileName,
name: file.name,
sizeBytes: file.size,
url: `/uploads/${fileName}`,
})
}
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
export const runtime = 'nodejs'
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) {
try {
// Auth check
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: 'File too large' }, { status: 413 })
}
// Only allow safe file types
const allowedTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
}
const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}`
const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(uploadPath, fileName), buffer)
return NextResponse.json({
storagePath: fileName,
name: file.name,
sizeBytes: file.size,
url: `/uploads/${fileName}`,
})
} catch (error) {
console.error('Upload failed:', error)
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
)
}
}

View File

@@ -5,6 +5,8 @@ import Link from 'next/link'
import Image from 'next/image'
import { Syne } from 'next/font/google'
import { ArrowRight, ArrowUpRight, Sun, Moon, Menu, X } from 'lucide-react'
import posthog from 'posthog-js'
import { initPosthog } from '../instrumentation-client'
const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] })
@@ -75,21 +77,17 @@ export default function RootPage() {
useEffect(() => {
if (cookieConsent === 'accepted') {
window.posthog?.opt_in_capturing?.()
const sendPageView = () => {
window.posthog?.capture?.('landing_page_viewed', {
path: window.location.pathname,
})
}
if (window.posthog?.capture) {
sendPageView()
} else {
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
window.setTimeout(sendPageView, 150)
}
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
initPosthog()
posthog.opt_in_capturing()
posthog.capture('$pageview', {
$current_url: window.location.href,
path: window.location.pathname,
referrer: document.referrer,
})
}
if (cookieConsent === 'declined') {
window.posthog?.opt_out_capturing?.()
posthog.opt_out_capturing()
}
}, [cookieConsent])
@@ -110,18 +108,12 @@ export default function RootPage() {
const trackLandingCta = (placement: string) => {
if (cookieConsent !== 'accepted') return
const sendCta = () => {
window.posthog?.capture?.('landing_cta_clicked', {
placement,
target: 'contact_email',
})
}
if (window.posthog?.capture) {
sendCta()
} else {
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
window.setTimeout(sendCta, 150)
}
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
initPosthog()
posthog.capture('landing_cta_clicked', {
placement,
target: 'contact_email',
})
}
const handleContactCtaClick = (placement: string) => {

View File

@@ -5,9 +5,20 @@ import { useRouter } from 'next/navigation'
import { createOrganization } from './actions'
import { LandingPagePreview } from './LandingPagePreview'
const initialState = { success: false, error: '' }
export function CreateOrgForm() {
const initialState = { success: false, error: '' }
async function readUploadResponse(res: Response) {
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || 'Upload fehlgeschlagen')
}
if (!data?.url) {
throw new Error('Upload-Antwort enthaelt keine Datei-URL')
}
return data as { url: string }
}
export function CreateOrgForm() {
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
const router = useRouter()
const [step, setStep] = useState(1)
@@ -69,20 +80,19 @@ export function CreateOrgForm() {
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
setFormData(prev => ({ ...prev, logoUrl: data.url }))
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
}
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await readUploadResponse(res)
setFormData(prev => ({ ...prev, logoUrl: data.url }))
} catch (err) {
console.error('Upload failed', err)
alert(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setIsUploading(false)
}
}
const [isHeroUploading, setIsHeroUploading] = useState(false)
@@ -98,20 +108,19 @@ export function CreateOrgForm() {
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsHeroUploading(false)
}
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await readUploadResponse(res)
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
} catch (err) {
console.error('Upload failed', err)
alert(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setIsHeroUploading(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {

View File

@@ -1,6 +1,6 @@
'use server'
import { prisma, Prisma } from '@innungsapp/shared'
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
@@ -13,13 +13,13 @@ function normalizeEmail(email: string | null | undefined): string {
return (email ?? '').trim().toLowerCase()
}
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (!value) {
return Prisma.DbNull
}
return value
}
function toJsonbText(value: string | undefined): string | null {
if (!value) {
return null
}
return value
}
/**
* Sets a credential (email+password) account for a user.

View File

@@ -43,9 +43,20 @@ interface Props {
}
}
const initialState = { success: false, error: '' }
export function EditOrgForm({ org }: Props) {
const initialState = { success: false, error: '' }
async function readUploadResponse(res: Response) {
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || 'Upload fehlgeschlagen')
}
if (!data?.url) {
throw new Error('Upload-Antwort enthaelt keine Datei-URL')
}
return data as { url: string }
}
export function EditOrgForm({ org }: Props) {
const boundAction = updateOrganization.bind(null, org.id)
const [state, formAction, isPending] = useActionState(boundAction, initialState)
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
@@ -65,21 +76,20 @@ export function EditOrgForm({ org }: Props) {
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
if (type === 'logo') setLogoUrl(data.url)
if (type === 'hero') setHeroImageUrl(data.url)
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(prev => ({ ...prev, [type]: false }))
}
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await readUploadResponse(res)
if (type === 'logo') setLogoUrl(data.url)
if (type === 'hero') setHeroImageUrl(data.url)
} catch (err) {
console.error('Upload failed', err)
alert(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setIsUploading(prev => ({ ...prev, [type]: false }))
}
}

View File

@@ -1,102 +1,29 @@
import posthog from 'posthog-js'
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com'
const COOKIE_CONSENT_KEY = 'innungsapp_cookie_consent'
const COOKIE_CONSENT_EVENT = 'innungsapp:cookie-consent-granted'
export {}
type PostHogApi = {
__SV?: number
init?: (token: string, config: Record<string, unknown>) => void
capture?: (event: string, properties?: Record<string, unknown>) => void
opt_in_capturing?: () => void
opt_out_capturing?: () => void
}
declare global {
interface Window {
posthog?: PostHogApi
__innungsappPosthogInitialized?: boolean
}
}
function ensurePosthogSnippetLoaded() {
const w = window as any
if (w.posthog?.__SV) return
export function initPosthog() {
if (typeof window === 'undefined' || !POSTHOG_KEY) return false
if (window.__innungsappPosthogInitialized) return true
;(function loadSnippet(doc: Document, ph: any) {
const base: any = Array.isArray(ph) ? ph : []
if (base.__SV) return
w.posthog = base
base._i = base._i || []
base.init = function init(token: string, config: Record<string, unknown>, name?: string) {
const target = name ? (base[name] = base[name] || []) : base
const setMethod = (obj: any, method: string) => {
obj[method] = function methodStub(...args: unknown[]) {
obj.push([method, ...args])
}
}
const methods = [
'capture',
'identify',
'alias',
'group',
'set_config',
'reset',
'register',
'register_once',
'unregister',
'opt_in_capturing',
'opt_out_capturing',
'has_opted_in_capturing',
'has_opted_out_capturing',
'isFeatureEnabled',
'reloadFeatureFlags',
]
methods.forEach((method) => setMethod(target, method))
target.people = target.people || []
const peopleMethods = ['set', 'set_once', 'unset', 'increment', 'append', 'union', 'track_charge', 'clear_charges', 'delete_user']
peopleMethods.forEach((method) => setMethod(target.people, method))
const script = doc.createElement('script')
script.type = 'text/javascript'
script.async = true
script.src = `${(config.api_host as string).replace('.i.posthog.com', '-assets.i.posthog.com')}/static/array.js`
const firstScript = doc.getElementsByTagName('script')[0]
if (firstScript?.parentNode) {
firstScript.parentNode.insertBefore(script, firstScript)
} else {
doc.head.appendChild(script)
}
base._i.push([token, config, name])
}
base.__SV = 1
})(document, w.posthog || [])
}
function initPosthog() {
if (typeof window === 'undefined' || !POSTHOG_KEY) return
if (window.__innungsappPosthogInitialized) return
ensurePosthogSnippetLoaded()
window.posthog?.init?.(POSTHOG_KEY, {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
defaults: '2026-01-30',
autocapture: false,
capture_pageview: false,
respect_dnt: true,
})
window.__innungsappPosthogInitialized = true
return true
}
if (typeof window !== 'undefined' && POSTHOG_KEY) {

View File

@@ -27,6 +27,7 @@
"next": "15.3.4",
"nodemailer": "^6.9.0",
"openai": "^6.22.0",
"posthog-js": "^1.372.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"sharp": "^0.33.0",

View File

@@ -1,7 +1 @@
<!DOCTYPE html>
<html>
<head>
<meta name="google-site-verification" content="googleccd5315437d68a49" />
</head>
<body></body>
</html>
google-site-verification: googleccd5315437d68a49.html

File diff suppressed because one or more lines are too long