feat: Add CHORUS teaser website with mobile-responsive design
- Created complete Next.js 15 teaser website with CHORUS brand styling - Implemented mobile-responsive 3D logo (128px mobile, 512px desktop) - Added proper Exo font loading via Next.js Google Fonts for iOS/Chrome compatibility - Built comprehensive early access form with GDPR compliance and rate limiting - Integrated PostgreSQL database with complete schema for lead capture - Added scroll indicators that auto-hide when scrolling begins - Optimized mobile modal forms with proper scrolling and submit button access - Deployed via Docker Swarm with Traefik SSL termination at chorus.services - Includes database migrations, consent tracking, and email notifications 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
276
modules/teaser/components/EarlyAccessForm.tsx
Normal file
276
modules/teaser/components/EarlyAccessForm.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { EarlyAccessLead, useEarlyAccessCapture } from '../hooks/useEarlyAccessCapture'
|
||||
|
||||
import { LeadSourceType } from '../hooks/useEarlyAccessCapture'
|
||||
|
||||
interface EarlyAccessFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
leadSource: LeadSourceType
|
||||
}
|
||||
|
||||
export default function EarlyAccessForm({ isOpen, onClose, leadSource }: EarlyAccessFormProps) {
|
||||
const { submitEarlyAccess, isSubmitting, submitStatus, errorMessage } = useEarlyAccessCapture()
|
||||
|
||||
const [formData, setFormData] = useState<EarlyAccessLead>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
companyName: '',
|
||||
companyRole: '',
|
||||
interestLevel: 'general_interest',
|
||||
leadSource: leadSource,
|
||||
gdprConsent: false,
|
||||
marketingConsent: false,
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.gdprConsent) {
|
||||
alert('Please accept the privacy policy to continue.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await submitEarlyAccess(formData)
|
||||
|
||||
if (result.success) {
|
||||
// Reset form on success
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
companyName: '',
|
||||
companyRole: '',
|
||||
interestLevel: 'general_interest',
|
||||
leadSource: leadSource,
|
||||
gdprConsent: false,
|
||||
marketingConsent: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof EarlyAccessLead, value: string | boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm overflow-y-auto"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md bg-gradient-to-b from-mulberry-900 to-carbon-900 text-white p-4 sm:p-chorus-xl rounded-lg border border-mulberry-700/50 shadow-2xl my-8 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-mulberry-300 hover:text-white text-2xl font-light transition-colors duration-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* Form Header */}
|
||||
<h3 className="text-h3 font-semibold text-white mb-chorus-sm">
|
||||
{leadSource === 'request_early_access'
|
||||
? 'Request Early Access to CHORUS'
|
||||
: 'Join the CHORUS Waitlist'}
|
||||
</h3>
|
||||
<p className="text-sm text-mulberry-200 font-light mb-4 sm:mb-chorus-xl">
|
||||
{leadSource === 'request_early_access'
|
||||
? 'Get priority access to contextual AI orchestration'
|
||||
: 'Be notified when CHORUS becomes available'}
|
||||
</p>
|
||||
|
||||
{/* Success State */}
|
||||
{submitStatus === 'success' ? (
|
||||
<div className="text-center py-chorus-xl">
|
||||
<div className="text-5xl text-eucalyptus-400 mb-chorus-lg animate-bounce">
|
||||
✓
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-eucalyptus-300 mb-chorus-sm">
|
||||
{leadSource === 'request_early_access'
|
||||
? 'Request submitted successfully!'
|
||||
: 'Welcome to the waitlist!'}
|
||||
</h4>
|
||||
<p className="text-sm text-mulberry-200 font-light">
|
||||
{leadSource === 'request_early_access'
|
||||
? 'We\'ll prioritize your request and contact you soon.'
|
||||
: 'We\'ll notify you when CHORUS becomes available.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Form */
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Name Fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-sm sm:gap-chorus-md mb-chorus-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="mb-chorus-md">
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="john@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company Information */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-sm sm:gap-chorus-md mb-chorus-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyName || ''}
|
||||
onChange={(e) => handleInputChange('companyName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
Role
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyRole || ''}
|
||||
onChange={(e) => handleInputChange('companyRole', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="CTO, Director, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interest Level */}
|
||||
<div className="mb-4 sm:mb-chorus-lg">
|
||||
<label className="block text-sm font-medium text-mulberry-200 mb-chorus-xs">
|
||||
Primary Interest
|
||||
</label>
|
||||
<select
|
||||
value={formData.interestLevel}
|
||||
onChange={(e) => handleInputChange('interestLevel', e.target.value as any)}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="general_interest">General Interest</option>
|
||||
<option value="technical_evaluation">Technical Evaluation</option>
|
||||
<option value="strategic_demo">Strategic Demo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* GDPR Consent */}
|
||||
<div style={{ marginBottom: '1.5rem', fontSize: '0.85rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.gdprConsent}
|
||||
onChange={(e) => handleInputChange('gdprConsent', e.target.checked)}
|
||||
required
|
||||
/>
|
||||
<span>
|
||||
I agree to the privacy policy and consent to processing my personal data for early access communications. *
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.marketingConsent}
|
||||
onChange={(e) => handleInputChange('marketingConsent', e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
I would like to receive updates about CHORUS Services and related products.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{submitStatus === 'error' && errorMessage && (
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid #dc2626',
|
||||
color: '#fca5a5',
|
||||
fontSize: '0.85rem',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
⚠ {errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !formData.gdprConsent}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: (isSubmitting || !formData.gdprConsent) ? 0.5 : 1,
|
||||
cursor: (isSubmitting || !formData.gdprConsent) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isSubmitting
|
||||
? (leadSource === 'request_early_access' ? 'Submitting Request...' : 'Joining Waitlist...')
|
||||
: (leadSource === 'request_early_access' ? 'Submit Request' : 'Join Waitlist')
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.6,
|
||||
textAlign: 'center',
|
||||
marginTop: '1.5rem',
|
||||
paddingTop: '1.5rem',
|
||||
borderTop: '1px solid #444'
|
||||
}}>
|
||||
By joining our waitlist, you'll receive exclusive early access and product updates.
|
||||
We respect your privacy and won't spam you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
modules/teaser/components/MissionStatement.tsx
Normal file
94
modules/teaser/components/MissionStatement.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import ScrollReveal from './ScrollReveal'
|
||||
|
||||
export default function MissionStatement() {
|
||||
return (
|
||||
<section className="py-chorus-xxl px-chorus-lg bg-gradient-to-b from-sand-200 via-sand-100 to-white dark:from-mulberry-950 dark:via-carbon-950 dark:to-mulberry-950 text-center">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
{/* Section Title */}
|
||||
<ScrollReveal delay={200} duration={600} direction="up">
|
||||
<h3 className="text-h2 font-bold text-carbon-950 dark:text-white mb-chorus-xl">
|
||||
Our Mission
|
||||
</h3>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Mission Statement */}
|
||||
<ScrollReveal delay={300} duration={600} direction="up">
|
||||
<p className="text-xl md:text-2xl leading-relaxed mb-chorus-xl text-carbon-700 dark:text-mulberry-100 font-light">
|
||||
We are creating a distributed, semantic and temporal knowledge fabric,
|
||||
for humans and AI, to share reasoning, context and intent, not just files.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={400} duration={600} direction="up">
|
||||
<div className="w-16 h-px bg-gradient-to-r from-transparent via-carbon-400 dark:via-mulberry-400 to-transparent mx-auto my-chorus-xxl" />
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={500} duration={600} direction="up">
|
||||
<p className="text-lg md:text-xl leading-relaxed mb-chorus-xxl text-carbon-600 dark:text-mulberry-200 font-light max-w-3xl mx-auto">
|
||||
CHORUS transforms how organizations orchestrate AI agents,
|
||||
ensuring every decision is informed by the right context,
|
||||
delivered to the right agent, at precisely the right moment.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Supporting Points */}
|
||||
<ScrollReveal delay={600} duration={600} direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-chorus-xl mt-chorus-xxl">
|
||||
{[
|
||||
{
|
||||
title: "Contextual Intelligence",
|
||||
description: "Beyond data sharing—intelligent context that understands meaning, relationships, and temporal significance.",
|
||||
color: "ocean"
|
||||
},
|
||||
{
|
||||
title: "Agent Orchestration",
|
||||
description: "Seamless coordination between human teams and AI agents through sophisticated workflow intelligence.",
|
||||
color: "eucalyptus"
|
||||
},
|
||||
{
|
||||
title: "Temporal Knowledge",
|
||||
description: "Understanding not just what happened, but when it mattered and why it influenced subsequent decisions.",
|
||||
color: "coral"
|
||||
}
|
||||
].map((point, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-center p-chorus-lg rounded-lg bg-sand-50/80 dark:bg-mulberry-900/30 border border-sand-200/60 dark:border-mulberry-800/40 backdrop-blur-sm hover:bg-sand-100/90 dark:hover:bg-mulberry-900/50 transition-all duration-500 ease-out"
|
||||
>
|
||||
<div className={`w-20 h-20 mx-auto mb-chorus-md rounded-full bg-gradient-to-br from-${point.color}-500 to-${point.color}-700 flex items-center justify-center`}>
|
||||
{index === 0 && (
|
||||
// Navigation/Compass Icon
|
||||
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m-6 3l6-3" />
|
||||
</svg>
|
||||
)}
|
||||
{index === 1 && (
|
||||
// Communication/Chat_Conversation Icon
|
||||
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
{index === 2 && (
|
||||
// Calendar/Clock Icon
|
||||
<svg className={`w-12 h-12 text-${point.color}-100`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
{point.title}
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed text-carbon-600 dark:text-mulberry-300 font-light">
|
||||
{point.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
62
modules/teaser/components/ScrollReveal.tsx
Normal file
62
modules/teaser/components/ScrollReveal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
distance?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScrollReveal({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 600,
|
||||
direction = 'up',
|
||||
distance = 24,
|
||||
className = '',
|
||||
}: ScrollRevealProps) {
|
||||
const { elementRef, isVisible } = useIntersectionObserver({
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -100px 0px',
|
||||
triggerOnce: true,
|
||||
});
|
||||
|
||||
const getTransform = (visible: boolean) => {
|
||||
if (visible) return 'translate3d(0, 0, 0)';
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return `translate3d(0, ${distance}px, 0)`;
|
||||
case 'down':
|
||||
return `translate3d(0, -${distance}px, 0)`;
|
||||
case 'left':
|
||||
return `translate3d(${distance}px, 0, 0)`;
|
||||
case 'right':
|
||||
return `translate3d(-${distance}px, 0, 0)`;
|
||||
default:
|
||||
return `translate3d(0, ${distance}px, 0)`;
|
||||
}
|
||||
};
|
||||
|
||||
const revealStyle = {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: getTransform(isVisible),
|
||||
transition: `opacity ${duration}ms ease-out ${delay}ms, transform ${duration}ms ease-out ${delay}ms`,
|
||||
willChange: 'opacity, transform',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
style={revealStyle}
|
||||
className={`scroll-reveal ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
modules/teaser/components/TeaserHero.tsx
Normal file
68
modules/teaser/components/TeaserHero.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LeadSourceType } from '../hooks/useEarlyAccessCapture'
|
||||
import ThreeLogo from './ThreeLogo'
|
||||
|
||||
interface TeaserHeroProps {
|
||||
onEarlyAccess: (leadSource: LeadSourceType) => void
|
||||
}
|
||||
|
||||
export default function TeaserHero({ onEarlyAccess }: TeaserHeroProps) {
|
||||
const [showScrollIndicator, setShowScrollIndicator] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowScrollIndicator(window.scrollY < 50)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center px-chorus-lg py-chorus-xxl bg-gradient-to-b from-white via-sand-100 to-sand-200 dark:from-carbon-950 dark:via-mulberry-950 dark:to-carbon-950 text-center relative">
|
||||
{/* CHORUS 3D Logo - Responsive sizing */}
|
||||
<div className="animate-fade-in">
|
||||
<div className="w-32 h-32 md:w-96 md:h-96 lg:w-[512px] lg:h-[512px]">
|
||||
<ThreeLogo className="mx-auto drop-shadow-2xl w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CHORUS Title */}
|
||||
<h1 className="text-h1 font-logo font-thin text-carbon-950 dark:text-white mb-chorus-md animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
CHORUS
|
||||
</h1>
|
||||
|
||||
{/* Tagline */}
|
||||
<h2 className="text-xl md:text-2xl text-carbon-700 dark:text-mulberry-100 mb-chorus-xxl max-w-2xl font-light leading-relaxed animate-fade-in-up" style={{ animationDelay: '0.6s' }}>
|
||||
The right context,<br/>to the right agent,<br/>at the right time.
|
||||
</h2>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex gap-chorus-md flex-wrap justify-center animate-fade-in-up" style={{ animationDelay: '0.9s' }}>
|
||||
<button
|
||||
onClick={() => onEarlyAccess('request_early_access')}
|
||||
className="btn-primary text-lg px-chorus-xl py-chorus-md"
|
||||
>
|
||||
Request Early Access
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEarlyAccess('early_access_waitlist')}
|
||||
className="btn-secondary text-lg px-chorus-xl py-chorus-md"
|
||||
>
|
||||
Join Waitlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtle scroll indicator */}
|
||||
{showScrollIndicator && (
|
||||
<div className="absolute bottom-chorus-lg left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-300">
|
||||
<div className="w-6 h-10 border-2 border-carbon-600 dark:border-mulberry-400 rounded-full flex justify-center items-start">
|
||||
<div className="w-1 h-3 bg-carbon-600 dark:bg-mulberry-400 rounded-full mt-2 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
43
modules/teaser/components/ThemeToggle.tsx
Normal file
43
modules/teaser/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
setTheme(isDark ? 'dark' : 'light')
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="fixed top-chorus-lg right-chorus-lg z-50 p-chorus-sm hover:bg-black/10 dark:hover:bg-white/10 transition-colors rounded-lg backdrop-blur-sm bg-white/20 dark:bg-black/20 border border-white/30 dark:border-white/20"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
// Sun icon for switching to light mode
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
// Moon icon for switching to dark mode
|
||||
<svg className="w-6 h-6 text-carbon-950" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
236
modules/teaser/components/ThreeLogo.tsx
Normal file
236
modules/teaser/components/ThreeLogo.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
|
||||
interface ThreeLogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ThreeLogo({ className = "" }: ThreeLogoProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
// Scene / Renderer / Camera
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
||||
camera.position.set(0, 0, 3.0); // Move camera back to prevent clipping
|
||||
camera.lookAt(0, 0, 0); // Ensure camera looks at exact center
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setClearColor(0x000000, 0); // transparent background
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Resize handling with proper aspect ratio
|
||||
const resize = () => {
|
||||
const { clientWidth, clientHeight } = container;
|
||||
// Ensure square aspect ratio for the logo
|
||||
const size = Math.min(clientWidth, clientHeight);
|
||||
renderer.setSize(size, size, false);
|
||||
camera.aspect = 1; // Always square
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// Exact lighting setup from your reference logo.html
|
||||
const light = new THREE.PointLight(0xffffff, 1.4);
|
||||
light.position.set(0, 4, 1);
|
||||
scene.add(light);
|
||||
|
||||
const bottomLight = new THREE.PointLight(0x800080, 1.2, 12);
|
||||
bottomLight.position.set(0, -4, 1);
|
||||
scene.add(bottomLight);
|
||||
|
||||
const leftLight = new THREE.PointLight(0x808000, 1.45, 5);
|
||||
leftLight.position.set(-5, 0, 4);
|
||||
scene.add(leftLight);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
||||
|
||||
// Load environment map from your horizon gradient image
|
||||
const expandGradient = (img: HTMLImageElement) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const w = 512, h = 256; // safe HDRI-like size
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Stretch the narrow gradient strip to fill the canvas
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
};
|
||||
|
||||
const envLoader = new THREE.ImageLoader();
|
||||
envLoader.load('/logos/horizon-gradient.png', (image) => {
|
||||
const tex = expandGradient(image);
|
||||
tex.mapping = THREE.EquirectangularReflectionMapping;
|
||||
|
||||
// Generate proper environment map using PMREM
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const envMap = pmrem.fromEquirectangular(tex).texture;
|
||||
|
||||
scene.environment = envMap;
|
||||
pmrem.dispose();
|
||||
});
|
||||
|
||||
scene.background = null; // keep transparent
|
||||
|
||||
// Theme-aware material colors
|
||||
const getThemeMaterial = (theme: string = 'default') => {
|
||||
const colorMap = {
|
||||
'default': 0x333333,
|
||||
'protanopia': 0x1e40af, // Blue-800 for red-blind
|
||||
'deuteranopia': 0x6b21a8, // Purple-800 for green-blind
|
||||
'tritanopia': 0x991b1b, // Red-800 for blue-blind
|
||||
'achromatopsia': 0x374151, // Gray-700 for color-blind
|
||||
};
|
||||
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color: colorMap[theme as keyof typeof colorMap] || colorMap.default,
|
||||
roughness: 0.24,
|
||||
metalness: 1.0,
|
||||
clearcoat: 0.48,
|
||||
clearcoatRoughness: 0.15,
|
||||
reflectivity: 1.2,
|
||||
sheen: 0.35,
|
||||
sheenColor: new THREE.Color(0x212121),
|
||||
sheenRoughness: 0.168,
|
||||
envMapIntensity: 1,
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize with default material
|
||||
let currentMaterial = getThemeMaterial('default');
|
||||
|
||||
// Load GLB with DRACO support
|
||||
const loader = new GLTFLoader();
|
||||
const draco = new DRACOLoader();
|
||||
draco.setDecoderPath('/draco/');
|
||||
loader.setDRACOLoader(draco);
|
||||
|
||||
let model: THREE.Object3D | null = null;
|
||||
|
||||
console.log('Loading your mobius-ring.glb...');
|
||||
loader.load(
|
||||
'/logos/mobius-ring.glb',
|
||||
(gltf) => {
|
||||
console.log('🎉 Your GLB loaded successfully!', gltf);
|
||||
model = gltf.scene;
|
||||
|
||||
// Apply the theme-aware material
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
(child as THREE.Mesh).material = currentMaterial;
|
||||
}
|
||||
});
|
||||
|
||||
// Center the model exactly at origin
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
model.position.sub(center); // Move model so its center is at (0,0,0)
|
||||
|
||||
scene.add(model);
|
||||
console.log('🎯 Your Möbius GLB model added to scene and centered at origin');
|
||||
},
|
||||
(progress) => {
|
||||
if (progress.total > 0) {
|
||||
const percent = Math.round(progress.loaded / progress.total * 100);
|
||||
console.log(`GLB loading progress: ${percent}% (${progress.loaded}/${progress.total} bytes)`);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
console.error('❌ GLB load error:', err);
|
||||
|
||||
// Fallback: create a torus geometry with the theme-aware material
|
||||
console.log('Creating fallback torus geometry...');
|
||||
const fallbackGeometry = new THREE.TorusGeometry(0.6, 0.2, 16, 100);
|
||||
const fallbackMesh = new THREE.Mesh(fallbackGeometry, currentMaterial);
|
||||
fallbackMesh.position.set(0, 0, 0); // Ensure fallback is also centered
|
||||
scene.add(fallbackMesh);
|
||||
model = fallbackMesh;
|
||||
|
||||
console.log('⚠️ Fallback torus geometry created (placeholder for your GLB)');
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for accessibility theme changes
|
||||
const handleThemeChange = (event: any) => {
|
||||
const newTheme = event.detail.theme;
|
||||
const newMaterial = getThemeMaterial(newTheme);
|
||||
|
||||
if (model) {
|
||||
// Update material on all meshes
|
||||
model.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material.dispose(); // Clean up old material
|
||||
child.material = newMaterial;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update current material reference
|
||||
currentMaterial = newMaterial;
|
||||
console.log(`🎨 Logo theme changed to: ${newTheme}`);
|
||||
};
|
||||
|
||||
window.addEventListener('accessibilityThemeChanged', handleThemeChange);
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
if (model) {
|
||||
// Use exact rotation parameters from your reference logo.html
|
||||
model.rotation.x += 0.010; // spinSpeedX from params
|
||||
model.rotation.y += -0.010; // spinSpeedY from params
|
||||
model.rotation.z += -0.1; // spinSpeedZ from params
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
tick();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('accessibilityThemeChanged', handleThemeChange);
|
||||
renderer.dispose();
|
||||
draco.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
scene.traverse((obj: any) => {
|
||||
if (obj.geometry) obj.geometry.dispose?.();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m: any) => m.dispose?.());
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user