One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #161 from EvanTechDev/feature/create-responsive-landing-page-for-one-calendar

Create modern minimalist black-and-white landing page

authored by

Evan Huang and committed by
GitHub
cb00ae7c 4ac5fae8

+617 -523
+34
app/globals.css
··· 2 2 @tailwind components; 3 3 @tailwind utilities; 4 4 5 + html { 6 + scroll-behavior: smooth; 7 + } 8 + 5 9 body { 6 10 font-family: Arial, Helvetica, sans-serif; 7 11 } ··· 10 14 .text-balance { 11 15 text-wrap: balance; 12 16 } 17 + 18 + } 19 + 20 + @keyframes landing-title-in-view { 21 + from { 22 + opacity: 0; 23 + filter: blur(12px); 24 + transform: translateY(10px); 25 + } 26 + to { 27 + opacity: 1; 28 + filter: blur(0); 29 + transform: translateY(0); 30 + } 31 + } 32 + 33 + 34 + .landing-title { 35 + opacity: 0; 36 + filter: blur(12px); 37 + transform: translateY(10px); 38 + } 39 + 40 + .landing-title-visible { 41 + animation: landing-title-in-view 1300ms cubic-bezier(0.22, 1, 0.36, 1) both; 13 42 } 14 43 15 44 @layer components { ··· 23 52 24 53 @layer base { 25 54 :root { 55 + --landing-bg: #080808; 56 + --landing-panel: #0b0b0b; 57 + --landing-text: #ffffff; 58 + --landing-muted: #9b9b9b; 59 + --landing-subtle: #6f6f6f; 26 60 --background: 0 0% 100%; 27 61 --foreground: 0 0% 3.9%; 28 62 --card: 0 0% 100%;
+22 -523
app/page.tsx
··· 1 - "use client"; 2 - 3 - import type React from "react"; 4 - 5 - import { useState, useEffect, useRef } from "react"; 6 - import SmartSimpleBrilliant from "@/components/marketing/smart-simple-brilliant"; 7 - import YourWorkInSync from "@/components/marketing/your-work-in-sync"; 8 - import DocumentationSection from "@/components/marketing/documentation-section"; 9 - import FAQSection from "@/components/marketing/faq-section"; 10 - import PricingSection from "@/components/marketing/pricing-section"; 11 - import CTASection from "@/components/marketing/cta-section"; 12 - import FooterSection from "@/components/marketing/footer-section"; 13 - import Image from "next/image"; 14 - import { useUser } from "@clerk/nextjs"; 15 - import { useRouter } from "next/navigation"; 16 1 import { 17 - readEncryptedLocalStorage, 18 - writeEncryptedLocalStorage, 19 - } from "@/hooks/useLocalStorage"; 20 - 21 - function Badge({ icon, text }: { icon: React.ReactNode; text: string }) { 22 - return ( 23 - <div className="px-[14px] py-[6px] bg-white shadow-[0px_0px_0px_4px_rgba(55,50,47,0.05)] overflow-hidden rounded-[90px] flex justify-start items-center gap-[8px] border border-[rgba(2,6,23,0.08)] shadow-xs"> 24 - <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center"> 25 - {icon} 26 - </div> 27 - <div className="text-center flex justify-center flex-col text-[#37322F] text-xs font-medium leading-3 font-sans"> 28 - {text} 29 - </div> 30 - </div> 31 - ); 32 - } 2 + LandingHeader, 3 + LandingHero, 4 + LandingFeatures, 5 + LandingDataShowcase, 6 + LandingComparison, 7 + LandingTestimonials, 8 + LandingFaq, 9 + LandingCta, 10 + LandingFooter, 11 + } from "@/components/landing"; 33 12 34 13 export default function LandingPage() { 35 - const [activeCard, setActiveCard] = useState(0); 36 - const [progress, setProgress] = useState(0); 37 - const router = useRouter(); 38 - const { isLoaded, isSignedIn } = useUser(); 39 - const mountedRef = useRef(true); 40 - const [shouldRender, setShouldRender] = useState(false); 41 - 42 - useEffect(() => { 43 - const progressInterval = setInterval(() => { 44 - if (!mountedRef.current) return; 45 - 46 - setProgress((prev) => { 47 - if (prev >= 100) { 48 - if (mountedRef.current) { 49 - setActiveCard((current) => (current + 1) % 3); 50 - } 51 - return 0; 52 - } 53 - return prev + 2; 54 - }); 55 - }, 100); 56 - 57 - return () => { 58 - clearInterval(progressInterval); 59 - mountedRef.current = false; 60 - }; 61 - }, []); 62 - 63 - useEffect(() => { 64 - return () => { 65 - mountedRef.current = false; 66 - }; 67 - }, []); 68 - 69 - const handleCardClick = (index: number) => { 70 - if (!mountedRef.current) return; 71 - setActiveCard(index); 72 - setProgress(0); 73 - }; 74 - 75 - const getDashboardContent = () => { 76 - switch (activeCard) { 77 - case 0: 78 - return ( 79 - <div className="text-[#828387] text-sm"> 80 - Customer Subscription Status and Details 81 - </div> 82 - ); 83 - case 1: 84 - return ( 85 - <div className="text-[#828387] text-sm"> 86 - Analytics Dashboard - Real-time Insights 87 - </div> 88 - ); 89 - case 2: 90 - return ( 91 - <div className="text-[#828387] text-sm"> 92 - Data Visualization - Charts and Metrics 93 - </div> 94 - ); 95 - default: 96 - return ( 97 - <div className="text-[#828387] text-sm"> 98 - Customer Subscription Status and Details 99 - </div> 100 - ); 101 - } 102 - }; 103 - 104 - useEffect(() => { 105 - let active = true; 106 - readEncryptedLocalStorage<string | boolean | null>( 107 - "skip-landing", 108 - null, 109 - ).then((value) => { 110 - if (!active) return; 111 - const hasSkippedLanding = value === "true" || value === true; 112 - if (hasSkippedLanding || (isLoaded && isSignedIn)) { 113 - router.replace("/app"); 114 - } else if (isLoaded) { 115 - setShouldRender(true); 116 - } 117 - }); 118 - return () => { 119 - active = false; 120 - }; 121 - }, [isLoaded, isSignedIn, router]); 122 - 123 - const handleGetStarted = () => { 124 - void writeEncryptedLocalStorage("skip-landing", "true"); 125 - router.push("/app"); 126 - }; 127 - 128 - if (!shouldRender) return null; 129 - 130 14 return ( 131 - <div className="w-full min-h-screen relative bg-white overflow-x-hidden flex flex-col justify-start items-center"> 132 - <div className="relative flex flex-col justify-start items-center w-full"> 133 - {} 134 - <div className="w-full max-w-none px-4 sm:px-6 md:px-8 lg:px-0 lg:max-w-[1060px] lg:w-[1060px] relative flex flex-col justify-start items-start min-h-screen"> 135 - {} 136 - <div className="w-[1px] h-full absolute left-4 sm:left-6 md:left-8 lg:left-0 top-0 bg-[rgba(55,50,47,0.12)] shadow-[1px_0px_0px_white] z-0"></div> 137 - 138 - {} 139 - <div className="w-[1px] h-full absolute right-4 sm:right-6 md:right-8 lg:right-0 top-0 bg-[rgba(55,50,47,0.12)] shadow-[1px_0px_0px_white] z-0"></div> 140 - 141 - <div className="self-stretch pt-[9px] overflow-hidden border-b border-[rgba(55,50,47,0.06)] flex flex-col justify-center items-center gap-4 sm:gap-6 md:gap-8 lg:gap-[66px] relative z-10"> 142 - {} 143 - <div className="w-full h-12 sm:h-14 md:h-16 lg:h-[84px] absolute left-0 top-0 flex justify-center items-center z-20 px-6 sm:px-8 md:px-12 lg:px-0"> 144 - <div className="w-full h-0 absolute left-0 top-6 sm:top-7 md:top-8 lg:top-[42px] border-t border-[rgba(55,50,47,0.12)] shadow-[0px_1px_0px_white]"></div> 145 - 146 - <div className="w-full max-w-[calc(100%-32px)] sm:max-w-[calc(100%-48px)] md:max-w-[calc(100%-64px)] lg:max-w-[700px] lg:w-[700px] h-10 sm:h-11 md:h-12 py-1.5 sm:py-2 px-3 sm:px-4 md:px-4 pr-2 sm:pr-3 bg-[#F7F5F3] backdrop-blur-sm shadow-[0px_0px_0px_2px_white] overflow-hidden rounded-[50px] flex justify-between items-center relative z-30"> 147 - <div className="flex justify-center items-center"> 148 - <div className="flex justify-start items-center"> 149 - <div className="flex items-center gap-2 py-2 px-3"> 150 - <Image 151 - src="/icon.svg" 152 - alt="One Calendar" 153 - width={24} 154 - height={24} 155 - /> 156 - </div> 157 - </div> 158 - <div className="pl-3 sm:pl-4 md:pl-5 lg:pl-5 flex justify-start items-start hidden sm:flex flex-row gap-2 sm:gap-3 md:gap-4 lg:gap-4"> 159 - <div className="flex justify-start items-center"> 160 - <div className="flex flex-col justify-center text-[rgba(49,45,43,0.80)] text-xs md:text-[13px] font-medium leading-[14px] font-sans"> 161 - <a href="/about">About</a> 162 - </div> 163 - </div> 164 - </div> 165 - </div> 166 - <div className="h-6 sm:h-7 md:h-8 flex justify-start items-start gap-2 sm:gap-3"> 167 - <div className="px-2 sm:px-3 md:px-[14px] py-1 sm:py-[6px] bg-white shadow-[0px_1px_2px_rgba(55,50,47,0.12)] overflow-hidden rounded-full flex justify-center items-center"> 168 - <div className="flex flex-col justify-center text-[#37322F] text-xs md:text-[13px] font-medium leading-5 font-sans"> 169 - <a href="/sign-in">Log in</a> 170 - </div> 171 - </div> 172 - </div> 173 - </div> 174 - </div> 175 - 176 - {} 177 - <div className="pt-16 sm:pt-20 md:pt-24 lg:pt-[216px] pb-8 sm:pb-12 md:pb-16 flex flex-col justify-start items-center px-2 sm:px-4 md:px-8 lg:px-0 w-full sm:pl-0 sm:pr-0 pl-0 pr-0"> 178 - <div className="w-full max-w-[937px] lg:w-[937px] flex flex-col justify-center items-center gap-3 sm:gap-4 md:gap-5 lg:gap-6"> 179 - <div className="self-stretch rounded-[3px] flex flex-col justify-center items-center gap-4 sm:gap-5 md:gap-6 lg:gap-8"> 180 - <div className="w-full max-w-[748.71px] lg:w-[748.71px] text-center flex justify-center flex-col text-[#37322F] text-[62px] font-medium leading-[1.1] sm:leading-[1.15] md:leading-[1.2] lg:leading-24 font-sans px-2 sm:px-4 md:px-0"> 181 - Time-Saving AI Calendar 182 - <br /> 183 - Designed for Efficiency 184 - </div> 185 - <div className="w-full max-w-[506.08px] lg:w-[506.08px] text-center flex justify-center flex-col text-[rgba(55,50,47,0.80)] sm:text-lg md:text-xl leading-[1.4] sm:leading-[1.45] md:leading-[1.5] lg:leading-7 font-sans px-2 sm:px-4 md:px-0 lg:text-lg font-medium text-sm"> 186 - Simplify schedule and securely store calendar data 187 - <br className="hidden sm:block" /> 188 - with One Calendars tailored, seamless auto solutions 189 - </div> 190 - </div> 191 - </div> 192 - 193 - <a href="/sign-up"> 194 - <div className="w-full max-w-[497px] lg:w-[497px] flex flex-col justify-center items-center gap-6 sm:gap-8 md:gap-10 lg:gap-12 relative z-10 mt-6 sm:mt-8 md:mt-10 lg:mt-12"> 195 - <div className="backdrop-blur-[8.25px] flex justify-start items-center gap-4"> 196 - <div className="h-10 sm:h-11 md:h-12 px-6 sm:px-8 md:px-10 lg:px-12 py-2 sm:py-[6px] relative bg-[#37322F] shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset] overflow-hidden rounded-full flex justify-center items-center"> 197 - <div className="w-20 sm:w-24 md:w-28 lg:w-44 h-[41px] absolute left-0 top-[-0.5px] bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(0,0,0,0.10)] mix-blend-multiply"></div> 198 - <div className="flex flex-col justify-center text-white text-sm sm:text-base md:text-[15px] font-medium leading-5 font-sans"> 199 - Get Started 200 - </div> 201 - </div> 202 - </div> 203 - </div> 204 - </a> 205 - 206 - <div className="absolute top-[232px] sm:top-[248px] md:top-[264px] lg:top-[320px] left-1/2 transform -translate-x-1/2 z-0 pointer-events-none"> 207 - <img 208 - src="/mask-group-pattern.svg" 209 - alt="" 210 - className="w-[936px] sm:w-[1404px] md:w-[2106px] lg:w-[2808px] h-auto opacity-30 sm:opacity-40 md:opacity-50 mix-blend-multiply" 211 - style={{ 212 - filter: "hue-rotate(15deg) saturate(0.7) brightness(1.2)", 213 - }} 214 - /> 215 - </div> 216 - 217 - <div className="w-full max-w-[960px] lg:w-[960px] pt-2 sm:pt-4 pb-6 sm:pb-8 md:pb-10 px-2 sm:px-4 md:px-6 lg:px-11 flex flex-col justify-center items-center gap-2 relative z-5 my-8 sm:my-12 md:my-16 lg:my-16 mb-0 lg:pb-0"> 218 - <div className="w-full max-w-[960px] lg:w-[960px] h-[200px] sm:h-[280px] md:h-[450px] lg:h-[695.55px] bg-white shadow-[0px_0px_0px_0.9056603908538818px_rgba(0,0,0,0.08)] overflow-hidden rounded-[6px] sm:rounded-[8px] lg:rounded-[9.06px] flex flex-col justify-start items-start"> 219 - {} 220 - <div className="self-stretch flex-1 flex justify-start items-start"> 221 - {} 222 - <div className="w-full h-full flex items-center justify-center"> 223 - <div className="relative w-full h-full overflow-hidden"> 224 - {} 225 - <div 226 - className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 227 - activeCard === 0 228 - ? "opacity-100 scale-100 blur-0" 229 - : "opacity-0 scale-95 blur-sm" 230 - }`} 231 - > 232 - <img 233 - src="/Banner.jpg" 234 - alt="Schedules Dashboard - Customer Subscription Management" 235 - className="w-full h-full object-cover" 236 - /> 237 - </div> 238 - 239 - {} 240 - <div 241 - className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 242 - activeCard === 1 243 - ? "opacity-100 scale-100 blur-0" 244 - : "opacity-0 scale-95 blur-sm" 245 - }`} 246 - > 247 - <img 248 - src="/A.jpg" 249 - alt="Analytics Dashboard" 250 - className="w-full h-full object-cover" 251 - /> 252 - </div> 253 - 254 - {} 255 - <div 256 - className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 257 - activeCard === 2 258 - ? "opacity-100 scale-100 blur-0" 259 - : "opacity-0 scale-95 blur-sm" 260 - }`} 261 - > 262 - <img 263 - src="/S.jpg" 264 - alt="Share Page" 265 - className="w-full h-full object-contain" 266 - /> 267 - </div> 268 - </div> 269 - </div> 270 - </div> 271 - </div> 272 - </div> 273 - 274 - <div className="self-stretch border-t border-[#E0DEDB] border-b border-[#E0DEDB] flex justify-center items-start"> 275 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 276 - {} 277 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 278 - {Array.from({ length: 50 }).map((_, i) => ( 279 - <div 280 - key={i} 281 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 282 - ></div> 283 - ))} 284 - </div> 285 - </div> 286 - 287 - <div className="flex-1 px-0 sm:px-2 md:px-0 flex flex-col md:flex-row justify-center items-stretch gap-0"> 288 - {} 289 - <FeatureCard 290 - title="Plan your schedules" 291 - description="Plan your schedules and manage your calendars with One Calendar." 292 - isActive={activeCard === 0} 293 - progress={activeCard === 0 ? progress : 0} 294 - onClick={() => handleCardClick(0)} 295 - /> 296 - <FeatureCard 297 - title="Analytics & insights" 298 - description="Transform your calendar data into actionable insights with real-time analytics." 299 - isActive={activeCard === 1} 300 - progress={activeCard === 1 ? progress : 0} 301 - onClick={() => handleCardClick(1)} 302 - /> 303 - <FeatureCard 304 - title="Password share" 305 - description="Use password share to protect your calendar data security." 306 - isActive={activeCard === 2} 307 - progress={activeCard === 2 ? progress : 0} 308 - onClick={() => handleCardClick(2)} 309 - /> 310 - </div> 311 - 312 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 313 - {} 314 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 315 - {Array.from({ length: 50 }).map((_, i) => ( 316 - <div 317 - key={i} 318 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 319 - ></div> 320 - ))} 321 - </div> 322 - </div> 323 - </div> 324 - 325 - {} 326 - {} 327 - 328 - {} 329 - <div className="w-full border-b border-[rgba(55,50,47,0.12)] flex flex-col justify-center items-center"> 330 - {} 331 - <div className="self-stretch px-4 sm:px-6 md:px-8 lg:px-0 lg:max-w-[1060px] lg:w-[1060px] py-8 sm:py-12 md:py-16 border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6"> 332 - <div className="w-full max-w-[616px] lg:w-[616px] px-4 sm:px-6 py-4 sm:py-5 shadow-[0px_2px_4px_rgba(50,45,43,0.06)] overflow-hidden rounded-lg flex flex-col justify-start items-center gap-3 sm:gap-4 shadow-none"> 333 - <Badge 334 - icon={ 335 - <svg 336 - width="12" 337 - height="12" 338 - viewBox="0 0 12 12" 339 - fill="none" 340 - xmlns="http://www.w3.org/2000/svg" 341 - > 342 - <rect 343 - x="1" 344 - y="1" 345 - width="4" 346 - height="4" 347 - stroke="#37322F" 348 - strokeWidth="1" 349 - fill="none" 350 - /> 351 - <rect 352 - x="7" 353 - y="1" 354 - width="4" 355 - height="4" 356 - stroke="#37322F" 357 - strokeWidth="1" 358 - fill="none" 359 - /> 360 - <rect 361 - x="1" 362 - y="7" 363 - width="4" 364 - height="4" 365 - stroke="#37322F" 366 - strokeWidth="1" 367 - fill="none" 368 - /> 369 - <rect 370 - x="7" 371 - y="7" 372 - width="4" 373 - height="4" 374 - stroke="#37322F" 375 - strokeWidth="1" 376 - fill="none" 377 - /> 378 - </svg> 379 - } 380 - text="Bento grid" 381 - /> 382 - <div className="w-full max-w-[598.06px] lg:w-[598.06px] text-center flex justify-center flex-col text-[#49423D] text-xl sm:text-2xl md:text-3xl lg:text-5xl font-semibold leading-tight md:leading-[60px] font-sans tracking-tight"> 383 - Built for absolute clarity and focused work 384 - </div> 385 - <div className="self-stretch text-center text-[#605A57] text-sm sm:text-base font-normal leading-6 sm:leading-7 font-sans"> 386 - Stay focused with tools that organize, connect 387 - <br /> 388 - and turn information into confident decisions. 389 - </div> 390 - </div> 391 - </div> 392 - 393 - {} 394 - <div className="self-stretch flex justify-center items-start"> 395 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 396 - {} 397 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 398 - {Array.from({ length: 200 }).map((_, i) => ( 399 - <div 400 - key={i} 401 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 402 - /> 403 - ))} 404 - </div> 405 - </div> 406 - 407 - <div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-0 border-l border-r border-[rgba(55,50,47,0.12)]"> 408 - {} 409 - <div className="border-b border-r-0 md:border-r border-[rgba(55,50,47,0.12)] p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6"> 410 - <div className="flex flex-col gap-2"> 411 - <h3 className="text-[#37322F] text-lg sm:text-xl font-semibold leading-tight font-sans"> 412 - Smart. Simple. Brilliant. 413 - </h3> 414 - <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 415 - Your data is beautifully organized so you see 416 - everything clearly without the clutter. 417 - </p> 418 - </div> 419 - <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex items-center justify-center overflow-hidden"> 420 - <SmartSimpleBrilliant 421 - width="100%" 422 - height="100%" 423 - theme="light" 424 - className="scale-50 sm:scale-65 md:scale-75 lg:scale-90" 425 - /> 426 - </div> 427 - </div> 428 - 429 - {} 430 - <div className="border-b border-[rgba(55,50,47,0.12)] p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6"> 431 - <div className="flex flex-col gap-2"> 432 - <h3 className="text-[#37322F] font-semibold leading-tight font-sans text-lg sm:text-xl"> 433 - Your personal AI assistant. 434 - </h3> 435 - <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 436 - Every users can talk to our One AI and get fast 437 - response 438 - </p> 439 - </div> 440 - <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex overflow-hidden text-right items-center justify-center"> 441 - <YourWorkInSync 442 - width="400" 443 - height="250" 444 - theme="light" 445 - className="scale-60 sm:scale-75 md:scale-90" 446 - /> 447 - </div> 448 - </div> 449 - 450 - {} 451 - </div> 452 - 453 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 454 - {} 455 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 456 - {Array.from({ length: 200 }).map((_, i) => ( 457 - <div 458 - key={i} 459 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 460 - /> 461 - ))} 462 - </div> 463 - </div> 464 - </div> 465 - </div> 466 - 467 - {} 468 - <DocumentationSection /> 469 - 470 - {} 471 - 472 - {} 473 - <PricingSection /> 474 - 475 - {} 476 - <FAQSection /> 477 - 478 - {} 479 - <CTASection /> 480 - 481 - {} 482 - <FooterSection /> 483 - </div> 484 - </div> 485 - </div> 15 + <main className="min-h-screen bg-[var(--landing-bg)] text-[var(--landing-text)]"> 16 + <div className="mx-auto flex w-full max-w-7xl flex-col px-6 md:px-10"> 17 + <LandingHeader /> 18 + <LandingHero /> 19 + <LandingFeatures /> 20 + <LandingDataShowcase /> 21 + <LandingComparison /> 22 + <LandingTestimonials /> 23 + <LandingFaq /> 24 + <LandingCta /> 486 25 </div> 487 - </div> 488 - ); 489 - } 490 - 491 - function FeatureCard({ 492 - title, 493 - description, 494 - isActive, 495 - progress, 496 - onClick, 497 - }: { 498 - title: string; 499 - description: string; 500 - isActive: boolean; 501 - progress: number; 502 - onClick: () => void; 503 - }) { 504 - return ( 505 - <div 506 - className={`w-full md:flex-1 self-stretch px-6 py-5 overflow-hidden flex flex-col justify-start items-start gap-2 cursor-pointer relative border-b md:border-b-0 last:border-b-0 ${ 507 - isActive 508 - ? "bg-white shadow-[0px_0px_0px_0.75px_#E0DEDB_inset]" 509 - : "border-l-0 border-r-0 md:border border-[#E0DEDB]/80" 510 - }`} 511 - onClick={onClick} 512 - > 513 - {isActive && ( 514 - <div className="absolute top-0 left-0 w-full h-0.5 bg-[rgba(50,45,43,0.08)]"> 515 - <div 516 - className="h-full bg-[#322D2B] transition-all duration-100 ease-linear" 517 - style={{ width: `${progress}%` }} 518 - /> 519 - </div> 520 - )} 521 - 522 - <div className="self-stretch flex justify-center flex-col text-[#49423D] text-sm md:text-sm font-semibold leading-6 md:leading-6 font-sans"> 523 - {title} 524 - </div> 525 - <div className="self-stretch text-[#605A57] text-[13px] md:text-[13px] font-normal leading-[22px] md:leading-[22px] font-sans"> 526 - {description} 527 - </div> 528 - </div> 26 + <LandingFooter /> 27 + </main> 529 28 ); 530 29 }
+11
components/landing/index.ts
··· 1 + export { LandingHeader } from "./landing-header"; 2 + export { LandingHero } from "./landing-hero"; 3 + export { LandingDataShowcase } from "./landing-data-showcase"; 4 + export { LandingFeatures } from "./landing-features"; 5 + export { LandingComparison } from "./landing-comparison"; 6 + export { LandingTestimonials } from "./landing-testimonials"; 7 + export { LandingFaq } from "./landing-faq"; 8 + export { LandingCta } from "./landing-cta"; 9 + export { LandingFooter } from "./landing-footer"; 10 + 11 + export { LandingTitle } from "./landing-title";
+42
components/landing/landing-comparison.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 3 + const rows = [ 4 + { label: "End-to-end encryption (E2EE)", one: "โœ…", google: "โŒ", proton: "โœ…" }, 5 + { label: "No analytics by default", one: "โœ…", google: "โŒ", proton: "โœ…" }, 6 + { label: "ICS import / export", one: "โœ…", google: "โœ…", proton: "โœ…" }, 7 + { label: "Keyboard shortcuts", one: "โœ…", google: "โœ…", proton: "โœ…" }, 8 + { label: "Custom themes", one: "โœ…", google: "โš ๏ธ", proton: "โš ๏ธ" }, 9 + ]; 10 + 11 + export function LandingComparison() { 12 + return ( 13 + <section className="border-b border-white/10 py-24 md:py-28"> 14 + <div className="grid gap-10 lg:grid-cols-[1fr_1fr]"> 15 + <LandingTitle as="h2" className="text-3xl font-semibold text-white md:text-5xl"> 16 + Privacy at a glance. 17 + </LandingTitle> 18 + <p className="max-w-xl text-base text-[var(--landing-muted)] md:text-lg"> 19 + A quick snapshot from the repository comparison table, focused on encryption, tracking defaults, and data portability. 20 + </p> 21 + </div> 22 + 23 + <div className="mt-10 overflow-hidden rounded-2xl border border-white/10"> 24 + <div className="grid border-b border-white/10 bg-white/[0.02] text-xs uppercase tracking-[0.18em] text-[var(--landing-subtle)] md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr]"> 25 + <div className="p-4">Feature</div> 26 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">One Calendar</div> 27 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Google</div> 28 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Proton</div> 29 + </div> 30 + 31 + {rows.map((row) => ( 32 + <div key={row.label} className="grid text-sm md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr] md:text-base"> 33 + <div className="border-b border-white/10 p-4 text-white">{row.label}</div> 34 + <div className="border-b border-white/10 p-4 text-white md:border-l">{row.one}</div> 35 + <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.google}</div> 36 + <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.proton}</div> 37 + </div> 38 + ))} 39 + </div> 40 + </section> 41 + ); 42 + }
+29
components/landing/landing-cta.tsx
··· 1 + export function LandingCta() { 2 + return ( 3 + <section className="py-24 text-center md:py-28"> 4 + <p className="text-xs uppercase tracking-[0.28em] text-[var(--landing-subtle)]">Ready to simplify planning</p> 5 + <h2 className="mx-auto mt-4 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl"> 6 + Your time. Your data. Yours. 7 + </h2> 8 + <p className="mx-auto mt-4 max-w-2xl text-sm text-[var(--landing-muted)] md:text-base"> 9 + Keep your schedule clear with privacy-first defaults, portable formats, and dependable sync. 10 + </p> 11 + <div className="mt-8 flex flex-wrap justify-center gap-3"> 12 + <a 13 + href="/sign-up" 14 + aria-label="Create your free account" 15 + className="rounded-md bg-white px-6 py-2.5 text-sm font-medium text-black transition duration-200 hover:-translate-y-0.5 hover:brightness-110" 16 + > 17 + Start free 18 + </a> 19 + <a 20 + href="https://docs.xyehr.cn/docs/one-calendar" 21 + aria-label="Read product documentation" 22 + className="rounded-md border border-white/20 px-6 py-2.5 text-sm text-[var(--landing-muted)] transition duration-200 hover:-translate-y-0.5 hover:border-white/35 hover:text-white" 23 + > 24 + Read docs 25 + </a> 26 + </div> 27 + </section> 28 + ); 29 + }
+49
components/landing/landing-data-showcase.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 3 + const metrics = [ 4 + { label: "Locale packs", value: "35" }, 5 + { label: "Theme options", value: "5" }, 6 + { label: "Import formats", value: "3" }, 7 + ]; 8 + 9 + const stack = [ 10 + "Open-source and privacy-first", 11 + "Optional cloud sync with PostgreSQL", 12 + "Authentication with Clerk", 13 + "Import/export support for .ics, .json, .csv", 14 + ]; 15 + 16 + export function LandingDataShowcase() { 17 + return ( 18 + <section id="data" className="border-b border-white/10 py-24 md:py-28"> 19 + <LandingTitle as="h2" className="text-3xl font-semibold leading-tight text-white md:text-5xl"> 20 + Trusted data. 21 + <br /> 22 + Clear architecture. 23 + </LandingTitle> 24 + <p className="mt-4 max-w-3xl text-base text-[var(--landing-muted)] md:text-lg"> 25 + Practical metrics and straightforward infrastructure choices, without black-box behavior. 26 + </p> 27 + 28 + <div className="mt-12 grid gap-10 lg:grid-cols-[1.2fr_1fr]"> 29 + <div className="grid gap-6 md:grid-cols-3"> 30 + {metrics.map((metric) => ( 31 + <div key={metric.label} className="border-b border-white/15 pb-4"> 32 + <p className="text-4xl font-semibold text-white">{metric.value}</p> 33 + <p className="mt-2 text-sm uppercase tracking-[0.12em] text-[var(--landing-subtle)]">{metric.label}</p> 34 + </div> 35 + ))} 36 + </div> 37 + 38 + <div className="space-y-4"> 39 + {stack.map((item, idx) => ( 40 + <div key={item} className="flex items-start gap-3 border-l border-white/15 pl-4"> 41 + <span className="mt-0.5 text-xs text-[var(--landing-subtle)]">0{idx + 1}</span> 42 + <p className="text-sm text-[var(--landing-muted)] md:text-base">{item}</p> 43 + </div> 44 + ))} 45 + </div> 46 + </div> 47 + </section> 48 + ); 49 + }
+50
components/landing/landing-faq.tsx
··· 1 + import { 2 + Accordion, 3 + AccordionContent, 4 + AccordionItem, 5 + AccordionTrigger, 6 + } from "@/components/ui/accordion"; 7 + import { LandingTitle } from "./landing-title"; 8 + 9 + const faqItems = [ 10 + { 11 + q: "What is One Calendar's product direction?", 12 + a: "One Calendar is positioned as a privacy-first, planning-focused open-source calendar built for clarity and control.", 13 + }, 14 + { 15 + q: "Which import formats are supported?", 16 + a: "The project documents support for iCalendar (.ics), JSON, and CSV import/export workflows.", 17 + }, 18 + { 19 + q: "Does One Calendar support encrypted data handling?", 20 + a: "Yes. The README lists optional end-to-end encryption (E2EE) support for privacy-sensitive usage.", 21 + }, 22 + { 23 + q: "What stack powers cloud sync and auth?", 24 + a: "Cloud sync is described as optional and PostgreSQL-based, while authentication is handled through Clerk.", 25 + }, 26 + ]; 27 + 28 + export function LandingFaq() { 29 + return ( 30 + <section id="faq" className="border-b border-white/10 py-24 md:py-28"> 31 + <div className="grid w-full gap-8 md:grid-cols-[300px_1fr]"> 32 + <LandingTitle as="h2" className="text-3xl font-semibold text-white md:text-5xl">FAQ</LandingTitle> 33 + <div className="px-0 md:px-2"> 34 + <Accordion type="single" collapsible className="w-full"> 35 + {faqItems.map((item, idx) => ( 36 + <AccordionItem key={item.q} value={`faq-${idx}`} className="border-white/10"> 37 + <AccordionTrigger className="py-5 text-left text-base font-medium text-white hover:no-underline"> 38 + {item.q} 39 + </AccordionTrigger> 40 + <AccordionContent className="pb-5 text-sm text-[var(--landing-muted)] md:text-base"> 41 + {item.a} 42 + </AccordionContent> 43 + </AccordionItem> 44 + ))} 45 + </Accordion> 46 + </div> 47 + </div> 48 + </section> 49 + ); 50 + }
+48
components/landing/landing-features.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 3 + const features = [ 4 + { 5 + title: "Fast planning", 6 + description: "Drag, resize, and edit events inline without modal-heavy flow.", 7 + icon: <path d="M4 10h24M8 4v8m16-8v8M5 18h22M5 24h12" />, 8 + }, 9 + { 10 + title: "Privacy by default", 11 + description: "No analytics scripts by default with optional end-to-end encryption.", 12 + icon: <path d="M16 4 6 9v7c0 6 4 9 10 12 6-3 10-6 10-12V9L16 4Zm0 8v7m-3-4h6" />, 13 + }, 14 + { 15 + title: "Open and portable", 16 + description: "Import/export with .ics, .json, and .csv while keeping full control.", 17 + icon: <path d="M16 4v16m0 0-5-5m5 5 5-5M5 26h22" />, 18 + }, 19 + ]; 20 + 21 + export function LandingFeatures() { 22 + return ( 23 + <section id="features" className="border-y border-white/10 py-24 md:py-28"> 24 + <div className="grid gap-10 lg:grid-cols-[1fr_1fr] lg:items-start"> 25 + <LandingTitle as="h2" className="text-3xl font-semibold leading-tight text-white md:text-5xl"> 26 + Fast planning. 27 + <br /> 28 + Less noise. 29 + </LandingTitle> 30 + <p className="max-w-xl text-base text-[var(--landing-muted)] md:text-lg"> 31 + Every interaction is designed to keep flow intact: fast edits, secure defaults, and formats that remain portable across tools. 32 + </p> 33 + </div> 34 + 35 + <div className="mt-14 grid gap-8 md:grid-cols-3 md:gap-0"> 36 + {features.map((feature, index) => ( 37 + <article key={feature.title} className={`px-0 md:px-8 ${index !== 2 ? "md:border-r md:border-white/10" : ""}`}> 38 + <svg viewBox="0 0 32 32" aria-hidden="true" className="mb-5 h-9 w-9 stroke-white" fill="none" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> 39 + {feature.icon} 40 + </svg> 41 + <LandingTitle as="h3" className="text-xl font-medium text-white">{feature.title}</LandingTitle> 42 + <p className="mt-3 text-sm leading-relaxed text-[var(--landing-subtle)]">{feature.description}</p> 43 + </article> 44 + ))} 45 + </div> 46 + </section> 47 + ); 48 + }
+59
components/landing/landing-footer.tsx
··· 1 + import Image from "next/image"; 2 + 3 + const footerColumns = [ 4 + { title: "Product", links: [{ label: "Overview", href: "#features" }, { label: "About", href: "/about" }] }, 5 + { 6 + title: "Resources", 7 + links: [ 8 + { label: "Documentation", href: "https://docs.xyehr.cn/docs/one-calendar" }, 9 + { label: "Status", href: "https://calendarstatus.xyehr.cn" }, 10 + { label: "Support", href: "mailto:evan.huang000@proton.me" }, 11 + ], 12 + }, 13 + { 14 + title: "Connect", 15 + links: [ 16 + { label: "Contact", href: "mailto:evan.huang000@proton.me" }, 17 + { label: "Bluesky", href: "https://bsky.app/profile/calendar.xyehr.cn" }, 18 + { label: "GitHub", href: "https://github.com/EvanTechDev/One-Calendar" }, 19 + ], 20 + }, 21 + ]; 22 + 23 + export function LandingFooter() { 24 + return ( 25 + <footer className="px-6 pb-10 pt-12 text-[var(--landing-subtle)] md:px-10"> 26 + <div className="mx-auto max-w-7xl border-t border-white/10 pt-10"> 27 + <div className="grid gap-10 md:grid-cols-4"> 28 + <div> 29 + <a href="#top" aria-label="One Calendar home" className="inline-flex items-center gap-2 text-white transition hover:brightness-110"> 30 + <Image src="/icon.svg" alt="One Calendar logo" width={20} height={20} className="h-5 w-5" /> 31 + <span className="text-sm font-medium">One Calendar</span> 32 + </a> 33 + </div> 34 + 35 + {footerColumns.map((column) => ( 36 + <div key={column.title}> 37 + <p className="text-sm font-medium text-white">{column.title}</p> 38 + <ul className="mt-4 space-y-3 text-sm text-[var(--landing-muted)]"> 39 + {column.links.map((link) => ( 40 + <li key={link.label}> 41 + <a href={link.href} className="transition duration-200 hover:-translate-y-0.5 hover:text-white"> 42 + {link.label} 43 + </a> 44 + </li> 45 + ))} 46 + </ul> 47 + </div> 48 + ))} 49 + </div> 50 + 51 + <div className="mt-10 flex flex-wrap items-center gap-6 border-t border-white/10 pt-5 text-xs text-[var(--landing-subtle)]"> 52 + <a href="#" className="transition hover:text-white">Privacy</a> 53 + <a href="#" className="transition hover:text-white">Terms</a> 54 + <p>ยฉ {new Date().getFullYear()} One Calendar. All rights reserved.</p> 55 + </div> 56 + </div> 57 + </footer> 58 + ); 59 + }
+43
components/landing/landing-header.tsx
··· 1 + import Image from "next/image"; 2 + 3 + const navLinks = [ 4 + { label: "Overview", href: "#features" }, 5 + { label: "Features", href: "#features" }, 6 + { label: "Data", href: "#data" }, 7 + { label: "FAQ", href: "#faq" }, 8 + { label: "About", href: "/about" }, 9 + ]; 10 + 11 + export function LandingHeader() { 12 + return ( 13 + <header className="sticky top-0 z-30 border-b border-white/10 bg-[var(--landing-bg)]/90 py-4 backdrop-blur"> 14 + <nav className="flex items-center justify-between gap-6"> 15 + <a href="#top" aria-label="One Calendar home" className="flex items-center gap-2 text-white transition hover:brightness-110"> 16 + <Image src="/icon.svg" alt="One Calendar logo" width={20} height={20} className="h-5 w-5" /> 17 + <span className="text-lg font-semibold tracking-tight">One Calendar</span> 18 + </a> 19 + 20 + <div className="hidden items-center gap-7 text-sm text-[var(--landing-muted)] md:flex"> 21 + {navLinks.map((link) => ( 22 + <a key={link.label} href={link.href} className="transition duration-200 hover:-translate-y-0.5 hover:text-white"> 23 + {link.label} 24 + </a> 25 + ))} 26 + </div> 27 + 28 + <div className="flex items-center gap-3"> 29 + <a href="/sign-in" aria-label="Log in" className="text-sm text-[var(--landing-muted)] transition duration-200 hover:-translate-y-0.5 hover:text-white"> 30 + Log in 31 + </a> 32 + <a 33 + href="/sign-up" 34 + aria-label="Sign up" 35 + className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black transition duration-200 hover:-translate-y-0.5 hover:brightness-110" 36 + > 37 + Sign up 38 + </a> 39 + </div> 40 + </nav> 41 + </header> 42 + ); 43 + }
+65
components/landing/landing-hero-demo.tsx
··· 1 + "use client"; 2 + 3 + import { cn } from "@/lib/utils"; 4 + import { LandingTitle } from "./landing-title"; 5 + 6 + type DemoEvent = { 7 + id: string; 8 + title: string; 9 + start: string; 10 + end: string; 11 + tone: string; 12 + accent: string; 13 + }; 14 + 15 + const demoEvents: DemoEvent[] = [ 16 + { id: "1", title: "Design review", start: "10:00", end: "10:45", tone: "bg-[#1a2430]", accent: "#6ea8ff" }, 17 + { id: "2", title: "Roadmap sync", start: "13:30", end: "14:15", tone: "bg-[#1a2a22]", accent: "#5bcf9a" }, 18 + { id: "3", title: "Focus block", start: "15:00", end: "17:00", tone: "bg-[#261f33]", accent: "#b18cff" }, 19 + { id: "4", title: "Release review", start: "17:30", end: "18:00", tone: "bg-[#31201f]", accent: "#ffab8a" }, 20 + ]; 21 + 22 + const encryptedRows = [ 23 + "2nd49snxieNwi29Dnejs", 24 + "4fK29xneJ2qLs09PzVaa", 25 + "A0zX19pwQm7RtL2he81n", 26 + "n0Mqe28XvLp31sTTad90", 27 + "dN7qa21PoxM44jvR8tyk", 28 + "Qv4mL2zPaa11Nwe8sX0t", 29 + "T7ePq82sLmN4xR3vA11f", 30 + "zP8wN2kLmQ1vD45sA0xe", 31 + ]; 32 + 33 + function WeekViewEventBlock({ event }: { event: DemoEvent }) { 34 + return ( 35 + <div className={cn("relative overflow-hidden rounded-lg p-2 text-sm", event.tone)}> 36 + <div className="absolute left-0 top-0 h-full w-1 rounded-l-md" style={{ backgroundColor: event.accent }} /> 37 + <div className="pl-1"> 38 + <p className="font-medium leading-tight" style={{ color: event.accent }}>{event.title}</p> 39 + <p className="text-xs" style={{ color: event.accent }}>{event.start} - {event.end}</p> 40 + </div> 41 + </div> 42 + ); 43 + } 44 + 45 + export function LandingHeroDemo() { 46 + return ( 47 + <div className="mt-8 grid gap-4 lg:grid-cols-[1.2fr_1fr]"> 48 + <div className="rounded-xl border border-white/10 bg-[var(--landing-panel)] p-4"> 49 + <LandingTitle as="p" className="mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Week View</LandingTitle> 50 + <div className="space-y-2"> 51 + {demoEvents.map((event) => <WeekViewEventBlock key={event.id} event={event} />)} 52 + </div> 53 + </div> 54 + 55 + <div className="rounded-xl border border-white/10 bg-[var(--landing-panel)] p-4"> 56 + <LandingTitle as="p" className="mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Encrypted Stream</LandingTitle> 57 + <div className="space-y-2 font-mono text-[12px] text-white/75"> 58 + {encryptedRows.map((line) => ( 59 + <p key={line}>{line}</p> 60 + ))} 61 + </div> 62 + </div> 63 + </div> 64 + ); 65 + }
+42
components/landing/landing-hero.tsx
··· 1 + import Image from "next/image"; 2 + import bannerDark from "@/public/Banner-dark.jpg"; 3 + import { LandingHeroDemo } from "./landing-hero-demo"; 4 + import { LandingTitle } from "./landing-title"; 5 + 6 + export function LandingHero() { 7 + return ( 8 + <section id="top" className="py-16 md:py-24"> 9 + <div className="mx-auto max-w-4xl text-center"> 10 + <LandingTitle as="h1" className="text-4xl font-semibold leading-tight tracking-tight text-white md:text-[56px]"> 11 + The calendar that keeps 12 + <br /> 13 + your life private 14 + </LandingTitle> 15 + <p className="mx-auto mt-5 max-w-2xl text-sm text-[var(--landing-muted)] md:text-base"> 16 + Secure by design. Powerful by default. 17 + </p> 18 + <div className="mt-8 flex justify-center gap-3"> 19 + <a href="/sign-up" aria-label="Get started" className="rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition duration-200 hover:-translate-y-0.5 hover:brightness-110"> 20 + Get started 21 + </a> 22 + <a href="#features" aria-label="View product features" className="rounded-md border border-white/15 px-5 py-2.5 text-sm text-[var(--landing-muted)] transition duration-200 hover:-translate-y-0.5 hover:border-white/30 hover:text-white"> 23 + View features 24 + </a> 25 + </div> 26 + </div> 27 + 28 + <div className="mt-12"> 29 + <Image 30 + src={bannerDark} 31 + alt="One Calendar dark preview" 32 + className="h-auto w-full" 33 + priority 34 + placeholder="blur" 35 + sizes="(max-width: 1024px) 100vw, 1200px" 36 + /> 37 + </div> 38 + 39 + <LandingHeroDemo /> 40 + </section> 41 + ); 42 + }
+80
components/landing/landing-testimonials.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 3 + const highlights = [ 4 + { 5 + icon: ( 6 + <path d="M4 16h24M4 22h18M4 10h24M4 4h14" /> 7 + ), 8 + title: "Clarity over complexity", 9 + detail: "A calm planning flow instead of overloaded automation and noisy dashboards.", 10 + }, 11 + { 12 + icon: ( 13 + <path d="M16 4 6 9v7c0 6 4 9 10 12 6-3 10-6 10-12V9L16 4Zm-3 12 2 2 4-5" /> 14 + ), 15 + title: "Privacy-first defaults", 16 + detail: "No analytics by default, with optional end-to-end encryption for sensitive data.", 17 + }, 18 + { 19 + icon: ( 20 + <path d="M6 24h20M10 20V8m6 12V4m6 16v-9" /> 21 + ), 22 + title: "Portable workflows", 23 + detail: "Import/export support keeps your data usable across ecosystems without lock-in.", 24 + }, 25 + { 26 + icon: ( 27 + <path d="M5 11h22M5 17h22M8 5v18m14-18v18" /> 28 + ), 29 + title: "Fast team coordination", 30 + detail: "Weekly scheduling, quick edits, and structure that scales from solo to team use.", 31 + }, 32 + ]; 33 + 34 + const stats = [ 35 + { value: "35", label: "Locales" }, 36 + { value: "5", label: "Themes" }, 37 + { value: "3", label: "Import formats" }, 38 + { value: "E2EE", label: "Optional" }, 39 + ]; 40 + 41 + export function LandingTestimonials() { 42 + return ( 43 + <section className="border-b border-white/10 py-24 md:py-28"> 44 + <LandingTitle as="h2" className="text-center text-3xl font-semibold text-white md:text-5xl"> 45 + Why One Calendar 46 + </LandingTitle> 47 + 48 + <div className="mt-10 grid gap-6 border-y border-white/10 py-6 md:grid-cols-4"> 49 + {stats.map((item) => ( 50 + <div key={item.label} className="text-center md:text-left"> 51 + <p className="text-3xl font-semibold text-white">{item.value}</p> 52 + <p className="mt-1 text-xs uppercase tracking-[0.12em] text-[var(--landing-subtle)]">{item.label}</p> 53 + </div> 54 + ))} 55 + </div> 56 + 57 + <div className="mt-10 grid gap-4 md:grid-cols-2"> 58 + {highlights.map((item) => ( 59 + <article key={item.title} className="rounded-xl border border-white/10 p-5"> 60 + <svg 61 + viewBox="0 0 32 32" 62 + aria-hidden="true" 63 + className="mb-4 h-7 w-7 stroke-white" 64 + fill="none" 65 + strokeWidth="1.5" 66 + strokeLinecap="round" 67 + strokeLinejoin="round" 68 + > 69 + {item.icon} 70 + </svg> 71 + <LandingTitle as="h3" className="text-lg font-medium text-white md:text-xl"> 72 + {item.title} 73 + </LandingTitle> 74 + <p className="mt-2 text-sm leading-relaxed text-[var(--landing-muted)] md:text-base">{item.detail}</p> 75 + </article> 76 + ))} 77 + </div> 78 + </section> 79 + ); 80 + }
+43
components/landing/landing-title.tsx
··· 1 + "use client"; 2 + 3 + import { cn } from "@/lib/utils"; 4 + import { type ElementType, type ReactNode, useEffect, useRef, useState } from "react"; 5 + 6 + type LandingTitleProps<T extends ElementType> = { 7 + as?: T; 8 + className?: string; 9 + children: ReactNode; 10 + }; 11 + 12 + export function LandingTitle<T extends ElementType = "h2">({ 13 + as, 14 + className, 15 + children, 16 + }: LandingTitleProps<T>) { 17 + const Tag = (as ?? "h2") as ElementType; 18 + const ref = useRef<HTMLElement | null>(null); 19 + const [visible, setVisible] = useState(false); 20 + 21 + useEffect(() => { 22 + if (!ref.current) return; 23 + const observer = new IntersectionObserver( 24 + (entries) => { 25 + entries.forEach((entry) => { 26 + if (entry.isIntersecting) { 27 + setVisible(true); 28 + observer.disconnect(); 29 + } 30 + }); 31 + }, 32 + { threshold: 0.25, rootMargin: "0px 0px -10% 0px" }, 33 + ); 34 + observer.observe(ref.current); 35 + return () => observer.disconnect(); 36 + }, []); 37 + 38 + return ( 39 + <Tag ref={ref} className={cn("landing-title", visible && "landing-title-visible", className)}> 40 + {children} 41 + </Tag> 42 + ); 43 + }