chore: migrate to payload / nextjs
This commit is contained in:
7
src/components/AdminBar/index.scss
Normal file
7
src/components/AdminBar/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import '@payloadcms/ui/scss';
|
||||
|
||||
.admin-bar {
|
||||
@include small-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
89
src/components/AdminBar/index.tsx
Normal file
89
src/components/AdminBar/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import type { PayloadAdminBarProps, PayloadMeUser } from '@payloadcms/admin-bar'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { useSelectedLayoutSegments } from 'next/navigation'
|
||||
import { PayloadAdminBar } from '@payloadcms/admin-bar'
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
const baseClass = 'admin-bar'
|
||||
|
||||
const collectionLabels = {
|
||||
pages: {
|
||||
plural: 'Pages',
|
||||
singular: 'Page',
|
||||
},
|
||||
posts: {
|
||||
plural: 'Posts',
|
||||
singular: 'Post',
|
||||
},
|
||||
projects: {
|
||||
plural: 'Projects',
|
||||
singular: 'Project',
|
||||
},
|
||||
}
|
||||
|
||||
const Title: React.FC = () => <span>Dashboard</span>
|
||||
|
||||
export const AdminBar: React.FC<{
|
||||
adminBarProps?: PayloadAdminBarProps
|
||||
}> = (props) => {
|
||||
const { adminBarProps } = props || {}
|
||||
const segments = useSelectedLayoutSegments()
|
||||
const [show, setShow] = useState(false)
|
||||
const collection = (
|
||||
collectionLabels[segments?.[1] as keyof typeof collectionLabels] ? segments[1] : 'pages'
|
||||
) as keyof typeof collectionLabels
|
||||
const router = useRouter()
|
||||
|
||||
const onAuthChange = React.useCallback((user: PayloadMeUser) => {
|
||||
setShow(Boolean(user?.id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(baseClass, 'py-2 bg-black text-white', {
|
||||
block: show,
|
||||
hidden: !show,
|
||||
})}
|
||||
>
|
||||
<div className="container">
|
||||
<PayloadAdminBar
|
||||
{...adminBarProps}
|
||||
className="py-2 text-white"
|
||||
classNames={{
|
||||
controls: 'font-medium text-white',
|
||||
logo: 'text-white',
|
||||
user: 'text-white',
|
||||
}}
|
||||
cmsURL={getClientSideURL()}
|
||||
collectionSlug={collection}
|
||||
collectionLabels={{
|
||||
plural: collectionLabels[collection]?.plural || 'Pages',
|
||||
singular: collectionLabels[collection]?.singular || 'Page',
|
||||
}}
|
||||
logo={<Title />}
|
||||
onAuthChange={onAuthChange}
|
||||
onPreviewExit={() => {
|
||||
fetch('/next/exit-preview').then(() => {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
zIndex: 'unset',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/components/BeforeDashboard/SeedButton/index.scss
Normal file
12
src/components/BeforeDashboard/SeedButton/index.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.seedButton {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
88
src/components/BeforeDashboard/SeedButton/index.tsx
Normal file
88
src/components/BeforeDashboard/SeedButton/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import React, { Fragment, useCallback, useState } from 'react'
|
||||
import { toast } from '@payloadcms/ui'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const SuccessMessage: React.FC = () => (
|
||||
<div>
|
||||
Database seeded! You can now{' '}
|
||||
<a target="_blank" href="/">
|
||||
visit your website
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SeedButton: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [seeded, setSeeded] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (seeded) {
|
||||
toast.info('Database already seeded.')
|
||||
return
|
||||
}
|
||||
if (loading) {
|
||||
toast.info('Seeding already in progress.')
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
toast.error(`An error occurred, please refresh and try again.`)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
toast.promise(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
fetch('/next/seed', { method: 'POST', credentials: 'include' })
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
resolve(true)
|
||||
setSeeded(true)
|
||||
} else {
|
||||
reject('An error occurred while seeding.')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: 'Seeding with data....',
|
||||
success: <SuccessMessage />,
|
||||
error: 'An error occurred while seeding.',
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err)
|
||||
setError(error)
|
||||
}
|
||||
},
|
||||
[loading, seeded, error],
|
||||
)
|
||||
|
||||
let message = ''
|
||||
if (loading) message = ' (seeding...)'
|
||||
if (seeded) message = ' (done!)'
|
||||
if (error) message = ` (error: ${error})`
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<button className="seedButton" onClick={handleClick}>
|
||||
Seed your database
|
||||
</button>
|
||||
{message}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
24
src/components/BeforeDashboard/index.scss
Normal file
24
src/components/BeforeDashboard/index.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@import '@payloadcms/ui/scss';
|
||||
|
||||
.dashboard .before-dashboard {
|
||||
margin-bottom: base(1.5);
|
||||
|
||||
&__banner {
|
||||
& h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__instructions {
|
||||
list-style: decimal;
|
||||
margin-bottom: base(0.5);
|
||||
|
||||
& li {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
69
src/components/BeforeDashboard/index.tsx
Normal file
69
src/components/BeforeDashboard/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Banner } from '@payloadcms/ui/elements/Banner'
|
||||
import React from 'react'
|
||||
|
||||
import { SeedButton } from './SeedButton'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'before-dashboard'
|
||||
|
||||
const BeforeDashboard: React.FC = () => {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Banner className={`${baseClass}__banner`} type="success">
|
||||
<h4>Welcome to your dashboard!</h4>
|
||||
</Banner>
|
||||
Here's what to do next:
|
||||
<ul className={`${baseClass}__instructions`}>
|
||||
<li>
|
||||
<SeedButton />
|
||||
{' with a few pages, posts, and projects to jump-start your new site, then '}
|
||||
<a href="/" target="_blank">
|
||||
visit your website
|
||||
</a>
|
||||
{' to see the results.'}
|
||||
</li>
|
||||
<li>
|
||||
{'Modify your '}
|
||||
<a
|
||||
href="https://payloadcms.com/docs/configuration/collections"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
collections
|
||||
</a>
|
||||
{' and add more '}
|
||||
<a
|
||||
href="https://payloadcms.com/docs/fields/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
fields
|
||||
</a>
|
||||
{' as needed. If you are new to Payload, we also recommend you check out the '}
|
||||
<a
|
||||
href="https://payloadcms.com/docs/getting-started/what-is-payload"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Getting Started
|
||||
</a>
|
||||
{' docs.'}
|
||||
</li>
|
||||
<li>
|
||||
Commit and push your changes to the repository to trigger a redeployment of your project.
|
||||
</li>
|
||||
</ul>
|
||||
{'Pro Tip: This block is a '}
|
||||
<a
|
||||
href="https://payloadcms.com/docs/custom-components/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
custom component
|
||||
</a>
|
||||
, you can remove it at any time by updating your <strong>payload.config</strong>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BeforeDashboard
|
||||
14
src/components/BeforeLogin/index.tsx
Normal file
14
src/components/BeforeLogin/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
const BeforeLogin: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<b>Welcome to your dashboard!</b>
|
||||
{' This is where site admins will log in to manage your website.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BeforeLogin
|
||||
80
src/components/Card/index.tsx
Normal file
80
src/components/Card/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import useClickableCard from '@/utilities/useClickableCard'
|
||||
import Link from 'next/link'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Post } from '@/payload-types'
|
||||
|
||||
import { Media } from '@/components/Media'
|
||||
|
||||
export type CardPostData = Pick<Post, 'slug' | 'categories' | 'meta' | 'title'>
|
||||
|
||||
export const Card: React.FC<{
|
||||
alignItems?: 'center'
|
||||
className?: string
|
||||
doc?: CardPostData
|
||||
relationTo?: 'posts'
|
||||
showCategories?: boolean
|
||||
title?: string
|
||||
}> = (props) => {
|
||||
const { card, link } = useClickableCard({})
|
||||
const { className, doc, relationTo, showCategories, title: titleFromProps } = props
|
||||
|
||||
const { slug, categories, meta, title } = doc || {}
|
||||
const { description, image: metaImage } = meta || {}
|
||||
|
||||
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
|
||||
const titleToUse = titleFromProps || title
|
||||
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
|
||||
const href = `/${relationTo}/${slug}`
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
ref={card.ref}
|
||||
>
|
||||
<div className="relative w-full ">
|
||||
{!metaImage && <div className="">No image</div>}
|
||||
{metaImage && typeof metaImage !== 'string' && <Media resource={metaImage} size="33vw" />}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{showCategories && hasCategories && (
|
||||
<div className="uppercase text-sm mb-4">
|
||||
{categories?.map((category, index) => {
|
||||
if (typeof category === 'object') {
|
||||
const { title: titleFromCategory } = category
|
||||
|
||||
const categoryTitle = titleFromCategory || 'Untitled category'
|
||||
|
||||
const isLast = index === categories.length - 1
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{categoryTitle}
|
||||
{!isLast && <Fragment>, </Fragment>}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{titleToUse && (
|
||||
<div className="prose">
|
||||
<h3>
|
||||
<Link className="not-prose" href={href} ref={link.ref}>
|
||||
{titleToUse}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{description && <div className="mt-2">{description && <p>{sanitizedDescription}</p>}</div>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
32
src/components/CollectionArchive/index.tsx
Normal file
32
src/components/CollectionArchive/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
|
||||
import { Card, CardPostData } from '@/components/Card'
|
||||
|
||||
export type Props = {
|
||||
posts: CardPostData[]
|
||||
}
|
||||
|
||||
export const CollectionArchive: React.FC<Props> = (props) => {
|
||||
const { posts } = props
|
||||
|
||||
return (
|
||||
<div className={cn('container')}>
|
||||
<div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-y-4 gap-x-4 lg:gap-y-8 lg:gap-x-8 xl:gap-x-8">
|
||||
{posts?.map((result, index) => {
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
return (
|
||||
<div className="col-span-4" key={index}>
|
||||
<Card className="h-full" doc={result} relationTo="posts" showCategories />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/components/Link/index.tsx
Normal file
66
src/components/Link/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import type { Page, Post } from '@/payload-types'
|
||||
|
||||
type CMSLinkType = {
|
||||
appearance?: 'inline' | ButtonProps['variant']
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
label?: string | null
|
||||
newTab?: boolean | null
|
||||
reference?: {
|
||||
relationTo: 'pages' | 'posts'
|
||||
value: Page | Post | string | number
|
||||
} | null
|
||||
size?: ButtonProps['size'] | null
|
||||
type?: 'custom' | 'reference' | null
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
export const CMSLink: React.FC<CMSLinkType> = (props) => {
|
||||
const {
|
||||
type,
|
||||
appearance = 'inline',
|
||||
children,
|
||||
className,
|
||||
label,
|
||||
newTab,
|
||||
reference,
|
||||
size: sizeFromProps,
|
||||
url,
|
||||
} = props
|
||||
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
|
||||
reference.value.slug
|
||||
}`
|
||||
: url
|
||||
|
||||
if (!href) return null
|
||||
|
||||
const size = appearance === 'link' ? 'clear' : sizeFromProps
|
||||
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
|
||||
|
||||
/* Ensure we don't break any styles set by richText */
|
||||
if (appearance === 'inline') {
|
||||
return (
|
||||
<Link className={cn(className)} href={href || url || ''} {...newTabProps}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button asChild className={className} size={size} variant={appearance}>
|
||||
<Link className={cn(className)} href={href || url || ''} {...newTabProps}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
10
src/components/LivePreviewListener/index.tsx
Normal file
10
src/components/LivePreviewListener/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
export const LivePreviewListener: React.FC = () => {
|
||||
const router = useRouter()
|
||||
return <PayloadLivePreview refresh={router.refresh} serverURL={getClientSideURL()} />
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const SESSION_KEY = 'bmp_auth';
|
||||
const VALID_USER = 'newedgebrand';
|
||||
const VALID_PASS = 'n3wedg3br4nd2o26';
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return sessionStorage.getItem(SESSION_KEY) === '1';
|
||||
}
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
export default function LoginGate({ children }: { children: React.ReactNode }) {
|
||||
const [authed, setAuthed] = useState(isAuthenticated);
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [shake, setShake] = useState(false);
|
||||
|
||||
if (authed) return <>{children}</>;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (user === VALID_USER && pass === VALID_PASS) {
|
||||
sessionStorage.setItem(SESSION_KEY, '1');
|
||||
setAuthed(true);
|
||||
} else {
|
||||
setError('Ungültige Zugangsdaten.');
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 600);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: '#111D55',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: FF,
|
||||
}}>
|
||||
{/* Subtle grid overlay */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, opacity: 0.04,
|
||||
backgroundImage: 'linear-gradient(#EFBF04 1px, transparent 1px), linear-gradient(90deg, #EFBF04 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Logo mark */}
|
||||
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', marginBottom: 48 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 64, height: 64,
|
||||
border: '2px solid #EFBF04',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<span style={{ fontFamily: FF, fontWeight: 700, fontSize: 22, color: '#EFBF04', letterSpacing: '0.05em' }}>BMP</span>
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: 10, letterSpacing: '0.35em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||
Bayerischer Mittelstandspreis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1,
|
||||
width: '100%', maxWidth: 380,
|
||||
padding: '40px 40px 36px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
animation: shake ? 'bmp-shake 0.5s ease' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ color: 'white', fontSize: 18, fontWeight: 300, marginBottom: 6, letterSpacing: '0.01em' }}>
|
||||
Zugang
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: 12 }}>
|
||||
Bitte melden Sie sich an, um fortzufahren.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user}
|
||||
onChange={e => { setUser(e.target.value); setError(''); }}
|
||||
autoComplete="username"
|
||||
style={{
|
||||
width: '100%', padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: `1px solid ${error ? '#e53e3e' : 'rgba(255,255,255,0.15)'}`,
|
||||
color: 'white', fontSize: 14, outline: 'none',
|
||||
fontFamily: FF, boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<label style={{ display: 'block', fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={e => { setPass(e.target.value); setError(''); }}
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
width: '100%', padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: `1px solid ${error ? '#e53e3e' : 'rgba(255,255,255,0.15)'}`,
|
||||
color: 'white', fontSize: 14, outline: 'none',
|
||||
fontFamily: FF, boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginBottom: 16, color: '#fc8181', fontSize: 12 }}>{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: '100%', padding: '14px',
|
||||
background: '#EFBF04', border: 'none', cursor: 'pointer',
|
||||
color: '#111D55', fontFamily: FF, fontSize: 12, fontWeight: 700,
|
||||
letterSpacing: '0.2em', textTransform: 'uppercase',
|
||||
transition: 'background 0.15s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '#EFBF04'; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, marginTop: 32, color: 'rgba(255,255,255,0.2)', fontSize: 10, letterSpacing: '0.2em' }}>
|
||||
© {new Date().getFullYear()} New Edge Brand
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes bmp-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-6px); }
|
||||
80% { transform: translateX(6px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/Logo/Logo.tsx
Normal file
29
src/components/Logo/Logo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
loading?: 'lazy' | 'eager'
|
||||
priority?: 'auto' | 'high' | 'low'
|
||||
}
|
||||
|
||||
export const Logo = (props: Props) => {
|
||||
const { loading: loadingFromProps, priority: priorityFromProps, className } = props
|
||||
|
||||
const loading = loadingFromProps || 'lazy'
|
||||
const priority = priorityFromProps || 'low'
|
||||
|
||||
return (
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
<img
|
||||
alt="Payload Logo"
|
||||
width={193}
|
||||
height={34}
|
||||
loading={loading}
|
||||
fetchPriority={priority}
|
||||
decoding="async"
|
||||
className={clsx('max-w-[9.375rem] w-full h-[34px]', className)}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/3.x/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
105
src/components/Media/ImageMedia/index.tsx
Normal file
105
src/components/Media/ImageMedia/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import type { StaticImageData } from 'next/image'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import NextImage from 'next/image'
|
||||
import React from 'react'
|
||||
|
||||
import type { Props as MediaProps } from '../types'
|
||||
|
||||
import { cssVariables } from '@/cssVariables'
|
||||
import { getMediaUrl } from '@/utilities/getMediaUrl'
|
||||
|
||||
const { breakpoints } = cssVariables
|
||||
|
||||
// A base64 encoded image to use as a placeholder while the image is loading
|
||||
const placeholderBlur =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABchJREFUWEdtlwtTG0kMhHtGM7N+AAdcDsjj///EBLzenbtuadbLJaZUTlHB+tRqSesETB3IABqQG1KbUFqDlQorBSmboqeEBcC1d8zrCixXYGZcgMsFmH8B+AngHdurAmXKOE8nHOoBrU6opcGswPi5KSP9CcBaQ9kACJH/ALAA1xm4zMD8AczvQCcAQeJVAZsy7nYApTSUzwCHUKACeUJi9TsFci7AHmDtuHYqQIC9AgQYKnSwNAig4NyOOwXq/xU47gDYggarjIpsRSEA3Fqw7AGkwgW4fgALAdiC2btKgNZwbgdMbEFpqFR2UyCR8xwAhf8bUHIGk1ckMyB5C1YkeWAdAPQBAeiD6wVYPoD1HUgXwFagZAGc6oSpTmilopoD5GzISQD3odcNIFca0BUQQM5YA2DpHV0AYURBDIAL0C+ugC0C4GedSsVUmwC8/4w8TPiwU6AClJ5RWL1PgQNkrABWdKB3YF3cBwRY5lsI4ApkKpCQi+FIgFJU/TDgDuAxAAwonJuKpGD1rkCXCR1ALyrAUSSEQAhwBdYZ6DPAgSUA2c1wKIZmRcHxMzMYR9DH8NlbkAwwApSAcABwBwTAbb6owAr0AFiZPILVEyCtMmK2jCkTwFDNUNj7nJETQx744gCUmgkZVGJUHyakEZE4W91jtGFA9KsD8Z3JFYDlhGYZLWcllwJMnplcPy+csFAgAAaIDOgeuAGoB96GLZg4kmtfMjnr6ig5oSoySsoy3ya/FMivXZWxwr0KIf9nACbfqcBEgmBSAtAlIT83R+70IWpyACamIjf5E1Iqb9ECVmnoI/FvAIRk8s2J0Y5IquQDgB+5wpScw5AUTC75VTmTs+72NUzoCvQIaAXv5Q8PDAZKLD+MxLv3RFE7KlsQChgBIlKiCv5ByaZv3gJZNm8AnVMhAN+EjrtTYQMICJpu6/0aiQnhClANlz+Bw0cIWa8ev0sBrtrhAyaXEnrfGfATQJiRKih5vKeOHNXXPFrgyamAADh0Q4F2/sESojomDS9o9k0b0H83xjB8qL+JNoTjN+enjpaBpingRh4e8MSugudM030A8FeqMI6PFIgNyPehkpZWGFEAARIQdH5LcAAqIACHkAJqg4OoBccHAuz76wr4BbzFOEa8iBuAZB8AtJHLP2VgMgJw/EIBowo7HxCAH3V6dAXEE/vZ5aZIA8BP8RKhm7Cp8BnAMnAQADdgQDA520AVIpScP+enHz0Gwp25h4i2dPg5FkDXrbsdJikQwXuWgaM5gEMk1AgH4DKKFjDf3bMD+FjEeIxLlRKYnBk2BbquvSDCAQ4gwZiMAAmH4gBTyRtEsYxi7gP6QSrc//39BrDNqG8rtYTmC4BV1SfMhOhaumFCT87zy4pPhQBZEK1kQVRjJBBi7AOlePgyAPYjwlvtagx9e/dnQraAyS894TIkkAIEYMKEc8k4EqJ68lZ5jjNqcQC2QteQOf7659umwBgPybNtK4dg9WvnMyFwXYGP7uEO1lwJgAnPNeMYMVXbIIYKFioI4PGFt+BWPVfmWJdjW2lTUnLGCswECAgaUy86iwA1464ajo0QhgMBFGyBoZahANsMpMfXr1JA1SN29m5lqgXj+UPV85uRA7yv/KYUO4Tk7Hc1AZwbIRzg0AyNj2UlAMwfSLSMnl7fdAbcxHuA27YaAMvaQ4GOjwX4RTUGAG8Ge14N963g1AynqUiFqRX9noasxT4b8entNRQYyamk/3tYcHsO7R3XJRRYOn4tw4iUnwBM5gDnySGOreAwAGo8F9IDHEcq8Pz2Kg/oXCpuIL6tOPD8LsDn0ABYQoGFRowlsAEUPPDrGAGowAbgKsgDMmE8mDy/vXQ9IAwI7u4wta+gAdAdgB64Ah9SgD4IgGKhwACoAjgNgFDhtxY8f33ZTMjqdTAiHMBPrn8ZWkEfzFdX4Oc1AHg3+ADbvN8PU8WdFKg4Tt6CQy2+D4YHaMT/JP4XzbAq98cPDIUAAAAASUVORK5CYII='
|
||||
|
||||
/**
|
||||
* ImageMedia
|
||||
*
|
||||
* This component passes a **relative** `src` (e.g. `/media/...`) to Next.js Image.
|
||||
* The `getMediaUrl` utility constructs the full URL by prepending the base URL from env vars
|
||||
* (NEXT_PUBLIC_SERVER_URL). Next.js then optimizes this using `remotePatterns` configured
|
||||
* in next.config.js — no custom `loader` needed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Resource URL from Payload: `/media/image-123.jpg`
|
||||
* 2. getMediaUrl() adds base URL: `https://yourdomain.com/media/image-123.jpg`
|
||||
* 3. Next.js Image optimizes via remotePatterns: `/_next/image?url=...&w=1200&q=75`
|
||||
*
|
||||
* If your storage/plugin returns **external CDN URLs** (e.g. `https://cdn.example.com/...`),
|
||||
* choose ONE of the following:
|
||||
* A) Allow the remote host in next.config.js:
|
||||
* images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }
|
||||
* B) Provide a **custom loader** for CDN-specific transforms:
|
||||
* const imageLoader: ImageLoader = ({ src, width, quality }) =>
|
||||
* `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`
|
||||
* <Image loader={imageLoader} src="/media/hero.jpg" width={1200} height={600} alt="" />
|
||||
* C) Skip optimization:
|
||||
* <Image unoptimized src="https://cdn.example.com/hero.jpg" width={1200} height={600} alt="" />
|
||||
*
|
||||
* TL;DR: Template uses relative URLs + getMediaUrl() to construct full URLs, then relies on
|
||||
* remotePatterns for optimization. Only add `loader` if using external CDNs with custom transforms.
|
||||
*/
|
||||
|
||||
export const ImageMedia: React.FC<MediaProps> = (props) => {
|
||||
const {
|
||||
alt: altFromProps,
|
||||
fill,
|
||||
pictureClassName,
|
||||
imgClassName,
|
||||
priority,
|
||||
resource,
|
||||
size: sizeFromProps,
|
||||
src: srcFromProps,
|
||||
loading: loadingFromProps,
|
||||
} = props
|
||||
|
||||
let width: number | undefined
|
||||
let height: number | undefined
|
||||
let alt = altFromProps
|
||||
let src: StaticImageData | string = srcFromProps || ''
|
||||
|
||||
if (!src && resource && typeof resource === 'object') {
|
||||
const { alt: altFromResource, height: fullHeight, url, width: fullWidth } = resource
|
||||
|
||||
width = fullWidth!
|
||||
height = fullHeight!
|
||||
alt = altFromResource || ''
|
||||
|
||||
const cacheTag = resource.updatedAt
|
||||
|
||||
src = getMediaUrl(url, cacheTag)
|
||||
}
|
||||
|
||||
const loading = loadingFromProps || (!priority ? 'lazy' : undefined)
|
||||
|
||||
// NOTE: this is used by the browser to determine which image to download at different screen sizes
|
||||
const sizes = sizeFromProps
|
||||
? sizeFromProps
|
||||
: Object.entries(breakpoints)
|
||||
.map(([, value]) => `(max-width: ${value}px) ${value * 2}w`)
|
||||
.join(', ')
|
||||
|
||||
return (
|
||||
<picture className={cn(pictureClassName)}>
|
||||
<NextImage
|
||||
alt={alt || ''}
|
||||
className={cn(imgClassName)}
|
||||
fill={fill}
|
||||
height={!fill ? height : undefined}
|
||||
placeholder="blur"
|
||||
blurDataURL={placeholderBlur}
|
||||
priority={priority}
|
||||
quality={100}
|
||||
loading={loading}
|
||||
sizes={sizes}
|
||||
src={src}
|
||||
width={!fill ? width : undefined}
|
||||
/>
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
46
src/components/Media/VideoMedia/index.tsx
Normal file
46
src/components/Media/VideoMedia/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
import type { Props as MediaProps } from '../types'
|
||||
|
||||
import { getMediaUrl } from '@/utilities/getMediaUrl'
|
||||
|
||||
export const VideoMedia: React.FC<MediaProps> = (props) => {
|
||||
const { onClick, resource, videoClassName } = props
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
// const [showFallback] = useState<boolean>()
|
||||
|
||||
useEffect(() => {
|
||||
const { current: video } = videoRef
|
||||
if (video) {
|
||||
video.addEventListener('suspend', () => {
|
||||
// setShowFallback(true);
|
||||
// console.warn('Video was suspended, rendering fallback image.')
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (resource && typeof resource === 'object') {
|
||||
const { filename } = resource
|
||||
|
||||
return (
|
||||
<video
|
||||
autoPlay
|
||||
className={cn(videoClassName)}
|
||||
controls={false}
|
||||
loop
|
||||
muted
|
||||
onClick={onClick}
|
||||
playsInline
|
||||
ref={videoRef}
|
||||
>
|
||||
<source src={getMediaUrl(`/media/${filename}`)} />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
25
src/components/Media/index.tsx
Normal file
25
src/components/Media/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Props } from './types'
|
||||
|
||||
import { ImageMedia } from './ImageMedia'
|
||||
import { VideoMedia } from './VideoMedia'
|
||||
|
||||
export const Media: React.FC<Props> = (props) => {
|
||||
const { className, htmlElement = 'div', resource } = props
|
||||
|
||||
const isVideo = typeof resource === 'object' && resource?.mimeType?.includes('video')
|
||||
const Tag = htmlElement || Fragment
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(htmlElement !== null
|
||||
? {
|
||||
className,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{isVideo ? <VideoMedia {...props} /> : <ImageMedia {...props} />}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
22
src/components/Media/types.ts
Normal file
22
src/components/Media/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { StaticImageData } from 'next/image'
|
||||
import type { ElementType, Ref } from 'react'
|
||||
|
||||
import type { Media as MediaType } from '@/payload-types'
|
||||
|
||||
export interface Props {
|
||||
alt?: string
|
||||
className?: string
|
||||
fill?: boolean // for NextImage only
|
||||
htmlElement?: ElementType | null
|
||||
pictureClassName?: string
|
||||
imgClassName?: string
|
||||
onClick?: () => void
|
||||
onLoad?: () => void
|
||||
loading?: 'lazy' | 'eager' // for NextImage only
|
||||
priority?: boolean // for NextImage only
|
||||
ref?: Ref<HTMLImageElement | HTMLVideoElement | null>
|
||||
resource?: MediaType | string | number | null // for Payload media
|
||||
size?: string // for NextImage only
|
||||
src?: StaticImageData // for static media
|
||||
videoClassName?: string
|
||||
}
|
||||
57
src/components/PageRange/index.tsx
Normal file
57
src/components/PageRange/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
|
||||
const defaultLabels = {
|
||||
plural: 'Docs',
|
||||
singular: 'Doc',
|
||||
}
|
||||
|
||||
const defaultCollectionLabels = {
|
||||
posts: {
|
||||
plural: 'Posts',
|
||||
singular: 'Post',
|
||||
},
|
||||
}
|
||||
|
||||
export const PageRange: React.FC<{
|
||||
className?: string
|
||||
collection?: keyof typeof defaultCollectionLabels
|
||||
collectionLabels?: {
|
||||
plural?: string
|
||||
singular?: string
|
||||
}
|
||||
currentPage?: number
|
||||
limit?: number
|
||||
totalDocs?: number
|
||||
}> = (props) => {
|
||||
const {
|
||||
className,
|
||||
collection,
|
||||
collectionLabels: collectionLabelsFromProps,
|
||||
currentPage,
|
||||
limit,
|
||||
totalDocs,
|
||||
} = props
|
||||
|
||||
let indexStart = (currentPage ? currentPage - 1 : 1) * (limit || 1) + 1
|
||||
if (totalDocs && indexStart > totalDocs) indexStart = 0
|
||||
|
||||
let indexEnd = (currentPage || 1) * (limit || 1)
|
||||
if (totalDocs && indexEnd > totalDocs) indexEnd = totalDocs
|
||||
|
||||
const { plural, singular } =
|
||||
collectionLabelsFromProps ||
|
||||
(collection ? defaultCollectionLabels[collection] : undefined) ||
|
||||
defaultLabels ||
|
||||
{}
|
||||
|
||||
return (
|
||||
<div className={[className, 'font-semibold'].filter(Boolean).join(' ')}>
|
||||
{(typeof totalDocs === 'undefined' || totalDocs === 0) && 'Search produced no results.'}
|
||||
{typeof totalDocs !== 'undefined' &&
|
||||
totalDocs > 0 &&
|
||||
`Showing ${indexStart}${indexStart > 0 ? ` - ${indexEnd}` : ''} of ${totalDocs} ${
|
||||
totalDocs > 1 ? plural : singular
|
||||
}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
src/components/Pagination/index.tsx
Normal file
101
src/components/Pagination/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
import {
|
||||
Pagination as PaginationComponent,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
export const Pagination: React.FC<{
|
||||
className?: string
|
||||
page: number
|
||||
totalPages: number
|
||||
}> = (props) => {
|
||||
const router = useRouter()
|
||||
|
||||
const { className, page, totalPages } = props
|
||||
const hasNextPage = page < totalPages
|
||||
const hasPrevPage = page > 1
|
||||
|
||||
const hasExtraPrevPages = page - 1 > 1
|
||||
const hasExtraNextPages = page + 1 < totalPages
|
||||
|
||||
return (
|
||||
<div className={cn('my-12', className)}>
|
||||
<PaginationComponent>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => {
|
||||
router.push(`/posts/page/${page - 1}`)
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{hasExtraPrevPages && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
{hasPrevPage && (
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => {
|
||||
router.push(`/posts/page/${page - 1}`)
|
||||
}}
|
||||
>
|
||||
{page - 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
isActive
|
||||
onClick={() => {
|
||||
router.push(`/posts/page/${page}`)
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
|
||||
{hasNextPage && (
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => {
|
||||
router.push(`/posts/page/${page + 1}`)
|
||||
}}
|
||||
>
|
||||
{page + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
{hasExtraNextPages && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => {
|
||||
router.push(`/posts/page/${page + 1}`)
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</PaginationComponent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/components/PayloadRedirects/index.tsx
Normal file
48
src/components/PayloadRedirects/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type React from 'react'
|
||||
import type { Page, Post } from '@/payload-types'
|
||||
|
||||
import { getCachedDocument } from '@/utilities/getDocument'
|
||||
import { getCachedRedirects } from '@/utilities/getRedirects'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
interface Props {
|
||||
disableNotFound?: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
/* This component helps us with SSR based dynamic redirects */
|
||||
export const PayloadRedirects: React.FC<Props> = async ({ disableNotFound, url }) => {
|
||||
const redirects = await getCachedRedirects()()
|
||||
|
||||
const redirectItem = redirects.find((redirect) => redirect.from === url)
|
||||
|
||||
if (redirectItem) {
|
||||
if (redirectItem.to?.url) {
|
||||
redirect(redirectItem.to.url)
|
||||
}
|
||||
|
||||
let redirectUrl: string
|
||||
|
||||
if (typeof redirectItem.to?.reference?.value === 'string') {
|
||||
const collection = redirectItem.to?.reference?.relationTo
|
||||
const id = redirectItem.to?.reference?.value
|
||||
|
||||
const document = (await getCachedDocument(collection, id)()) as Page | Post
|
||||
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
|
||||
document?.slug
|
||||
}`
|
||||
} else {
|
||||
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
|
||||
typeof redirectItem.to?.reference?.value === 'object'
|
||||
? redirectItem.to?.reference?.value?.slug
|
||||
: ''
|
||||
}`
|
||||
}
|
||||
|
||||
if (redirectUrl) redirect(redirectUrl)
|
||||
}
|
||||
|
||||
if (disableNotFound) return null
|
||||
|
||||
notFound()
|
||||
}
|
||||
81
src/components/RichText/index.tsx
Normal file
81
src/components/RichText/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||
import {
|
||||
DefaultNodeTypes,
|
||||
SerializedBlockNode,
|
||||
SerializedLinkNode,
|
||||
type DefaultTypedEditorState,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
import {
|
||||
JSXConvertersFunction,
|
||||
LinkJSXConverter,
|
||||
RichText as ConvertRichText,
|
||||
} from '@payloadcms/richtext-lexical/react'
|
||||
|
||||
import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component'
|
||||
|
||||
import type {
|
||||
BannerBlock as BannerBlockProps,
|
||||
CallToActionBlock as CTABlockProps,
|
||||
MediaBlock as MediaBlockProps,
|
||||
} from '@/payload-types'
|
||||
import { BannerBlock } from '@/blocks/Banner/Component'
|
||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||
import { cn } from '@/utilities/ui'
|
||||
|
||||
type NodeTypes =
|
||||
| DefaultNodeTypes
|
||||
| SerializedBlockNode<CTABlockProps | MediaBlockProps | BannerBlockProps | CodeBlockProps>
|
||||
|
||||
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
|
||||
const { value, relationTo } = linkNode.fields.doc!
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('Expected value to be an object')
|
||||
}
|
||||
const slug = value.slug
|
||||
return relationTo === 'posts' ? `/posts/${slug}` : `/${slug}`
|
||||
}
|
||||
|
||||
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
...LinkJSXConverter({ internalDocToHref }),
|
||||
blocks: {
|
||||
banner: ({ node }) => <BannerBlock className="col-start-2 mb-4" {...node.fields} />,
|
||||
mediaBlock: ({ node }) => (
|
||||
<MediaBlock
|
||||
className="col-start-1 col-span-3"
|
||||
imgClassName="m-0"
|
||||
{...node.fields}
|
||||
captionClassName="mx-auto max-w-[48rem]"
|
||||
enableGutter={false}
|
||||
disableInnerContainer={true}
|
||||
/>
|
||||
),
|
||||
code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,
|
||||
cta: ({ node }) => <CallToActionBlock {...node.fields} />,
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
data: DefaultTypedEditorState
|
||||
enableGutter?: boolean
|
||||
enableProse?: boolean
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export default function RichText(props: Props) {
|
||||
const { className, enableProse = true, enableGutter = true, ...rest } = props
|
||||
return (
|
||||
<ConvertRichText
|
||||
converters={jsxConverters}
|
||||
className={cn(
|
||||
'payload-richtext',
|
||||
{
|
||||
container: enableGutter,
|
||||
'max-w-none': !enableGutter,
|
||||
'mx-auto prose md:prose-md dark:prose-invert': enableProse,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import MunichSkylineBg from '@/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
export const TESTIMONIALS = [
|
||||
{ img: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=600", name: "Dr. Maria Huber", role: "Geschäftsführerin", company: "TechVision GmbH", year: "2025", quote: "Der BMP hat uns neue Türen geöffnet. Die Auszeichnung ist ein wichtiges Qualitätssiegel für unser gesamtes Team." },
|
||||
{ img: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=600", name: "Hannes Weber", role: "Inhaber & CEO", company: "Weber Präzision GmbH", year: "2023", quote: "Der Gewinn war ein Moment des Stolzes für die gesamte Belegschaft. Die höchste Anerkennung für unsere tägliche Arbeit." },
|
||||
{ img: "https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&q=80&w=600", name: "Lisa Berger", role: "Gründerin & CEO", company: "EcoTech Bavaria", year: "2024", quote: "Das Netzwerk, das wir durch den BMP gewonnen haben, ist unbezahlbar. Wir fanden Partner, die unsere Werte teilen." },
|
||||
{ img: "https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&q=80&w=600", name: "Dr. Michael Schuh", role: "Vorstand", company: "Bayerischer Maschinenbau", year: "2024", quote: "Der Preis hat unsere Sichtbarkeit weit über die bayerischen Grenzen hinaus gestärkt. Ein echtes Premium-Siegel." },
|
||||
{ img: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=600", name: "Anna Kellner", role: "Mitgründerin", company: "AlpinTech Solutions", year: "2024", quote: "Der BMP hat unsere Employer Brand transformiert. Wir gewinnen heute die besten Talente, weil wir Preisträger sind." },
|
||||
];
|
||||
|
||||
export default function TestimonialsSection() {
|
||||
const [active, setActive] = useState(0);
|
||||
const t = TESTIMONIALS[active];
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const prev = () => setActive(i => (i - 1 + TESTIMONIALS.length) % TESTIMONIALS.length);
|
||||
const next = () => setActive(i => (i + 1) % TESTIMONIALS.length);
|
||||
|
||||
return (
|
||||
<section style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
{/* ── MOBILE: headed section + premium testimonial card ── */}
|
||||
{isMobile && (
|
||||
<div style={{ padding: '48px 24px 52px', position: 'relative', zIndex: 1 }}>
|
||||
{/* Section header */}
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>Stimmen der Preisträger</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 8vw, 2.3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 16px' }}>
|
||||
DAS SAGEN UNSERE<br />PREISTRÄGER.
|
||||
</h2>
|
||||
<div style={{ width: 40, height: 2, background: '#EFBF04', marginBottom: 28 }} />
|
||||
|
||||
{/* Card */}
|
||||
<div style={{ background: '#fff', borderRadius: 20, border: '1px solid rgba(17,29,85,0.10)', boxShadow: '0 16px 38px rgba(3,9,58,0.10)', overflow: 'hidden' }}>
|
||||
{/* Photo banner */}
|
||||
<div style={{ position: 'relative', height: 240, overflow: 'hidden' }}>
|
||||
<img key={t.img} src={t.img} alt={t.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'top', display: 'block' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,9,58,0.28) 0%, transparent 45%)' }} />
|
||||
<span style={{ position: 'absolute', top: 14, left: 14, fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: '#101828', background: '#EFBF04', padding: '5px 10px', borderRadius: 100 }}>Preisträger {t.year}</span>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div style={{ padding: '22px 22px 24px' }}>
|
||||
<span aria-hidden style={{ fontFamily: 'Georgia, serif', fontSize: 52, lineHeight: 0.4, color: '#EFBF04', display: 'block', height: 26, marginBottom: 6 }}>“</span>
|
||||
<p style={{ fontFamily: FF, fontSize: '1.2rem', fontWeight: 300, color: '#101828', lineHeight: 1.5, margin: '0 0 22px' }}>{t.quote}</p>
|
||||
<div style={{ width: 36, height: 2, background: '#EFBF04', marginBottom: 16 }} />
|
||||
<div style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#101828', marginBottom: 4 }}>{t.name}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 14, color: 'rgba(16,24,40,0.5)' }}>{t.role} · {t.company}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 24 }}>
|
||||
<button onClick={prev} aria-label="Vorheriges" style={{ width: 46, height: 46, borderRadius: 12, border: '1.5px solid #D0D5DD', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<ChevronRight size={16} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
<button onClick={next} aria-label="Nächstes" style={{ width: 46, height: 46, borderRadius: 12, border: '1.5px solid #111D55', background: '#111D55', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', flexShrink: 0 }}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 6 }}>
|
||||
{TESTIMONIALS.map((_, i) => (
|
||||
<button key={i} onClick={() => setActive(i)} aria-label={`Stimme ${i + 1}`} style={{ width: i === active ? 22 : 7, height: 7, borderRadius: 100, background: i === active ? '#EFBF04' : '#D0D5DD', border: 'none', cursor: 'pointer', padding: 0, transition: 'all 0.2s' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── DESKTOP ── */}
|
||||
{!isMobile && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '42% 58%', height: 560, position: 'relative', zIndex: 1 }}>
|
||||
{/* Left – portrait photo */}
|
||||
<div style={{ position: 'relative', overflow: 'hidden', height: isMobile ? 280 : 'auto' }}>
|
||||
<img
|
||||
key={t.img}
|
||||
src={t.img}
|
||||
alt={t.name}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'top', display: 'block', transition: 'opacity 0.4s' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, background: isMobile ? 'linear-gradient(to bottom, transparent 70%, #fff 100%)' : 'linear-gradient(to right, transparent 60%, #fff 100%)' }} />
|
||||
</div>
|
||||
|
||||
{/* Right – quote */}
|
||||
<div style={{ padding: isMobile ? '40px 22px 56px' : '80px 72px 80px 56px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 14 }}>Stimmen der Preisträger</span>
|
||||
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 2.6vw, 2.6rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 36px' }}>
|
||||
DAS SAGEN UNSERE PREISTRÄGER.
|
||||
</h2>
|
||||
|
||||
<p style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2.2vw, 2rem)', fontWeight: 300, color: '#101828', lineHeight: 1.55, margin: '0 0 40px' }}>
|
||||
{t.quote}
|
||||
</p>
|
||||
|
||||
<div style={{ width: 40, height: 2, background: '#EFBF04', marginBottom: 20 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828', marginBottom: 4 }}>{t.name}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 12, color: 'rgba(16,24,40,0.5)' }}>{t.role} · {t.company}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#4A8FC9', letterSpacing: '0.1em', textTransform: 'uppercase', marginTop: 4 }}>Preisträger {t.year}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 40 }}>
|
||||
<button onClick={prev} style={{ width: isMobile ? 44 : 40, height: isMobile ? 44 : 40, border: '1.5px solid #D0D5DD', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#111D55'; (e.currentTarget as HTMLElement).style.background = '#111D55'; (e.currentTarget as HTMLElement).style.color = '#fff'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = '#D0D5DD'; (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = 'inherit'; }}
|
||||
>
|
||||
<ChevronRight size={16} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
<button onClick={next} style={{ width: isMobile ? 44 : 40, height: isMobile ? 44 : 40, border: '1.5px solid #111D55', background: '#111D55', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s', color: '#fff' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#EFBF04'; (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '#111D55'; (e.currentTarget as HTMLElement).style.borderColor = '#111D55'; }}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 8 }}>
|
||||
{TESTIMONIALS.map((_, i) => (
|
||||
<button key={i} onClick={() => setActive(i)} style={{ width: i === active ? 20 : 6, height: 6, background: i === active ? '#EFBF04' : '#D0D5DD', border: 'none', cursor: 'pointer', padding: 0, transition: 'all 0.2s' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FileText, Search, UserPlus, Trophy } from 'lucide-react';
|
||||
import {
|
||||
ContainerScroll,
|
||||
ContainerSticky,
|
||||
ProcessCard,
|
||||
ProcessCardBody,
|
||||
ProcessCardTitle,
|
||||
} from '@/components/ui/process-timeline';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
|
||||
const STEPS = [
|
||||
{ step: '01', title: 'Online-Einreichung', desc: 'Bewerbungsformular vollständig ausfüllen. Die Teilnahme ist kostenfrei und ohne bürokratischen Aufwand möglich.', Icon: FileText, date: 'Bis 30. Juni 2026' },
|
||||
{ step: '02', title: 'Formale Vorprüfung', desc: 'Das Gremium sichtet alle Unterlagen auf Vollständigkeit, KMU-Konformität und regionale Zugehörigkeit im Freistaat.', Icon: Search, date: 'Juli 2026' },
|
||||
{ step: '03', title: 'Audit & Jury-Sitzung',desc: 'Die besten Unternehmen werden durch unabhängige Experten-Audits vor Ort evaluiert und in der Jury-Sitzung bewertet.', Icon: UserPlus, date: 'Aug – Sep 2026' },
|
||||
{ step: '04', title: 'Gala-Preisverleihung',desc: 'Die Gewinner werden im Rahmen der feierlichen Gala in München vor geladenen Gästen aus Wirtschaft und Politik geehrt.', Icon: Trophy, date: 'Oktober 2026' },
|
||||
];
|
||||
|
||||
export default function WegZurAuszeichnungSection() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
|
||||
if (isMobile) {
|
||||
return (
|
||||
<section id="schritte" style={{ background: NAVY, padding: '56px 22px' }}>
|
||||
{/* Section header */}
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>Schritt für Schritt</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.05, margin: '0 0 16px' }}>
|
||||
IHR WEG ZUM<br />PREIS.
|
||||
</h2>
|
||||
<p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, margin: 0 }}>
|
||||
Von der Einreichung bis zur Gala – vier klar definierte Schritte auf dem Weg zur höchsten Auszeichnung des bayerischen Mittelstands.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stacked step cards */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{STEPS.map((item, index) => {
|
||||
const Icon = item.Icon;
|
||||
return (
|
||||
<div key={item.step} style={{ border: '1px solid rgba(239,191,4,0.25)', background: '#111D55', padding: '24px 22px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{ width: 44, height: 44, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.18em', textTransform: 'uppercase' }}>Schritt {item.step}</span>
|
||||
<span style={{ width: 1, height: 11, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.06em' }}>{item.date}</span>
|
||||
</div>
|
||||
<h3 style={{ fontFamily: FF, fontSize: '1.35rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.68)', lineHeight: 1.7, margin: 0 }}>
|
||||
{item.desc}
|
||||
</p>
|
||||
{/* Step progress indicator */}
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 20 }}>
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerScroll id="schritte" className="h-[380vh]" style={{ background: NAVY }}>
|
||||
<ContainerSticky className="top-0 h-screen flex flex-col">
|
||||
|
||||
{/* Section header */}
|
||||
<div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)', flexShrink: 0 }}>
|
||||
<div>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>Schritt für Schritt</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
|
||||
IHR WEG ZUM<br />PREIS.
|
||||
</h2>
|
||||
</div>
|
||||
<p style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.65)', lineHeight: 1.8, margin: 0 }}>
|
||||
Von der Einreichung bis zur Gala – vier klar definierte Schritte auf dem Weg zur höchsten Auszeichnung des bayerischen Mittelstands.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scroll hint – prominent */}
|
||||
<div style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||||
{/* Animated chevron stack */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<svg
|
||||
key={i}
|
||||
width="12" height="7" viewBox="0 0 12 7" fill="none"
|
||||
style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
|
||||
>
|
||||
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)' }}>
|
||||
Scrollen zum Erkunden
|
||||
</span>
|
||||
{/* Progress bar */}
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: GOLD, opacity: 0.6 }} />
|
||||
</div>
|
||||
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>01 / 04</span>
|
||||
</div>
|
||||
|
||||
{/* Cards strip */}
|
||||
<div className="flex flex-nowrap flex-1 items-stretch px-20 pb-16 gap-0">
|
||||
{STEPS.map((item, index) => {
|
||||
const Icon = item.Icon;
|
||||
return (
|
||||
<ProcessCard
|
||||
key={item.step}
|
||||
itemsLength={4}
|
||||
index={index}
|
||||
variant="bmp"
|
||||
className="min-w-[70%] max-w-[70%] flex-shrink-0"
|
||||
>
|
||||
{/* Left accent strip with step number */}
|
||||
<ProcessCardTitle className="border-r border-[rgba(239,191,4,0.2)] flex flex-col items-center justify-between py-8 px-5 min-w-[80px]">
|
||||
<div style={{ width: 40, height: 40, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon size={16} style={{ color: GOLD }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 900, color: 'rgba(239,191,4,0.4)', letterSpacing: '0.1em', writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
||||
{item.step}
|
||||
</span>
|
||||
</ProcessCardTitle>
|
||||
|
||||
{/* Card body */}
|
||||
<ProcessCardBody className="flex flex-col justify-between py-10 px-10 gap-6">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.22em', textTransform: 'uppercase' }}>
|
||||
Schritt {item.step}
|
||||
</span>
|
||||
<span style={{ width: 1, height: 12, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.08em' }}>{item.date}</span>
|
||||
</div>
|
||||
<div style={{ width: 32, height: 1, background: GOLD, marginBottom: 20, opacity: 0.5 }} />
|
||||
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.5rem, 2.5vw, 2.2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.68)', lineHeight: 1.75, margin: 0 }}>
|
||||
{item.desc}
|
||||
</p>
|
||||
{/* Step progress indicator */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0,1,2,3].map(i => (
|
||||
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)', transition: 'flex 0.3s' }} />
|
||||
))}
|
||||
</div>
|
||||
</ProcessCardBody>
|
||||
</ProcessCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContainerSticky>
|
||||
|
||||
<style>{`
|
||||
@keyframes scrollBounce {
|
||||
0%, 100% { transform: translateY(0); opacity: inherit; }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
`}</style>
|
||||
</ContainerScroll>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,610 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, ArrowLeft, Check, Trophy, User, Send, Building2, AlertCircle } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
/* ── Theme definitions ─────────────────────────────────────────────────── */
|
||||
|
||||
const DARK = {
|
||||
outerBg: 'transparent',
|
||||
outerBgImage: 'none',
|
||||
border: 'rgba(255,255,255,0.12)',
|
||||
borderActive: 'rgba(239,191,4,0.8)',
|
||||
progressBg: 'rgba(255,255,255,0.1)',
|
||||
progressFill: 'linear-gradient(to right,#EFBF04,rgba(239,191,4,0.75))',
|
||||
progressGlow: '0 0 8px rgba(239,191,4,0.6)',
|
||||
stepActive: '#fff',
|
||||
stepDone: '#EFBF04',
|
||||
stepInactive: 'rgba(255,255,255,0.25)',
|
||||
stepActiveBg: 'rgba(239,191,4,0.07)',
|
||||
stepUnderline: '#EFBF04',
|
||||
accentLabel: 'rgba(239,191,4,0.8)',
|
||||
heading: '#fff',
|
||||
inputBg: 'rgba(255,255,255,0.05)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.08)',
|
||||
inputBorder: 'rgba(255,255,255,0.18)',
|
||||
inputText: '#fff',
|
||||
inputPlaceholder: 'rgba(255,255,255,0.35)',
|
||||
fieldLabel: 'rgba(255,255,255,0.45)',
|
||||
fieldError: '#f87171',
|
||||
cardBg: 'rgba(255,255,255,0.06)',
|
||||
cardBgSelected: 'rgba(239,191,4,0.1)',
|
||||
cardBorder: 'rgba(255,255,255,0.22)',
|
||||
cardBorderSel: '#EFBF04',
|
||||
cardText: '#fff',
|
||||
cardTextSel: '#EFBF04',
|
||||
cardDesc: 'rgba(255,255,255,0.65)',
|
||||
optBg: 'rgba(255,255,255,0.05)',
|
||||
optBgSel: 'rgba(239,191,4,0.12)',
|
||||
optBorder: 'rgba(255,255,255,0.22)',
|
||||
optBorderSel: '#EFBF04',
|
||||
optText: 'rgba(255,255,255,0.7)',
|
||||
optTextSel: '#EFBF04',
|
||||
bodyText: 'rgba(255,255,255,0.6)',
|
||||
mutedText: 'rgba(255,255,255,0.35)',
|
||||
rowLabel: 'rgba(255,255,255,0.35)',
|
||||
rowValue: '#fff',
|
||||
alertIcon: '#EFBF04',
|
||||
alertTitle: '#EFBF04',
|
||||
alertEmail: '#EFBF04',
|
||||
btnBg: '#EFBF04',
|
||||
btnBgHover: '#FFD130',
|
||||
btnText: '#101828',
|
||||
backText: 'rgba(255,255,255,0.35)',
|
||||
backTextHover: '#fff',
|
||||
successIcon: '#EFBF04',
|
||||
successIconInner: '#111D55',
|
||||
successHeading: '#fff',
|
||||
successBody: 'rgba(255,255,255,0.55)',
|
||||
successName: '#fff',
|
||||
successAccent: '#EFBF04',
|
||||
};
|
||||
|
||||
const GOLD = {
|
||||
outerBg: 'transparent',
|
||||
outerBgImage: 'none',
|
||||
border: 'rgba(17,29,85,0.15)',
|
||||
borderActive: '#111D55',
|
||||
progressBg: 'rgba(17,29,85,0.12)',
|
||||
progressFill: 'linear-gradient(to right,#111D55,#051A87)',
|
||||
progressGlow: 'none',
|
||||
stepActive: '#111D55',
|
||||
stepDone: 'rgba(17,29,85,0.55)',
|
||||
stepInactive: 'rgba(17,29,85,0.28)',
|
||||
stepActiveBg: 'rgba(17,29,85,0.07)',
|
||||
stepUnderline: '#111D55',
|
||||
accentLabel: 'rgba(17,29,85,0.5)',
|
||||
heading: '#111D55',
|
||||
inputBg: 'rgba(255,255,255,0.5)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.9)',
|
||||
inputBorder: 'rgba(17,29,85,0.18)',
|
||||
inputText: '#111D55',
|
||||
inputPlaceholder: 'rgba(17,29,85,0.35)',
|
||||
fieldLabel: 'rgba(17,29,85,0.5)',
|
||||
fieldError: '#b91c1c',
|
||||
cardBg: 'rgba(255,255,255,0.38)',
|
||||
cardBgSelected: 'rgba(17,29,85,0.1)',
|
||||
cardBorder: 'rgba(17,29,85,0.2)',
|
||||
cardBorderSel: '#111D55',
|
||||
cardText: '#111D55',
|
||||
cardTextSel: '#111D55',
|
||||
cardDesc: 'rgba(17,29,85,0.6)',
|
||||
optBg: 'rgba(255,255,255,0.4)',
|
||||
optBgSel: 'rgba(17,29,85,0.1)',
|
||||
optBorder: 'rgba(17,29,85,0.2)',
|
||||
optBorderSel: '#111D55',
|
||||
optText: 'rgba(17,29,85,0.65)',
|
||||
optTextSel: '#111D55',
|
||||
bodyText: 'rgba(17,29,85,0.65)',
|
||||
mutedText: 'rgba(17,29,85,0.4)',
|
||||
rowLabel: 'rgba(17,29,85,0.4)',
|
||||
rowValue: '#111D55',
|
||||
alertIcon: '#111D55',
|
||||
alertTitle: '#111D55',
|
||||
alertEmail: '#111D55',
|
||||
btnBg: '#111D55',
|
||||
btnBgHover: '#051A87',
|
||||
btnText: '#fff',
|
||||
backText: 'rgba(17,29,85,0.4)',
|
||||
backTextHover: '#111D55',
|
||||
successIcon: '#111D55',
|
||||
successIconInner: '#EFBF04',
|
||||
successHeading: '#111D55',
|
||||
successBody: 'rgba(17,29,85,0.6)',
|
||||
successName: '#111D55',
|
||||
successAccent: '#111D55',
|
||||
};
|
||||
|
||||
type C = typeof DARK;
|
||||
|
||||
/* ── Data / Steps ─────────────────────────────────────────────────────── */
|
||||
|
||||
type FormData = {
|
||||
type: 'selbst' | 'vorschlag' | '';
|
||||
mitarbeiter: string;
|
||||
standortBayern: 'ja' | 'nein' | '';
|
||||
familiengeführt: 'ja' | 'nein' | '';
|
||||
firmenname: string;
|
||||
kontaktName: string;
|
||||
email: string;
|
||||
telefon: string;
|
||||
nomFirma: string;
|
||||
nomBranche: string;
|
||||
nomStandort: string;
|
||||
nomName: string;
|
||||
nomEmail: string;
|
||||
nomBeziehung: string;
|
||||
};
|
||||
|
||||
const STEPS_SELBST = [
|
||||
{ label: 'Einreichung', icon: Trophy },
|
||||
{ label: 'Schnell-Check',icon: Check },
|
||||
{ label: 'Kontakt', icon: User },
|
||||
{ label: 'Absenden', icon: Send },
|
||||
];
|
||||
|
||||
const STEPS_VORSCHLAG = [
|
||||
{ label: 'Einreichung', icon: Trophy },
|
||||
{ label: 'Unternehmen', icon: Building2},
|
||||
{ label: 'Nominierender',icon: User },
|
||||
{ label: 'Absenden', icon: Send },
|
||||
];
|
||||
|
||||
const MITARBEITER_OPTIONS = [
|
||||
{ value: 'unter10', label: 'Unter 10' },
|
||||
{ value: '10-50', label: '10 – 50' },
|
||||
{ value: '51-200', label: '51 – 200' },
|
||||
{ value: '201-500', label: '201 – 500'},
|
||||
{ value: 'über500', label: 'Über 500' },
|
||||
];
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -40 : 40, opacity: 0 }),
|
||||
};
|
||||
|
||||
/* ── Sub-components ───────────────────────────────────────────────────── */
|
||||
|
||||
function Field({ label, error, children, optional, c }: {
|
||||
label: string; error?: string; children: React.ReactNode; optional?: boolean; c: C;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: c.fieldLabel }}>
|
||||
{label}{!optional && ' *'}
|
||||
</label>
|
||||
{children}
|
||||
{error && <span style={{ fontFamily: FF, fontSize: 11, color: c.fieldError }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledInput({ c, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { c: C }) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onFocus={e => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur ={e => { setFocused(false); props.onBlur?.(e); }}
|
||||
style={{
|
||||
width: '100%', height: isMobile ? 44 : 38, padding: isMobile ? '0 14px' : '0 12px',
|
||||
fontFamily: FF, fontSize: isMobile ? 16 : 13, color: c.inputText,
|
||||
border: `1px solid ${focused ? c.borderActive : c.inputBorder}`,
|
||||
background: focused ? c.inputBgFocus : c.inputBg,
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s,background 0.15s',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeCard({ selected, onClick, title, desc, c }: {
|
||||
selected: boolean; onClick: () => void; title: string; desc: string; c: C;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
flex: 1, padding: '13px 12px', textAlign: 'left', cursor: 'pointer',
|
||||
border: `1.5px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.cardBgSelected : c.cardBg,
|
||||
fontFamily: FF, transition: 'all 0.15s',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: selected ? c.cardTextSel : c.cardText }}>{title}</span>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', border: `2px solid ${selected ? c.cardBorderSel : c.border}`, background: selected ? c.cardBorderSel : 'transparent', transition: 'all 0.15s' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: c.cardDesc, lineHeight: 1.5 }}>{desc}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionButton({ selected, onClick, label, c, style }: {
|
||||
selected: boolean; onClick: () => void; label: string; c: C; style?: React.CSSProperties;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
padding: isMobile ? '12px 18px' : '8px 16px', minHeight: isMobile ? 44 : undefined,
|
||||
fontFamily: FF, fontSize: isMobile ? 14 : 12, fontWeight: 600, cursor: 'pointer',
|
||||
border: `1.5px solid ${selected ? c.optBorderSel : c.optBorder}`,
|
||||
background: selected ? c.optBgSel : c.optBg,
|
||||
color: selected ? c.optTextSel : c.optText,
|
||||
transition: 'all 0.15s',
|
||||
...style,
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function YesNo({ value, onChange, c }: {
|
||||
value: 'ja' | 'nein' | ''; onChange: (v: 'ja' | 'nein') => void; c: C;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const flexStyle = isMobile ? { flex: 1 } : undefined;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<OptionButton selected={value === 'ja'} onClick={() => onChange('ja')} label="Ja" c={c} style={flexStyle} />
|
||||
<OptionButton selected={value === 'nein'} onClick={() => onChange('nein')} label="Nein" c={c} style={flexStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isEligible(data: FormData) {
|
||||
return ['10-50', '51-200', '201-500'].includes(data.mitarbeiter) && data.standortBayern === 'ja';
|
||||
}
|
||||
|
||||
/* ── Main component ───────────────────────────────────────────────────── */
|
||||
|
||||
export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'gold' }) {
|
||||
const c = theme === 'gold' ? GOLD : DARK;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [data, setData] = useState<FormData>({
|
||||
type: '', mitarbeiter: '', standortBayern: '', familiengeführt: '',
|
||||
firmenname: '', kontaktName: '', email: '', telefon: '',
|
||||
nomFirma: '', nomBranche: '', nomStandort: '',
|
||||
nomName: '', nomEmail: '', nomBeziehung: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
const STEPS = data.type === 'vorschlag' ? STEPS_VORSCHLAG : STEPS_SELBST;
|
||||
const progress = step === 0 ? 0 : (step / (STEPS.length - 1)) * 100;
|
||||
|
||||
const set = (k: keyof FormData, v: string) => {
|
||||
setData(d => ({ ...d, [k]: v }));
|
||||
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: typeof errors = {};
|
||||
if (data.type === 'selbst') {
|
||||
if (step === 1) {
|
||||
if (!data.mitarbeiter) e.mitarbeiter = 'Bitte wählen';
|
||||
if (!data.standortBayern) e.standortBayern = 'Bitte wählen';
|
||||
if (!data.familiengeführt)e.familiengeführt= 'Bitte wählen';
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!data.firmenname) e.firmenname = 'Pflichtfeld';
|
||||
if (!data.kontaktName) e.kontaktName = 'Pflichtfeld';
|
||||
if (!data.email) e.email = 'Pflichtfeld';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Ungültige E-Mail';
|
||||
}
|
||||
} else {
|
||||
if (step === 1) {
|
||||
if (!data.nomFirma) e.nomFirma = 'Pflichtfeld';
|
||||
if (!data.nomBranche) e.nomBranche = 'Pflichtfeld';
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!data.nomName) e.nomName = 'Pflichtfeld';
|
||||
if (!data.nomEmail) e.nomEmail = 'Pflichtfeld';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.nomEmail)) e.nomEmail = 'Ungültige E-Mail';
|
||||
}
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
|
||||
const prev = () => { setDir(-1); setStep(s => s - 1); };
|
||||
const submit = () => { if (validate()) setSubmitted(true); };
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div style={{ fontFamily: FF, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, borderTop: `1px solid ${c.border}`, background: c.outerBg, backgroundImage: c.outerBgImage }}>
|
||||
<div style={{ padding: '48px 28px', textAlign: 'center' }}>
|
||||
<div style={{ width: 52, height: 52, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Check size={22} color={c.successIconInner} strokeWidth={3} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 900, color: c.successHeading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 10 }}>
|
||||
{data.type === 'selbst' ? 'Anfrage eingegangen' : 'Nominierung eingegangen'}
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: c.successBody, maxWidth: 340, margin: '0 auto', lineHeight: 1.7 }}>
|
||||
{data.type === 'selbst'
|
||||
? <><span>Vielen Dank, </span><strong style={{ color: c.successName }}>{data.firmenname}</strong><span>. Wir senden Ihnen den vollständigen Fragebogen an </span><strong style={{ color: c.successAccent }}>{data.email}</strong>.</>
|
||||
: <><span>Vielen Dank, </span><strong style={{ color: c.successName }}>{data.nomName}</strong><span>. Wir nehmen Kontakt mit </span><strong style={{ color: c.successAccent }}>{data.nomFirma}</strong><span> auf und informieren sie über Ihre Nominierung.</span></>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, borderTop: `1px solid ${c.border}`, background: c.outerBg, backgroundImage: c.outerBgImage }}>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{ height: 3, background: c.progressBg, flexShrink: 0 }}>
|
||||
<div style={{ height: '100%', backgroundImage: c.progressFill, width: `${progress}%`, transition: 'width 0.4s ease', boxShadow: c.progressGlow }} />
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const done = i < step;
|
||||
const active = i === step;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1, padding: '8px 4px', textAlign: 'center', fontFamily: FF, fontSize: 9,
|
||||
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
color: active ? c.stepActive : done ? c.stepDone : c.stepInactive,
|
||||
borderBottom: active ? `2px solid ${c.stepUnderline}` : '2px solid transparent',
|
||||
background: active ? c.stepActiveBg : 'transparent',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||
marginBottom: -1, transition: 'color 0.2s',
|
||||
}}>
|
||||
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
|
||||
<span>{done ? '✓' : s.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: isMobile ? '18px 20px' : '20px 28px', flex: 1, minHeight: 0, overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<AnimatePresence mode="wait" custom={dir}>
|
||||
<motion.div key={`${step}-${data.type}`} custom={dir} variants={variants}
|
||||
initial="enter" animate="center" exit="exit"
|
||||
transition={{ duration: 0.22, ease: 'easeInOut' }}>
|
||||
|
||||
{/* ── Step 0: Typ-Wahl ── */}
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>Schritt 1</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 14 }}>Art der Einreichung</h3>
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8 }}>
|
||||
<TypeCard c={c} selected={data.type === 'selbst'} title="Eigenbewerbung"
|
||||
desc="Ich bewerbe mein eigenes Unternehmen für den Bayerischen Mittelstandspreis."
|
||||
onClick={() => { set('type', 'selbst'); setDir(1); setStep(1); }} />
|
||||
<TypeCard c={c} selected={data.type === 'vorschlag'} title="Vorschlag"
|
||||
desc="Ich schlage ein anderes Unternehmen vor, das den Preis verdient."
|
||||
onClick={() => { set('type', 'vorschlag'); setDir(1); setStep(1); }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Eigenbewerbung Step 1: Schnell-Check ── */}
|
||||
{step === 1 && data.type === 'selbst' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Schnell-Check</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Grundlegende Eignung</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: c.fieldLabel }}>Wie viele Mitarbeiter hat Ihr Unternehmen? *</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{MITARBEITER_OPTIONS.map(o => (
|
||||
<OptionButton c={c} key={o.value} selected={data.mitarbeiter === o.value} onClick={() => set('mitarbeiter', o.value)} label={o.label} />
|
||||
))}
|
||||
</div>
|
||||
{errors.mitarbeiter && <span style={{ fontFamily: FF, fontSize: 11, color: c.fieldError }}>{errors.mitarbeiter}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: c.fieldLabel }}>Hat Ihr Unternehmen seinen Sitz in Bayern? *</label>
|
||||
<YesNo c={c} value={data.standortBayern} onChange={v => set('standortBayern', v)} />
|
||||
{errors.standortBayern && <span style={{ fontFamily: FF, fontSize: 11, color: c.fieldError }}>{errors.standortBayern}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: c.fieldLabel }}>Ist Ihr Unternehmen inhabergeführt oder familiengeführt? *</label>
|
||||
<YesNo c={c} value={data.familiengeführt} onChange={v => set('familiengeführt', v)} />
|
||||
{errors.familiengeführt && <span style={{ fontFamily: FF, fontSize: 11, color: c.fieldError }}>{errors.familiengeführt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Eigenbewerbung Step 2: Kontakt / Ineligible ── */}
|
||||
{step === 2 && data.type === 'selbst' && (() => {
|
||||
if (!isEligible(data)) return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<AlertCircle size={18} color={c.alertIcon} />
|
||||
<h3 style={{ fontFamily: FF, fontSize: 15, fontWeight: 800, color: c.alertTitle, textTransform: 'uppercase' }}>Leider nicht förderfähig</h3>
|
||||
</div>
|
||||
<p style={{ fontFamily: FF, fontSize: 13, color: c.bodyText, lineHeight: 1.7 }}>
|
||||
Der Bayerische Mittelstandspreis richtet sich an inhabergeführte Unternehmen mit 10–500 Mitarbeitern und Sitz in Bayern. Ihr Unternehmen erfüllt diese Voraussetzungen aktuell nicht.
|
||||
</p>
|
||||
<p style={{ fontFamily: FF, fontSize: 13, color: c.mutedText, lineHeight: 1.7 }}>
|
||||
Fragen? Schreiben Sie uns: <span style={{ color: c.alertEmail }}>info@bmp-bayern.de</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Kontakt</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ihre Kontaktdaten</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
|
||||
<Field c={c} label="Firmenname" error={errors.firmenname}>
|
||||
<StyledInput c={c} value={data.firmenname} onChange={e => set('firmenname', e.target.value)} placeholder="Muster GmbH" />
|
||||
</Field>
|
||||
<Field c={c} label="Ihr Name" error={errors.kontaktName}>
|
||||
<StyledInput c={c} value={data.kontaktName} onChange={e => set('kontaktName', e.target.value)} placeholder="Vor- und Nachname" />
|
||||
</Field>
|
||||
<Field c={c} label="E-Mail" error={errors.email}>
|
||||
<StyledInput c={c} type="email" value={data.email} onChange={e => set('email', e.target.value)} placeholder="name@unternehmen.de" />
|
||||
</Field>
|
||||
<Field c={c} label="Telefon" optional>
|
||||
<StyledInput c={c} type="tel" value={data.telefon} onChange={e => set('telefon', e.target.value)} placeholder="+49 89 ..." />
|
||||
</Field>
|
||||
</div>
|
||||
<p style={{ fontFamily: FF, fontSize: 12, color: c.mutedText, lineHeight: 1.6 }}>
|
||||
Wir senden Ihnen den vollständigen Fragebogen automatisch per E-Mail zu.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Eigenbewerbung Step 3: Zusammenfassung ── */}
|
||||
{step === 3 && data.type === 'selbst' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Zusammenfassung</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ihre Bewerbung</h3>
|
||||
</div>
|
||||
<div style={{ borderTop: `1px solid ${c.border}` }}>
|
||||
{[
|
||||
{ label: 'Unternehmen', value: data.firmenname },
|
||||
{ label: 'Mitarbeiter', value: MITARBEITER_OPTIONS.find(o => o.value === data.mitarbeiter)?.label || '' },
|
||||
{ label: 'Standort', value: `Bayern: ${data.standortBayern === 'ja' ? 'Ja' : 'Nein'}` },
|
||||
{ label: 'Kontakt', value: `${data.kontaktName} · ${data.email}` },
|
||||
].map(row => (
|
||||
<div key={row.label} style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 12, padding: '10px 0', borderBottom: `1px solid ${c.border}`, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: c.rowLabel, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{row.label}</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 13, color: c.rowValue, overflowWrap: 'anywhere' }}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Vorschlag Step 1: Nominiertes Unternehmen ── */}
|
||||
{step === 1 && data.type === 'vorschlag' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Nominierung</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Nominiertes Unternehmen</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
|
||||
<Field c={c} label="Firmenname" error={errors.nomFirma}>
|
||||
<StyledInput c={c} value={data.nomFirma} onChange={e => set('nomFirma', e.target.value)} placeholder="Muster GmbH" />
|
||||
</Field>
|
||||
<Field c={c} label="Branche" error={errors.nomBranche}>
|
||||
<StyledInput c={c} value={data.nomBranche} onChange={e => set('nomBranche', e.target.value)} placeholder="z. B. Maschinenbau" />
|
||||
</Field>
|
||||
<Field c={c} label="Standort Bayern" optional>
|
||||
<StyledInput c={c} value={data.nomStandort} onChange={e => set('nomStandort', e.target.value)} placeholder="z. B. München" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Vorschlag Step 2: Nominierender ── */}
|
||||
{step === 2 && data.type === 'vorschlag' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Nominierender</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ihre Angaben</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 14 }}>
|
||||
<Field c={c} label="Ihr Name" error={errors.nomName}>
|
||||
<StyledInput c={c} value={data.nomName} onChange={e => set('nomName', e.target.value)} placeholder="Vor- und Nachname" />
|
||||
</Field>
|
||||
<Field c={c} label="Ihre E-Mail" error={errors.nomEmail}>
|
||||
<StyledInput c={c} type="email" value={data.nomEmail} onChange={e => set('nomEmail', e.target.value)} placeholder="ihre@email.de" />
|
||||
</Field>
|
||||
<Field c={c} label="Ihre Beziehung zum Unternehmen" optional>
|
||||
<StyledInput c={c} value={data.nomBeziehung} onChange={e => set('nomBeziehung', e.target.value)} placeholder="z. B. Kunde, Partner, Bekannter" />
|
||||
</Field>
|
||||
</div>
|
||||
<p style={{ fontFamily: FF, fontSize: 12, color: c.mutedText, lineHeight: 1.6 }}>
|
||||
Wir nehmen Kontakt mit dem nominierten Unternehmen auf und informieren es über Ihre Nominierung.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Vorschlag Step 3: Zusammenfassung ── */}
|
||||
{step === 3 && data.type === 'vorschlag' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Zusammenfassung</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ihre Nominierung</h3>
|
||||
</div>
|
||||
<div style={{ borderTop: `1px solid ${c.border}` }}>
|
||||
{[
|
||||
{ label: 'Unternehmen', value: data.nomFirma },
|
||||
{ label: 'Branche', value: data.nomBranche },
|
||||
{ label: 'Standort', value: data.nomStandort || '–' },
|
||||
{ label: 'Nominiert von',value: `${data.nomName} · ${data.nomEmail}` },
|
||||
].map(row => (
|
||||
<div key={row.label} style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: 12, padding: '10px 0', borderBottom: `1px solid ${c.border}`, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: c.rowLabel, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{row.label}</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 13, color: c.rowValue, overflowWrap: 'anywhere' }}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{step > 0 && (
|
||||
<div style={{ padding: isMobile ? '12px 20px' : '12px 28px', borderTop: `1px solid ${c.border}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<button type="button" onClick={prev} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: c.backText, background: 'none',
|
||||
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: isMobile ? '12px 4px' : 0, minHeight: isMobile ? 44 : undefined,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = c.backTextHover)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = c.backText)}
|
||||
>
|
||||
<ArrowLeft size={12} /> Zurück
|
||||
</button>
|
||||
|
||||
{step < STEPS.length - 1 ? (
|
||||
step === 2 && data.type === 'selbst' && !isEligible(data) ? <div /> :
|
||||
<button type="button" onClick={next} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: c.btnText, background: c.btnBg,
|
||||
border: 'none', cursor: 'pointer', padding: isMobile ? '13px 24px' : '10px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 6, transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = c.btnBgHover; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = c.btnBg; }}
|
||||
>
|
||||
Weiter <ArrowRight size={12} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={submit} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: c.btnText, background: c.btnBg,
|
||||
border: 'none', cursor: 'pointer', padding: isMobile ? '13px 24px' : '10px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 6, transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = c.btnBgHover; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = c.btnBg; }}
|
||||
>
|
||||
Absenden <Send size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, ArrowLeft, Check, MessageSquare, User, Send } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
const DARK = {
|
||||
border: 'rgba(255,255,255,0.12)',
|
||||
borderActive: 'rgba(239,191,4,0.8)',
|
||||
progressBg: 'rgba(255,255,255,0.1)',
|
||||
progressFill: '#EFBF04',
|
||||
stepActive: '#fff',
|
||||
stepDone: '#EFBF04',
|
||||
stepInactive: 'rgba(255,255,255,0.25)',
|
||||
stepActiveBg: 'rgba(239,191,4,0.07)',
|
||||
stepUnderline: '#EFBF04',
|
||||
subtext: 'rgba(255,255,255,0.5)',
|
||||
inputBg: 'rgba(255,255,255,0.05)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.09)',
|
||||
inputBorder: 'rgba(255,255,255,0.18)',
|
||||
inputBorderFoc: 'rgba(239,191,4,0.8)',
|
||||
inputText: '#fff',
|
||||
labelText: 'rgba(255,255,255,0.45)',
|
||||
errorText: '#f87171',
|
||||
cardBg: 'rgba(255,255,255,0.06)',
|
||||
cardBgSel: 'rgba(239,191,4,0.1)',
|
||||
cardBorder: 'rgba(255,255,255,0.22)',
|
||||
cardBorderSel: '#EFBF04',
|
||||
cardTitle: '#fff',
|
||||
cardTitleSel: '#EFBF04',
|
||||
cardDesc: 'rgba(255,255,255,0.5)',
|
||||
radioBorder: 'rgba(255,255,255,0.25)',
|
||||
radioBorderSel: '#EFBF04',
|
||||
radioFill: '#EFBF04',
|
||||
radioInner: '#111D55',
|
||||
btnBg: '#EFBF04',
|
||||
btnBgHover: '#FFD130',
|
||||
btnText: '#101828',
|
||||
backText: 'rgba(255,255,255,0.35)',
|
||||
backTextHover: '#fff',
|
||||
successIcon: '#EFBF04',
|
||||
successIconIn: '#111D55',
|
||||
successHeading: '#fff',
|
||||
successBody: 'rgba(255,255,255,0.6)',
|
||||
};
|
||||
|
||||
const GOLD = {
|
||||
border: 'rgba(17,29,85,0.15)',
|
||||
borderActive: '#111D55',
|
||||
progressBg: 'rgba(17,29,85,0.12)',
|
||||
progressFill: '#111D55',
|
||||
stepActive: '#111D55',
|
||||
stepDone: 'rgba(17,29,85,0.55)',
|
||||
stepInactive: 'rgba(17,29,85,0.28)',
|
||||
stepActiveBg: 'rgba(17,29,85,0.07)',
|
||||
stepUnderline: '#111D55',
|
||||
subtext: 'rgba(17,29,85,0.6)',
|
||||
inputBg: 'rgba(255,255,255,0.5)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.9)',
|
||||
inputBorder: 'rgba(17,29,85,0.18)',
|
||||
inputBorderFoc: '#111D55',
|
||||
inputText: '#111D55',
|
||||
labelText: 'rgba(17,29,85,0.5)',
|
||||
errorText: '#b91c1c',
|
||||
cardBg: 'rgba(255,255,255,0.38)',
|
||||
cardBgSel: 'rgba(17,29,85,0.1)',
|
||||
cardBorder: 'rgba(17,29,85,0.2)',
|
||||
cardBorderSel: '#111D55',
|
||||
cardTitle: '#111D55',
|
||||
cardTitleSel: '#111D55',
|
||||
cardDesc: 'rgba(17,29,85,0.55)',
|
||||
radioBorder: 'rgba(17,29,85,0.25)',
|
||||
radioBorderSel: '#111D55',
|
||||
radioFill: '#111D55',
|
||||
radioInner: '#fff',
|
||||
btnBg: '#111D55',
|
||||
btnBgHover: '#051A87',
|
||||
btnText: '#fff',
|
||||
backText: 'rgba(17,29,85,0.4)',
|
||||
backTextHover: '#111D55',
|
||||
successIcon: '#111D55',
|
||||
successIconIn: '#EFBF04',
|
||||
successHeading: '#111D55',
|
||||
successBody: 'rgba(17,29,85,0.65)',
|
||||
};
|
||||
|
||||
type C = typeof DARK;
|
||||
|
||||
type FormData = {
|
||||
betreff: string;
|
||||
name: string;
|
||||
email: string;
|
||||
nachricht: string;
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Betreff', icon: MessageSquare },
|
||||
{ label: 'Kontakt', icon: User },
|
||||
{ label: 'Nachricht', icon: Send },
|
||||
];
|
||||
|
||||
const BETREFFS = [
|
||||
{ value: 'allgemein', label: 'Allgemeine Anfrage', desc: 'Ich habe eine allgemeine Frage.' },
|
||||
{ value: 'bewerbung', label: 'Frage zur Bewerbung', desc: 'Ich möchte mich bewerben oder habe Rückfragen.' },
|
||||
{ value: 'presse', label: 'Presseanfrage', desc: 'Ich bin Journalist:in und recherchiere zum BMP.' },
|
||||
{ value: 'sponsoring', label: 'Sponsoring', desc: 'Ich interessiere mich für eine Partnerschaft.' },
|
||||
];
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -40 : 40, opacity: 0 }),
|
||||
};
|
||||
|
||||
function StyledInput({ c, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { c: C }) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onFocus={e => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur ={e => { setFocused(false); props.onBlur?.(e); }}
|
||||
style={{
|
||||
width: '100%', height: isMobile ? 46 : 42, padding: '0 12px',
|
||||
fontSize: isMobile ? 16 : 14, color: c.inputText, fontFamily: FF,
|
||||
border: `1.5px solid ${focused ? c.inputBorderFoc : c.inputBorder}`,
|
||||
background: focused ? c.inputBgFocus : c.inputBg,
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledTextarea({ c, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { c: C }) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={e => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur ={e => { setFocused(false); props.onBlur?.(e); }}
|
||||
style={{
|
||||
width: '100%', padding: '10px 12px',
|
||||
fontSize: isMobile ? 16 : 14, color: c.inputText, fontFamily: FF,
|
||||
border: `1.5px solid ${focused ? c.inputBorderFoc : c.inputBorder}`,
|
||||
background: focused ? c.inputBgFocus : c.inputBg,
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
resize: 'vertical', minHeight: 100,
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BetreffCard({ c, item, selected, onClick }: { c: C; item: typeof BETREFFS[0]; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button" onClick={onClick}
|
||||
style={{
|
||||
width: '100%', padding: '11px 14px', textAlign: 'left', cursor: 'pointer',
|
||||
border: `1.5px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.cardBgSel : c.cardBg,
|
||||
fontFamily: FF, transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? c.radioBorderSel : c.radioBorder}`,
|
||||
background: selected ? c.radioFill : 'transparent',
|
||||
transition: 'all 0.15s', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{selected && <div style={{ width: 5, height: 5, borderRadius: '50%', background: c.radioInner }} />}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: selected ? c.cardTitleSel : c.cardTitle, marginBottom: 1 }}>{item.label}</div>
|
||||
<div style={{ fontSize: 11, color: c.cardDesc }}>{item.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold' }) {
|
||||
const c = theme === 'gold' ? GOLD : DARK;
|
||||
const isMobile = useIsMobile();
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [data, setData] = useState<FormData>({ betreff: 'allgemein', name: '', email: '', nachricht: '' });
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
const set = (k: keyof FormData, v: string) => {
|
||||
setData(d => ({ ...d, [k]: v }));
|
||||
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const e: typeof errors = {};
|
||||
if (step === 1) {
|
||||
if (!data.name) e.name = 'Bitte Namen angeben';
|
||||
if (!data.email) e.email = 'Bitte E-Mail angeben';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Ungültige E-Mail';
|
||||
}
|
||||
if (step === 2 && !data.nachricht) e.nachricht = 'Bitte Nachricht verfassen.';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
|
||||
const prev = () => { setDir(-1); setStep(s => s - 1); };
|
||||
const submit = () => { if (validate()) setSubmitted(true); };
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
|
||||
style={{ padding: '48px 24px', textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ width: 56, height: 56, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Check size={24} color={c.successIconIn} strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 800, color: c.successHeading, marginBottom: 10, letterSpacing: '-0.02em', fontFamily: FF }}>
|
||||
Nachricht gesendet
|
||||
</h3>
|
||||
<p style={{ fontSize: 15, color: c.successBody, lineHeight: 1.7, maxWidth: 280, margin: '0 auto', fontFamily: FF, overflowWrap: 'anywhere' }}>
|
||||
Danke, <strong style={{ color: c.successHeading }}>{data.name}</strong>. Wir melden uns zeitnah bei{' '}
|
||||
<strong style={{ color: c.successIcon }}>{data.email}</strong>.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{/* Progress */}
|
||||
<div style={{ height: 3, background: c.progressBg, flexShrink: 0 }}>
|
||||
<div style={{ height: '100%', background: c.progressFill, width: step === 0 ? '0%' : `${(step / (STEPS.length - 1)) * 100}%`, transition: 'width 0.4s ease' }} />
|
||||
</div>
|
||||
|
||||
{/* Step tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const done = i < step, active = i === step;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1, padding: '8px 4px', textAlign: 'center', fontFamily: FF, fontSize: 9,
|
||||
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
color: active ? c.stepActive : done ? c.stepDone : c.stepInactive,
|
||||
borderBottom: active ? `2px solid ${c.stepUnderline}` : '2px solid transparent',
|
||||
background: active ? c.stepActiveBg : 'transparent',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||
marginBottom: -1, transition: 'color 0.2s',
|
||||
}}>
|
||||
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
|
||||
<span>{done ? '✓' : s.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '20px 0', flex: 1, minHeight: 0, overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<AnimatePresence mode="wait" custom={dir}>
|
||||
<motion.div key={step} custom={dir} variants={variants}
|
||||
initial="enter" animate="center" exit="exit"
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}>
|
||||
|
||||
{step === 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 12, fontFamily: FF }}>Womit können wir Ihnen helfen?</p>
|
||||
{BETREFFS.map(item => (
|
||||
<BetreffCard key={item.value} c={c} item={item} selected={data.betreff === item.value}
|
||||
onClick={() => { set('betreff', item.value); setDir(1); setStep(1); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>Wie dürfen wir Sie kontaktieren?</p>
|
||||
{([
|
||||
{ label: 'Name', key: 'name' as const, type: 'text', placeholder: 'Vor- und Nachname', error: errors.name },
|
||||
{ label: 'E-Mail', key: 'email' as const, type: 'email', placeholder: 'ihre@email.de', error: errors.email },
|
||||
] as const).map(f => (
|
||||
<div key={f.key} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>{f.label}</label>
|
||||
<StyledInput c={c} type={f.type} value={data[f.key]} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
{f.error && <span style={{ fontSize: 11, color: c.errorText, fontFamily: FF }}>{f.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>Was möchten Sie uns mitteilen?</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>Nachricht</label>
|
||||
<StyledTextarea c={c} value={data.nachricht} onChange={e => set('nachricht', e.target.value)} rows={5} placeholder="Schreiben Sie Ihre Nachricht…" />
|
||||
{errors.nachricht && <span style={{ fontSize: 11, color: c.errorText, fontFamily: FF }}>{errors.nachricht}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{step > 0 && (
|
||||
<div style={{ paddingTop: 16, borderTop: `1px solid ${c.border}`, display: 'flex', flexDirection: isMobile ? 'column-reverse' : 'row', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', gap: 12, flexShrink: 0 }}>
|
||||
<button type="button" onClick={prev} style={{
|
||||
fontSize: 12, fontWeight: 600, color: c.backText, background: 'none', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', gap: 5,
|
||||
padding: isMobile ? '12px 0' : 0, minHeight: isMobile ? 44 : undefined, fontFamily: FF, transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = c.backTextHover)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = c.backText)}
|
||||
>
|
||||
<ArrowLeft size={13} /> Zurück
|
||||
</button>
|
||||
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" onClick={next} style={{
|
||||
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
padding: '12px 28px', fontFamily: FF, display: 'flex', alignItems: 'center', gap: 8,
|
||||
transition: 'background 0.15s',
|
||||
...(isMobile ? { width: '100%', justifyContent: 'center' } : {}),
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = c.btnBg)}
|
||||
>
|
||||
Weiter <ArrowRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={submit} style={{
|
||||
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
padding: '12px 28px', fontFamily: FF, display: 'flex', alignItems: 'center', gap: 8,
|
||||
transition: 'background 0.15s',
|
||||
...(isMobile ? { width: '100%', justifyContent: 'center' } : {}),
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = c.btnBg)}
|
||||
>
|
||||
Anfrage senden <Send size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, ArrowLeft, Check, Layers, Building2, Mail } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
const DARK = {
|
||||
border: 'rgba(255,255,255,0.12)',
|
||||
borderActive: 'rgba(239,191,4,0.8)',
|
||||
progressBg: 'rgba(255,255,255,0.06)',
|
||||
progressFill: '#EFBF04',
|
||||
stepActive: '#fff',
|
||||
stepDone: '#EFBF04',
|
||||
stepInactive: 'rgba(255,255,255,0.2)',
|
||||
stepActiveBg: 'rgba(239,191,4,0.07)',
|
||||
stepUnderline: '#EFBF04',
|
||||
accentLabel: 'rgba(239,191,4,0.8)',
|
||||
heading: '#fff',
|
||||
inputBg: 'rgba(255,255,255,0.05)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.08)',
|
||||
inputBorder: 'rgba(255,255,255,0.18)',
|
||||
inputBorderFoc: 'rgba(239,191,4,0.8)',
|
||||
inputText: '#fff',
|
||||
labelText: 'rgba(255,255,255,0.5)',
|
||||
errorText: '#f87171',
|
||||
cardBg: 'rgba(255,255,255,0.05)',
|
||||
cardBgSel: 'rgba(239,191,4,0.08)',
|
||||
cardBorder: 'rgba(255,255,255,0.12)',
|
||||
cardBorderSel: '#EFBF04',
|
||||
cardTitle: '#fff',
|
||||
cardTitleSel: '#EFBF04',
|
||||
cardDesc: 'rgba(255,255,255,0.65)',
|
||||
radioFill: '#EFBF04',
|
||||
btnBg: '#EFBF04',
|
||||
btnBgHover: '#FFD130',
|
||||
btnText: '#101828',
|
||||
backText: 'rgba(255,255,255,0.35)',
|
||||
backTextHover: '#fff',
|
||||
successIcon: '#EFBF04',
|
||||
successIconIn: '#111D55',
|
||||
successHeading: '#fff',
|
||||
successBody: 'rgba(255,255,255,0.45)',
|
||||
footerText: 'rgba(255,255,255,0.3)',
|
||||
};
|
||||
|
||||
const GOLD = {
|
||||
border: 'rgba(17,29,85,0.15)',
|
||||
borderActive: '#111D55',
|
||||
progressBg: 'rgba(17,29,85,0.1)',
|
||||
progressFill: '#111D55',
|
||||
stepActive: '#111D55',
|
||||
stepDone: 'rgba(17,29,85,0.55)',
|
||||
stepInactive: 'rgba(17,29,85,0.28)',
|
||||
stepActiveBg: 'rgba(17,29,85,0.07)',
|
||||
stepUnderline: '#111D55',
|
||||
accentLabel: 'rgba(17,29,85,0.5)',
|
||||
heading: '#111D55',
|
||||
inputBg: 'rgba(255,255,255,0.5)',
|
||||
inputBgFocus: 'rgba(255,255,255,0.9)',
|
||||
inputBorder: 'rgba(17,29,85,0.18)',
|
||||
inputBorderFoc: '#111D55',
|
||||
inputText: '#111D55',
|
||||
labelText: 'rgba(17,29,85,0.5)',
|
||||
errorText: '#b91c1c',
|
||||
cardBg: 'rgba(255,255,255,0.38)',
|
||||
cardBgSel: 'rgba(17,29,85,0.1)',
|
||||
cardBorder: 'rgba(17,29,85,0.2)',
|
||||
cardBorderSel: '#111D55',
|
||||
cardTitle: '#111D55',
|
||||
cardTitleSel: '#111D55',
|
||||
cardDesc: 'rgba(17,29,85,0.6)',
|
||||
radioFill: '#111D55',
|
||||
btnBg: '#111D55',
|
||||
btnBgHover: '#051A87',
|
||||
btnText: '#fff',
|
||||
backText: 'rgba(17,29,85,0.4)',
|
||||
backTextHover: '#111D55',
|
||||
successIcon: '#111D55',
|
||||
successIconIn: '#EFBF04',
|
||||
successHeading: '#111D55',
|
||||
successBody: 'rgba(17,29,85,0.6)',
|
||||
footerText: 'rgba(17,29,85,0.4)',
|
||||
};
|
||||
|
||||
type C = typeof DARK;
|
||||
|
||||
type FormData = {
|
||||
paket: string;
|
||||
unternehmen: string;
|
||||
branche: string;
|
||||
kontakt: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Paket', icon: Layers },
|
||||
{ label: 'Unternehmen', icon: Building2 },
|
||||
{ label: 'Kontakt', icon: Mail },
|
||||
];
|
||||
|
||||
const PAKETE = [
|
||||
{ value: 'main', title: 'Haupt-Sponsoring', desc: 'Maximale Sichtbarkeit auf allen Kanälen – Bühne, Digital, Print.' },
|
||||
{ value: 'category', title: 'Kategorie-Partner', desc: 'Namensgebung einer Auszeichnungskategorie – fokussiertes Branding.' },
|
||||
{ value: 'event', title: 'Event-Partner', desc: 'Präsenz beim exklusiven Gala-Abend in München.' },
|
||||
];
|
||||
|
||||
const variants = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -40 : 40, opacity: 0 }),
|
||||
};
|
||||
|
||||
function Field({ c, label, error, children }: { c: C; label: string; error?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: c.labelText }}>{label}</label>
|
||||
{children}
|
||||
{error && <span style={{ fontFamily: FF, fontSize: 11, color: c.errorText }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledInput({ c, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { c: C }) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onFocus={e => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur ={e => { setFocused(false); props.onBlur?.(e); }}
|
||||
style={{
|
||||
width: '100%', height: isMobile ? 46 : 44, padding: '0 14px',
|
||||
fontFamily: FF, fontSize: isMobile ? 16 : 14, color: c.inputText,
|
||||
border: `1px solid ${focused ? c.inputBorderFoc : c.inputBorder}`,
|
||||
background: focused ? c.inputBgFocus : c.inputBg,
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaketCard({ c, pkg, selected, onClick }: { c: C; pkg: typeof PAKETE[0]; selected: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
width: '100%', padding: '13px 18px', textAlign: 'left', cursor: 'pointer',
|
||||
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.cardBgSel : c.cardBg,
|
||||
fontFamily: FF, transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', color: selected ? c.cardTitleSel : c.cardTitle, marginBottom: 5 }}>{pkg.title}</div>
|
||||
<div style={{ fontSize: 13, color: c.cardDesc, lineHeight: 1.45 }}>{pkg.desc}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', flexShrink: 0, marginTop: 2,
|
||||
border: `2px solid ${selected ? c.cardBorderSel : c.cardBorder}`,
|
||||
background: selected ? c.radioFill : 'transparent',
|
||||
transition: 'all 0.15s',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsoringForm({ theme = 'dark' }: { theme?: 'dark' | 'gold' }) {
|
||||
const c = theme === 'gold' ? GOLD : DARK;
|
||||
const isMobile = useIsMobile();
|
||||
const [step, setStep] = useState(0);
|
||||
const [dir, setDir] = useState(1);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [data, setData] = useState<FormData>({ paket: '', unternehmen: '', branche: '', kontakt: '', email: '' });
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
const set = (k: keyof FormData, v: string) => {
|
||||
setData(d => ({ ...d, [k]: v }));
|
||||
setErrors(e => { const n = { ...e }; delete n[k]; return n; });
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const e: typeof errors = {};
|
||||
if (step === 0 && !data.paket) e.paket = 'Bitte wählen Sie ein Paket.';
|
||||
if (step === 1 && !data.unternehmen) e.unternehmen = 'Pflichtfeld';
|
||||
if (step === 2) {
|
||||
if (!data.kontakt) e.kontakt = 'Pflichtfeld';
|
||||
if (!data.email) e.email = 'Pflichtfeld';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Ungültige E-Mail';
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const next = () => { if (validate()) { setDir(1); setStep(s => s + 1); } };
|
||||
const prev = () => { setDir(-1); setStep(s => s - 1); };
|
||||
const submit = () => { if (validate()) setSubmitted(true); };
|
||||
|
||||
const progress = (step / (STEPS.length - 1)) * 100;
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div style={{ padding: '64px 28px', textAlign: 'center', fontFamily: FF }}>
|
||||
<div style={{ width: 52, height: 52, background: c.successIcon, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Check size={22} color={c.successIconIn} strokeWidth={3} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 900, color: c.successHeading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 10 }}>
|
||||
Anfrage eingegangen
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: c.successBody, maxWidth: 320, margin: '0 auto', lineHeight: 1.6 }}>
|
||||
Vielen Dank, <strong style={{ color: c.successHeading }}>{data.kontakt}</strong>. Wir senden Ihnen unser Sponsoring-Exposé innerhalb von 48 Stunden zu.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{ height: 2, background: c.progressBg, flexShrink: 0 }}>
|
||||
<div style={{ height: '100%', background: c.progressFill, width: `${progress}%`, transition: 'width 0.4s ease' }} />
|
||||
</div>
|
||||
|
||||
{/* Step tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const done = i < step, active = i === step;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1, padding: '10px 4px', textAlign: 'center', fontFamily: FF, fontSize: 9,
|
||||
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
color: active ? c.stepActive : done ? c.stepDone : c.stepInactive,
|
||||
borderBottom: active ? `2px solid ${c.stepUnderline}` : '2px solid transparent',
|
||||
background: active ? c.stepActiveBg : 'transparent',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5,
|
||||
marginBottom: -1, transition: 'color 0.2s',
|
||||
}}>
|
||||
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
|
||||
<span>{done ? '✓' : s.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '20px 0', flex: 1, minHeight: 0, overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<AnimatePresence mode="wait" custom={dir}>
|
||||
<motion.div key={step} custom={dir} variants={variants}
|
||||
initial="enter" animate="center" exit="exit"
|
||||
transition={{ duration: 0.22, ease: 'easeInOut' }}>
|
||||
|
||||
{step === 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 6 }}>Schritt 1 – Paket wählen</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>Interesse an</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{PAKETE.map(pkg => (
|
||||
<PaketCard key={pkg.value} c={c} pkg={pkg} selected={data.paket === pkg.value} onClick={() => set('paket', pkg.value)} />
|
||||
))}
|
||||
</div>
|
||||
{errors.paket && <p style={{ fontFamily: FF, fontSize: 11, color: c.errorText, marginTop: 4 }}>{errors.paket}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>Schritt 2 – Unternehmen</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ihr Unternehmen</h3>
|
||||
</div>
|
||||
<Field c={c} label="Unternehmen *" error={errors.unternehmen}>
|
||||
<StyledInput c={c} value={data.unternehmen} onChange={e => set('unternehmen', e.target.value)} placeholder="Firmenname" />
|
||||
</Field>
|
||||
<Field c={c} label="Branche">
|
||||
<StyledInput c={c} value={data.branche} onChange={e => set('branche', e.target.value)} placeholder="z. B. Finanzdienstleistungen" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ fontFamily: FF, fontSize: 10, color: c.accentLabel, textTransform: 'uppercase', letterSpacing: '0.25em', fontWeight: 700, marginBottom: 8 }}>Schritt 3 – Kontakt</p>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 900, color: c.heading, textTransform: 'uppercase', letterSpacing: '-0.01em' }}>Ansprechpartner</h3>
|
||||
</div>
|
||||
<Field c={c} label="Ansprechpartner *" error={errors.kontakt}>
|
||||
<StyledInput c={c} value={data.kontakt} onChange={e => set('kontakt', e.target.value)} placeholder="Vor- und Nachname" />
|
||||
</Field>
|
||||
<Field c={c} label="E-Mail *" error={errors.email}>
|
||||
<StyledInput c={c} type="email" value={data.email} onChange={e => set('email', e.target.value)} placeholder="name@unternehmen.de" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div style={{ paddingTop: 14, borderTop: `1px solid ${c.border}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
{step > 0 ? (
|
||||
<button type="button" onClick={prev} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: c.backText, background: 'none', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: isMobile ? '12px 4px' : 0, minHeight: isMobile ? 44 : undefined,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = c.backTextHover)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = c.backText)}
|
||||
>
|
||||
<ArrowLeft size={13} /> Zurück
|
||||
</button>
|
||||
) : <div />}
|
||||
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" onClick={next} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
padding: isMobile ? '14px 24px' : '12px 24px', minHeight: isMobile ? 44 : undefined,
|
||||
display: 'flex', alignItems: 'center', gap: 7, transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = c.btnBgHover; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = c.btnBg; }}
|
||||
>
|
||||
Weiter <ArrowRight size={13} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={submit} style={{
|
||||
fontFamily: FF, fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
|
||||
padding: isMobile ? '14px 24px' : '12px 24px', minHeight: isMobile ? 44 : undefined,
|
||||
display: 'flex', alignItems: 'center', gap: 7, transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = c.btnBgHover; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = c.btnBg; }}
|
||||
>
|
||||
Exposé anfordern <ArrowRight size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={{ fontFamily: FF, textAlign: 'center', fontSize: 12, color: c.footerText, paddingTop: 12 }}>
|
||||
Wir melden uns innerhalb von 48 Stunden.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
const GOLD = '#EFBF04';
|
||||
const DARK = '#101828';
|
||||
|
||||
const SHARDS = [
|
||||
{ clip: 'polygon(0 0, 50% 0, 50% 50%, 0 50%)', tx: -60, ty: -60 },
|
||||
{ clip: 'polygon(50% 0, 100% 0, 100% 50%, 50% 50%)', tx: 60, ty: -60 },
|
||||
{ clip: 'polygon(0 50%, 50% 50%, 50% 100%, 0 100%)', tx: -60, ty: 60 },
|
||||
{ clip: 'polygon(50% 50%, 100% 50%, 100% 100%, 50% 100%)', tx: 60, ty: 60 },
|
||||
];
|
||||
|
||||
export default function LoadingScreen({ onDone }: { onDone: () => void }) {
|
||||
const [phase, setPhase] = useState<'logo' | 'progress' | 'shatter' | 'gone'>('logo');
|
||||
const called = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t1 = setTimeout(() => setPhase('progress'), 300);
|
||||
const t2 = setTimeout(() => setPhase('shatter'), 1700);
|
||||
const t3 = setTimeout(() => {
|
||||
setPhase('gone');
|
||||
if (!called.current) { called.current = true; onDone(); }
|
||||
}, 2500);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); };
|
||||
}, [onDone]);
|
||||
|
||||
if (phase === 'gone') return null;
|
||||
|
||||
const isShatter = phase === 'shatter';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
pointerEvents: isShatter ? 'none' : 'all',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ── SOLID PHASE (logo + progress bar) ── */}
|
||||
<AnimatePresence>
|
||||
{!isShatter && (
|
||||
<motion.div
|
||||
key="splash"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
style={{ position: 'absolute', inset: 0, background: DARK }}
|
||||
>
|
||||
|
||||
{/* Logo + bar centred */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 36,
|
||||
}}>
|
||||
<motion.img
|
||||
src="/bmp-logo.png"
|
||||
alt="BMP"
|
||||
initial={{ opacity: 0, scale: 0.86, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{
|
||||
width: 'clamp(260px, 32vw, 480px)',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gold progress track */}
|
||||
<div style={{
|
||||
width: 'clamp(200px, 26vw, 320px)',
|
||||
height: 2,
|
||||
background: 'rgba(239,191,4,0.15)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: phase === 'progress' ? 1 : 0 }}
|
||||
transition={{ duration: 1.1, ease: [0.4, 0, 0.2, 1] }}
|
||||
style={{
|
||||
height: '100%',
|
||||
background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.55))`,
|
||||
boxShadow: '0 0 10px rgba(239,191,4,0.7)',
|
||||
transformOrigin: 'left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── LIQUID GLASS SHATTER ── */}
|
||||
<AnimatePresence>
|
||||
{isShatter && SHARDS.map((s, i) => (
|
||||
<motion.div
|
||||
key={`shard-${i}`}
|
||||
initial={{ x: 0, y: 0, opacity: 1, filter: 'blur(0px) brightness(1)' }}
|
||||
animate={{
|
||||
x: `${s.tx}%`,
|
||||
y: `${s.ty}%`,
|
||||
opacity: 0,
|
||||
filter: 'blur(22px) brightness(2.2)',
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.65,
|
||||
ease: [0.4, 0, 1, 1],
|
||||
delay: i * 0.055,
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: DARK,
|
||||
clipPath: s.clip,
|
||||
}}
|
||||
>
|
||||
{/* Ghost logo inside each shard */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<img
|
||||
src="/bmp-logo.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
style={{ width: 'clamp(260px, 32vw, 480px)', height: 'auto', opacity: 0.9 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Central gold burst on shatter */}
|
||||
<AnimatePresence>
|
||||
{isShatter && (
|
||||
<motion.div
|
||||
key="burst"
|
||||
initial={{ opacity: 0.7, scale: 0 }}
|
||||
animate={{ opacity: 0, scale: 4 }}
|
||||
transition={{ duration: 0.55, ease: 'easeOut' }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 160, height: 160, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(239,191,4,0.55) 0%, transparent 68%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,53 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[#EFBF04] text-[#101828] shadow-xs hover:bg-[#03093A] hover:text-white",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
accent:
|
||||
"bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3 max-md:min-h-11",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 max-md:min-h-11",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 max-md:min-h-11",
|
||||
icon: "size-9 max-md:min-h-11 max-md:min-w-11",
|
||||
clear: '',
|
||||
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-9 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-11 rounded-md px-8 has-[>svg]:px-4',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ asChild = false, className, size, variant, ...props }) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -1,92 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const Card: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
className={cn('bg-card text-card-foreground rounded-lg border shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn('text-2xl leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return <div data-slot="card-content" className={cn('p-6 pt-0', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{ orientation = "horizontal", opts, setApi, plugins, className, children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{ ...opts, axis: orientation === "horizontal" ? "x" : "y" },
|
||||
plugins,
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => { api?.scrollPrev() }, [api])
|
||||
const scrollNext = React.useCallback(() => { api?.scrollNext() }, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") { event.preventDefault(); scrollPrev() }
|
||||
else if (event.key === "ArrowRight") { event.preventDefault(); scrollNext() }
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
return () => { api?.off("select", onSelect) }
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef, api, opts,
|
||||
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev, scrollNext, canScrollPrev, canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
return (
|
||||
<Button
|
||||
ref={ref} variant={variant} size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal" ? "-left-12 top-1/2 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
return (
|
||||
<Button
|
||||
ref={ref} variant={variant} size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal" ? "-right-12 top-1/2 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
29
src/components/ui/checkbox.tsx
Normal file
29
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
const Checkbox: React.FC<React.ComponentProps<typeof CheckboxPrimitive.Root>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-4 shrink-0 rounded-[4px] border shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,23 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full border border-stone-200 bg-white px-4 py-2 text-base md:text-sm text-[#101828] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#EFBF04] focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = ({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<input
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cn } from "@/lib/utils"
|
||||
'use client'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-[10px] font-bold uppercase tracking-[0.2em] text-[#101828]/50 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
)
|
||||
|
||||
const Label: React.FC<
|
||||
{ ref?: React.Ref<HTMLLabelElement> } & React.ComponentProps<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
> = ({ className, ref, ...props }) => (
|
||||
<LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />
|
||||
)
|
||||
|
||||
export { Label }
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* MunichSkylineBg
|
||||
* Drop inside any light section (position: relative required on parent).
|
||||
* Uses backgroundPositionY for parallax – no transform, no GPU compositing layer.
|
||||
* No mixBlendMode to avoid Safari compositing bleed into sibling elements.
|
||||
*/
|
||||
const MunichSkylineBg: React.FC = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const rect = el.parentElement?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const progress = (window.innerHeight - rect.top) / (window.innerHeight + rect.height);
|
||||
const offset = progress * 30;
|
||||
el.style.backgroundPositionY = `calc(100% - ${offset}px)`;
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '52%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
backgroundImage: 'url(/munich-skyline.jpg)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'bottom center',
|
||||
backgroundSize: 'cover',
|
||||
opacity: 0.08,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MunichSkylineBg;
|
||||
92
src/components/ui/pagination.tsx
Normal file
92
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ButtonProps } from '@/components/ui/button'
|
||||
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
role="navigation"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const PaginationContent: React.FC<
|
||||
{ ref?: React.Ref<HTMLUListElement> } & React.HTMLAttributes<HTMLUListElement>
|
||||
> = ({ className, ref, ...props }) => (
|
||||
<ul className={cn('flex flex-row items-center gap-1', className)} ref={ref} {...props} />
|
||||
)
|
||||
|
||||
const PaginationItem: React.FC<
|
||||
{ ref?: React.Ref<HTMLLIElement> } & React.HTMLAttributes<HTMLLIElement>
|
||||
> = ({ className, ref, ...props }) => <li className={cn('', className)} ref={ref} {...props} />
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'button'>
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
|
||||
<button
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size,
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
size="default"
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
size="default"
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface PartnerLogo {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PartnerTickerProps {
|
||||
logos: PartnerLogo[];
|
||||
}
|
||||
|
||||
export function PartnerTicker({ logos }: PartnerTickerProps) {
|
||||
// Duplicate logos for seamless infinite loop
|
||||
const track = [...logos, ...logos];
|
||||
|
||||
return (
|
||||
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0, position: 'relative' }}>
|
||||
{/* fade edges */}
|
||||
<div style={{ position: 'absolute', inset: 0, left: 0, width: 32, background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, right: 0, left: 'auto', width: 32, background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
|
||||
|
||||
<div style={{ display: 'flex', animation: 'marquee 22s linear infinite', width: 'max-content' }}>
|
||||
{track.map((logo, i) => (
|
||||
<div
|
||||
key={`${logo.id}-${i}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 24px',
|
||||
color: '#fff',
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 140,
|
||||
flexShrink: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{logo.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { useMeasure } from "@uidotdev/usehooks"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import {
|
||||
HTMLMotionProps,
|
||||
MotionValue,
|
||||
motion,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const processCardVariants = cva("flex border backdrop-blur-lg", {
|
||||
variants: {
|
||||
variant: {
|
||||
indigo:
|
||||
"flex border text-slate-50 border-slate-700 backdrop-blur-lg bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
|
||||
light: "shadow",
|
||||
bmp: "flex border text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
|
||||
},
|
||||
size: {
|
||||
sm: "min-w-[25%] max-w-[25%]",
|
||||
md: "min-w-[50%] max-w-[50%]",
|
||||
lg: "min-w-[75%] max-w-[75%]",
|
||||
xl: "min-w-full max-w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "bmp",
|
||||
size: "md",
|
||||
},
|
||||
})
|
||||
|
||||
interface ContainerScrollContextValue {
|
||||
scrollYProgress: MotionValue<number>
|
||||
}
|
||||
|
||||
interface ProcessCardProps
|
||||
extends HTMLMotionProps<"div">,
|
||||
VariantProps<typeof processCardVariants> {
|
||||
itemsLength: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const ContainerScrollContext = React.createContext<
|
||||
ContainerScrollContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
function useContainerScrollContext() {
|
||||
const context = React.useContext(ContainerScrollContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useContainerScrollContext must be used within a ContainerScroll Component"
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ContainerScroll = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: scrollRef,
|
||||
})
|
||||
return (
|
||||
<ContainerScrollContext.Provider value={{ scrollYProgress }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn("relative min-h-[120vh]", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContainerScrollContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContainerSticky = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("sticky left-0 top-0 w-full overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContainerSticky.displayName = "ContainerSticky"
|
||||
|
||||
export const ProcessCardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6", className)} {...props} />
|
||||
))
|
||||
ProcessCardTitle.displayName = "ProcessCardTitle"
|
||||
|
||||
export const ProcessCardBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-8 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ProcessCardBody.displayName = "ProcessCardBody"
|
||||
|
||||
export const ProcessCard: React.FC<ProcessCardProps> = ({
|
||||
className,
|
||||
style,
|
||||
variant,
|
||||
size,
|
||||
itemsLength,
|
||||
index,
|
||||
...props
|
||||
}) => {
|
||||
const { scrollYProgress } = useContainerScrollContext()
|
||||
const start = index / itemsLength
|
||||
const end = start + 1 / itemsLength
|
||||
const { innerWidth } = window
|
||||
const [ref, { width }] = useMeasure()
|
||||
|
||||
const x = useTransform(
|
||||
scrollYProgress,
|
||||
[start, end],
|
||||
[innerWidth, -((width ?? 0) * index) + 64 * index]
|
||||
)
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{
|
||||
x: index > 0 ? x : 0,
|
||||
...style,
|
||||
}}
|
||||
className={cn(processCardVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ProcessCard.displayName = "ProcessCard"
|
||||
166
src/components/ui/select.tsx
Normal file
166
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
const Select: React.FC<React.ComponentProps<typeof SelectPrimitive.Root>> = (props) => {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
const SelectGroup: React.FC<React.ComponentProps<typeof SelectPrimitive.Group>> = (props) => {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
const SelectValue: React.FC<React.ComponentProps<typeof SelectPrimitive.Value>> = (props) => {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
const SelectTrigger: React.FC<React.ComponentProps<typeof SelectPrimitive.Trigger>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground aria-invalid:border-destructive ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectContent: React.FC<React.ComponentProps<typeof SelectPrimitive.Content>> = ({
|
||||
children,
|
||||
className,
|
||||
position = 'popper',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectLabel: React.FC<React.ComponentProps<typeof SelectPrimitive.Label>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectItem: React.FC<React.ComponentProps<typeof SelectPrimitive.Item>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectSeparator: React.FC<React.ComponentProps<typeof SelectPrimitive.Separator>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectScrollUpButton: React.FC<
|
||||
React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectScrollDownButton: React.FC<
|
||||
React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/utilities/ui'
|
||||
import * as React from 'react'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[100px] w-full border border-stone-200 bg-white px-4 py-3 text-base md:text-sm text-[#101828] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#EFBF04] focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/50 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
Reference in New Issue
Block a user