diff --git a/README.md b/README.md index 7bfc56b28a..684f28eb2d 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@

The #1 Open-Source CRM

-

Website · Documentation · Roadmap · Discord · Figma

+

Website · Documentation · Roadmap · Discord · Figma

- - - Twenty banner + + + Twenty banner

@@ -85,17 +85,17 @@ Want to go deeper? Read the - - - Create your apps + + + Create your apps

Learn more about apps in doc

- - - Stay on top with version control + + + Stay on top with version control

Learn more about version control in doc

@@ -103,17 +103,17 @@ Want to go deeper? Read the - - - All the tools you need to build anything + + + All the tools you need to build anything

Learn more about primitives in doc

- - - Customize your layouts + + + Customize your layouts

Learn more about layouts in doc

@@ -121,17 +121,17 @@ Want to go deeper? Read the - - - AI agents and chats + + + AI agents and chats

Learn more about AI in doc

- - - Plus all the tools of a good CRM + + + Plus all the tools of a good CRM

Learn more about CRM features in doc

@@ -152,13 +152,13 @@ Want to go deeper? Read the - Chromatic + Chromatic      - Greptile + Greptile      - Sentry + Sentry      - Crowdin + Crowdin

Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin). diff --git a/packages/twenty-website-new/lingui.config.ts b/packages/twenty-website-new/lingui.config.ts index 1125c1c997..215989deb6 100644 --- a/packages/twenty-website-new/lingui.config.ts +++ b/packages/twenty-website-new/lingui.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@lingui/conf'; import { formatter } from '@lingui/format-po'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; -import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/website-locale-list'; +import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/utils/website-locale-list'; export default defineConfig({ sourceLocale: SOURCE_LOCALE, diff --git a/packages/twenty-website-new/next.config.ts b/packages/twenty-website-new/next.config.ts index fd7b50c4a4..7d09efba6d 100644 --- a/packages/twenty-website-new/next.config.ts +++ b/packages/twenty-website-new/next.config.ts @@ -55,6 +55,15 @@ const nextConfig: LinariaConfig = { source: '/:path*', headers: SECURITY_HEADERS.map((h) => ({ ...h })), }, + { + source: '/(images|illustrations|lottie)/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, ]; }, async redirects() { @@ -115,13 +124,13 @@ const nextConfig: LinariaConfig = { permanent: true, }, { - source: '/why-twenty', - destination: '/resources/why-twenty', + source: '/resources/why-twenty', + destination: '/why-twenty', permanent: true, }, { source: '/story', - destination: '/resources/why-twenty', + destination: '/why-twenty', permanent: true, }, { diff --git a/packages/twenty-website-new/package.json b/packages/twenty-website-new/package.json index e6b7db6f58..b8b12c9fa3 100644 --- a/packages/twenty-website-new/package.json +++ b/packages/twenty-website-new/package.json @@ -12,14 +12,11 @@ "@base-ui/react": "^1.3.0", "@base-ui/utils": "^0.2.6", "@calcom/embed-react": "^1.5.3", - "@gsap/react": "2.1.2", "@linaria/core": "^7.0.0", "@linaria/react": "^7.0.1", "@lingui/core": "^5.1.2", "@lingui/react": "^5.1.2", "@lottiefiles/dotlottie-react": "^0.18.10", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", "@tabler/icons-react": "^3.41.1", "@wyw-in-js/babel-preset": "^0.8.1", "axios": "^1.14.0", diff --git a/packages/twenty-website-new/public/images/case-studies/header-pattern.png b/packages/twenty-website-new/public/images/case-studies/header-pattern.png deleted file mode 100644 index d8b36a3c1c..0000000000 Binary files a/packages/twenty-website-new/public/images/case-studies/header-pattern.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/case-studies/header-pattern.webp b/packages/twenty-website-new/public/images/case-studies/header-pattern.webp new file mode 100644 index 0000000000..8d96f8d9b3 Binary files /dev/null and b/packages/twenty-website-new/public/images/case-studies/header-pattern.webp differ diff --git a/packages/twenty-website-new/public/images/core/logo-mini.png b/packages/twenty-website-new/public/images/core/logo-mini.png deleted file mode 100644 index ba655d69b8..0000000000 Binary files a/packages/twenty-website-new/public/images/core/logo-mini.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/core/logo-mini.webp b/packages/twenty-website-new/public/images/core/logo-mini.webp new file mode 100644 index 0000000000..661a8efe03 Binary files /dev/null and b/packages/twenty-website-new/public/images/core/logo-mini.webp differ diff --git a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png deleted file mode 100644 index 4f10ae59f7..0000000000 Binary files a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp new file mode 100644 index 0000000000..9aadf9a4ec Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp differ diff --git a/packages/twenty-website-new/public/images/home/stepper/gears.jpg b/packages/twenty-website-new/public/images/home/stepper/gears.jpg deleted file mode 100644 index 0c3a0eb39e..0000000000 Binary files a/packages/twenty-website-new/public/images/home/stepper/gears.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/home/stepper/gears.webp b/packages/twenty-website-new/public/images/home/stepper/gears.webp new file mode 100644 index 0000000000..8dc6f41907 Binary files /dev/null and b/packages/twenty-website-new/public/images/home/stepper/gears.webp differ diff --git a/packages/twenty-website-new/public/images/og/default.png b/packages/twenty-website-new/public/images/og/default.png new file mode 100644 index 0000000000..0f924eeb36 Binary files /dev/null and b/packages/twenty-website-new/public/images/og/default.png differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg deleted file mode 100644 index 7d26679408..0000000000 Binary files a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp new file mode 100644 index 0000000000..f71b5a741c Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg deleted file mode 100644 index 293df084b2..0000000000 Binary files a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp new file mode 100644 index 0000000000..28acfff234 Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png deleted file mode 100644 index 26cdabed77..0000000000 Binary files a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp new file mode 100644 index 0000000000..1a9d1dd5c0 Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg deleted file mode 100644 index d164db974d..0000000000 Binary files a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp new file mode 100644 index 0000000000..51f3d1df5d Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp differ diff --git a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png deleted file mode 100644 index d9b73f3bb1..0000000000 Binary files a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp new file mode 100644 index 0000000000..65f9a6f788 Binary files /dev/null and b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp differ diff --git a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png deleted file mode 100644 index 33c2fb7b65..0000000000 Binary files a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp new file mode 100644 index 0000000000..84b03da335 Binary files /dev/null and b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp differ diff --git a/packages/twenty-website-new/public/images/readme/chromatic.png b/packages/twenty-website-new/public/images/readme/chromatic.png deleted file mode 100644 index 75beea4d8d..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/chromatic.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/chromatic.webp b/packages/twenty-website-new/public/images/readme/chromatic.webp new file mode 100644 index 0000000000..84adec3f8b Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/chromatic.webp differ diff --git a/packages/twenty-website-new/public/images/readme/crowdin.png b/packages/twenty-website-new/public/images/readme/crowdin.png deleted file mode 100644 index 1291e8a17d..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/crowdin.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/crowdin.webp b/packages/twenty-website-new/public/images/readme/crowdin.webp new file mode 100644 index 0000000000..2255648c48 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/crowdin.webp differ diff --git a/packages/twenty-website-new/public/images/readme/data-model-dark.png b/packages/twenty-website-new/public/images/readme/data-model-dark.png deleted file mode 100644 index ffb30a3b20..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/data-model-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/data-model-dark.webp b/packages/twenty-website-new/public/images/readme/data-model-dark.webp new file mode 100644 index 0000000000..1232535543 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/data-model-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/data-model-light.png b/packages/twenty-website-new/public/images/readme/data-model-light.png deleted file mode 100644 index 9eb4efb4df..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/data-model-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/data-model-light.webp b/packages/twenty-website-new/public/images/readme/data-model-light.webp new file mode 100644 index 0000000000..29c6910b00 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/data-model-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/figma-icon.png b/packages/twenty-website-new/public/images/readme/figma-icon.png deleted file mode 100644 index c6595adfae..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/figma-icon.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/figma-icon.webp b/packages/twenty-website-new/public/images/readme/figma-icon.webp new file mode 100644 index 0000000000..366acfc1ed Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/figma-icon.webp differ diff --git a/packages/twenty-website-new/public/images/readme/github-cover-dark.png b/packages/twenty-website-new/public/images/readme/github-cover-dark.png deleted file mode 100644 index d1218eefaa..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/github-cover-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/github-cover-dark.webp b/packages/twenty-website-new/public/images/readme/github-cover-dark.webp new file mode 100644 index 0000000000..3ebec8e15e Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/github-cover-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/github-cover-light.png b/packages/twenty-website-new/public/images/readme/github-cover-light.png deleted file mode 100644 index 17f2c17796..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/github-cover-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/github-cover-light.webp b/packages/twenty-website-new/public/images/readme/github-cover-light.webp new file mode 100644 index 0000000000..9be4616610 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/github-cover-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/greptile.png b/packages/twenty-website-new/public/images/readme/greptile.png deleted file mode 100644 index 65f79ec270..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/greptile.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/greptile.webp b/packages/twenty-website-new/public/images/readme/greptile.webp new file mode 100644 index 0000000000..ab269df423 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/greptile.webp differ diff --git a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png deleted file mode 100644 index bbb0312eb2..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp new file mode 100644 index 0000000000..02f43e3ddf Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp differ diff --git a/packages/twenty-website-new/public/images/readme/permissions-dark.png b/packages/twenty-website-new/public/images/readme/permissions-dark.png deleted file mode 100644 index f4f3eb5a6d..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/permissions-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/permissions-dark.webp b/packages/twenty-website-new/public/images/readme/permissions-dark.webp new file mode 100644 index 0000000000..ac5e8878cf Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/permissions-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/permissions-light.png b/packages/twenty-website-new/public/images/readme/permissions-light.png deleted file mode 100644 index 0b81f80350..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/permissions-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/permissions-light.webp b/packages/twenty-website-new/public/images/readme/permissions-light.webp new file mode 100644 index 0000000000..7974aed6f5 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/permissions-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png deleted file mode 100644 index d9bd9be269..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp new file mode 100644 index 0000000000..a3deea9dbd Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-light.png b/packages/twenty-website-new/public/images/readme/plus-other-features-light.png deleted file mode 100644 index bb5ac79927..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/plus-other-features-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp b/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp new file mode 100644 index 0000000000..92150d53bf Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png deleted file mode 100644 index 2341bb8362..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp new file mode 100644 index 0000000000..c3134152e7 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png deleted file mode 100644 index e39a4708f0..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp new file mode 100644 index 0000000000..5c8913de4d Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner.png deleted file mode 100644 index a57a66b8c5..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp new file mode 100644 index 0000000000..43bbb1cbdb Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp differ diff --git a/packages/twenty-website-new/public/images/readme/sentry.png b/packages/twenty-website-new/public/images/readme/sentry.png deleted file mode 100644 index 52ac04749f..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/sentry.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/sentry.webp b/packages/twenty-website-new/public/images/readme/sentry.webp new file mode 100644 index 0000000000..d8679cc1b4 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/sentry.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png deleted file mode 100644 index d24a3f9fad..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp new file mode 100644 index 0000000000..d17740e6a4 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png deleted file mode 100644 index c511c9fe1b..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp new file mode 100644 index 0000000000..ff7767181d Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents.png deleted file mode 100644 index a2beefeff3..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp new file mode 100644 index 0000000000..322714f12b Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png deleted file mode 100644 index 783b20ae66..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp new file mode 100644 index 0000000000..ca7589db68 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png deleted file mode 100644 index e262f9de1a..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp new file mode 100644 index 0000000000..679090dad1 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png deleted file mode 100644 index eac6b93a1c..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp new file mode 100644 index 0000000000..9f93b1e62c Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png deleted file mode 100644 index 6f3d2586b3..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp new file mode 100644 index 0000000000..8e2c962847 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps.png b/packages/twenty-website-new/public/images/readme/v2-build-apps.png deleted file mode 100644 index 6f3d2586b3..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps.webp new file mode 100644 index 0000000000..8e2c962847 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png deleted file mode 100644 index 0867904227..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp new file mode 100644 index 0000000000..627ed655bc Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png deleted file mode 100644 index 8a77212b62..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp new file mode 100644 index 0000000000..dd54c06813 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools.png deleted file mode 100644 index 90e34b4881..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp new file mode 100644 index 0000000000..f218d6802c Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-tools-dark.png deleted file mode 100644 index 16bba7e005..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-tools-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp new file mode 100644 index 0000000000..4851348b52 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-tools-light.png deleted file mode 100644 index ee284b3f5a..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-tools-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-tools-light.webp new file mode 100644 index 0000000000..bf3b882788 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools.png b/packages/twenty-website-new/public/images/readme/v2-tools.png deleted file mode 100644 index 6e5e9c4540..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-tools.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-tools.webp b/packages/twenty-website-new/public/images/readme/v2-tools.webp new file mode 100644 index 0000000000..dae7bc11b1 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png deleted file mode 100644 index c90a843b28..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp new file mode 100644 index 0000000000..a99d790c6b Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-light.png b/packages/twenty-website-new/public/images/readme/v2-version-control-light.png deleted file mode 100644 index db6d8c3209..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp b/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp new file mode 100644 index 0000000000..324f025d6a Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control.png b/packages/twenty-website-new/public/images/readme/v2-version-control.png deleted file mode 100644 index db6d8c3209..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control.webp b/packages/twenty-website-new/public/images/readme/v2-version-control.webp new file mode 100644 index 0000000000..324f025d6a Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control.webp differ diff --git a/packages/twenty-website-new/public/images/readme/views-dark.png b/packages/twenty-website-new/public/images/readme/views-dark.png deleted file mode 100644 index 3311354cb3..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/views-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/views-dark.webp b/packages/twenty-website-new/public/images/readme/views-dark.webp new file mode 100644 index 0000000000..11f12d9481 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/views-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/views-light.png b/packages/twenty-website-new/public/images/readme/views-light.png deleted file mode 100644 index e513c6a427..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/views-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/views-light.webp b/packages/twenty-website-new/public/images/readme/views-light.webp new file mode 100644 index 0000000000..9154c0502d Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/views-light.webp differ diff --git a/packages/twenty-website-new/public/images/readme/workflows-dark.png b/packages/twenty-website-new/public/images/readme/workflows-dark.png deleted file mode 100644 index 7a4f62ad22..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/workflows-dark.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/workflows-dark.webp b/packages/twenty-website-new/public/images/readme/workflows-dark.webp new file mode 100644 index 0000000000..0112690e61 Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/workflows-dark.webp differ diff --git a/packages/twenty-website-new/public/images/readme/workflows-light.png b/packages/twenty-website-new/public/images/readme/workflows-light.png deleted file mode 100644 index edad0e935f..0000000000 Binary files a/packages/twenty-website-new/public/images/readme/workflows-light.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/readme/workflows-light.webp b/packages/twenty-website-new/public/images/readme/workflows-light.webp new file mode 100644 index 0000000000..529e3d943f Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/workflows-light.webp differ diff --git a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png deleted file mode 100644 index 9ad17467d5..0000000000 Binary files a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp new file mode 100644 index 0000000000..18fc466d98 Binary files /dev/null and b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp differ diff --git a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png deleted file mode 100644 index 4efcf31802..0000000000 Binary files a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp new file mode 100644 index 0000000000..61fae0e11a Binary files /dev/null and b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp differ diff --git a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png deleted file mode 100644 index 2473ba7063..0000000000 Binary files a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp index 03cbf3f29e..9233b6e28e 100644 Binary files a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp and b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png deleted file mode 100644 index b0d256d5b2..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp new file mode 100644 index 0000000000..43798acb0f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/accel.png b/packages/twenty-website-new/public/images/shared/companies/logos/accel.png deleted file mode 100644 index 19ce7ca692..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/accel.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp b/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp new file mode 100644 index 0000000000..7e61057680 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png deleted file mode 100644 index 7582b89816..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp new file mode 100644 index 0000000000..2c9fa7324f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png deleted file mode 100644 index f11070ad04..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp new file mode 100644 index 0000000000..9cfe76536f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png deleted file mode 100644 index 119e1b49a1..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp new file mode 100644 index 0000000000..9156a40778 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png deleted file mode 100644 index 14b2a0b2a6..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp new file mode 100644 index 0000000000..131499f5bc Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple.png b/packages/twenty-website-new/public/images/shared/companies/logos/apple.png deleted file mode 100644 index a5ce06c27c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/apple.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp b/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp new file mode 100644 index 0000000000..ce4717bcf6 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png deleted file mode 100644 index b2b39108d6..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp new file mode 100644 index 0000000000..e1f5c91423 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png b/packages/twenty-website-new/public/images/shared/companies/logos/claude.png deleted file mode 100644 index b5913ed37e..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp b/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp new file mode 100644 index 0000000000..79640933cb Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png deleted file mode 100644 index a21a3c35e7..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp new file mode 100644 index 0000000000..a531ddf91c Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png deleted file mode 100644 index 8c2c3ff668..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp new file mode 100644 index 0000000000..20ce247b9f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/figma.png b/packages/twenty-website-new/public/images/shared/companies/logos/figma.png deleted file mode 100644 index b8123ce8d0..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/figma.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp b/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp new file mode 100644 index 0000000000..ede071c202 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png deleted file mode 100644 index 399c73f8cf..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp new file mode 100644 index 0000000000..5affdc758e Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/github.png b/packages/twenty-website-new/public/images/shared/companies/logos/github.png deleted file mode 100644 index 5688f1d670..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/github.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/github.webp b/packages/twenty-website-new/public/images/shared/companies/logos/github.webp new file mode 100644 index 0000000000..df3c08d959 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/github.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png deleted file mode 100644 index f6620b5ec3..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp new file mode 100644 index 0000000000..36ae973903 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/google.png b/packages/twenty-website-new/public/images/shared/companies/logos/google.png deleted file mode 100644 index 5652d06705..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/google.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/google.webp b/packages/twenty-website-new/public/images/shared/companies/logos/google.webp new file mode 100644 index 0000000000..22e0ba91ad Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/google.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png deleted file mode 100644 index cc52cc8f51..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp new file mode 100644 index 0000000000..795f325e32 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png deleted file mode 100644 index 02c9c1a829..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp new file mode 100644 index 0000000000..0af40ceb21 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png deleted file mode 100644 index 230a0228b9..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp new file mode 100644 index 0000000000..01629d282f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png deleted file mode 100644 index 17e4a661a6..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp new file mode 100644 index 0000000000..a9c994ce68 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png deleted file mode 100644 index dac60f27f1..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp new file mode 100644 index 0000000000..7bc14f1c10 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/meet.png b/packages/twenty-website-new/public/images/shared/companies/logos/meet.png deleted file mode 100644 index 66638552e2..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/meet.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp b/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp new file mode 100644 index 0000000000..eb0f50833c Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png deleted file mode 100644 index de46b9370c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp new file mode 100644 index 0000000000..99acc24b5d Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png deleted file mode 100644 index 265ee5498f..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp new file mode 100644 index 0000000000..23c8ed4e74 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/notion.png b/packages/twenty-website-new/public/images/shared/companies/logos/notion.png deleted file mode 100644 index 4b15aaf862..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/notion.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp b/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp new file mode 100644 index 0000000000..c4118c38a3 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/okta.png b/packages/twenty-website-new/public/images/shared/companies/logos/okta.png deleted file mode 100644 index 90d08aa440..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/okta.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp b/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp new file mode 100644 index 0000000000..143c34354c Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/openai.png b/packages/twenty-website-new/public/images/shared/companies/logos/openai.png deleted file mode 100644 index 2605bfef9c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/openai.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp b/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp new file mode 100644 index 0000000000..fb836b64be Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png deleted file mode 100644 index 4c2dceff7e..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp new file mode 100644 index 0000000000..fb5315b804 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png deleted file mode 100644 index 128b0526fa..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp new file mode 100644 index 0000000000..139c36dcf5 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png deleted file mode 100644 index 4a9c0422bb..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp new file mode 100644 index 0000000000..d73ffe7182 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png deleted file mode 100644 index bd8bb18ac3..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp new file mode 100644 index 0000000000..adeccdc9e3 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png deleted file mode 100644 index 9695e7b2e6..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp new file mode 100644 index 0000000000..7bd40a416c Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/segment.png b/packages/twenty-website-new/public/images/shared/companies/logos/segment.png deleted file mode 100644 index 54a032342f..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/segment.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp b/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp new file mode 100644 index 0000000000..f551e54b3a Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png deleted file mode 100644 index dd2ff84237..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp new file mode 100644 index 0000000000..bb3f60aca1 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/slack.png b/packages/twenty-website-new/public/images/shared/companies/logos/slack.png deleted file mode 100644 index 23523fc912..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/slack.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp b/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp new file mode 100644 index 0000000000..68c4424b38 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png deleted file mode 100644 index cc3c0de27a..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp new file mode 100644 index 0000000000..6fe0ec2222 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/tally.png b/packages/twenty-website-new/public/images/shared/companies/logos/tally.png deleted file mode 100644 index bd9a5ad362..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/tally.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp b/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp new file mode 100644 index 0000000000..ad311546be Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png deleted file mode 100644 index adfb6af341..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp new file mode 100644 index 0000000000..4873fd10fc Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png deleted file mode 100644 index d24852f3e8..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp new file mode 100644 index 0000000000..ea8ae29045 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png deleted file mode 100644 index 6d275c19c8..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp new file mode 100644 index 0000000000..8ba68a0424 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp differ diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png deleted file mode 100644 index 8d25e59b0f..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp new file mode 100644 index 0000000000..97549d13a7 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp differ diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png deleted file mode 100644 index 20336ce301..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp new file mode 100644 index 0000000000..1cba624d75 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp differ diff --git a/packages/twenty-website-new/public/images/shared/menu/developers-preview.png b/packages/twenty-website-new/public/images/shared/menu/developers-preview.png deleted file mode 100644 index 2017cef4da..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/menu/developers-preview.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp b/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp new file mode 100644 index 0000000000..b521308da6 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp differ diff --git a/packages/twenty-website-new/public/images/shared/menu/partners-preview.png b/packages/twenty-website-new/public/images/shared/menu/partners-preview.png deleted file mode 100644 index e75a4a9272..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/menu/partners-preview.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp b/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp new file mode 100644 index 0000000000..2bbda4d514 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp differ diff --git a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png deleted file mode 100644 index 4f67fff238..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp new file mode 100644 index 0000000000..98e171701c Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg deleted file mode 100644 index caa923e848..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp new file mode 100644 index 0000000000..d10a796443 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg deleted file mode 100644 index 3d400a5258..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp new file mode 100644 index 0000000000..1e1cca7703 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg deleted file mode 100644 index adc7577e7d..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp new file mode 100644 index 0000000000..0078252829 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg deleted file mode 100644 index db8e7c5d16..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp new file mode 100644 index 0000000000..e8ce7605af Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg deleted file mode 100644 index 7f529e3e00..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp new file mode 100644 index 0000000000..c351b70f86 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg deleted file mode 100644 index d09062d97f..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp new file mode 100644 index 0000000000..edc9b1e221 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg deleted file mode 100644 index c4c1a74d50..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp new file mode 100644 index 0000000000..0c38085fc0 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg deleted file mode 100644 index f85d3c2d78..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp new file mode 100644 index 0000000000..79fc11a8d8 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg deleted file mode 100644 index 32cd940f2b..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp new file mode 100644 index 0000000000..68985a00ab Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg deleted file mode 100644 index ab9376c4c7..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp new file mode 100644 index 0000000000..db95480be0 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg deleted file mode 100644 index 6b883a75df..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp new file mode 100644 index 0000000000..54b2de4f4f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg deleted file mode 100644 index 21ee309060..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp new file mode 100644 index 0000000000..0f03518995 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg deleted file mode 100644 index 4ffd0685b1..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp new file mode 100644 index 0000000000..fb708daf8b Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg deleted file mode 100644 index 70aa4c32bc..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp new file mode 100644 index 0000000000..dd6af85617 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg deleted file mode 100644 index b4d1889b66..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp new file mode 100644 index 0000000000..486ce5a535 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg deleted file mode 100644 index cbd6624968..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp new file mode 100644 index 0000000000..a27d6ad93e Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg deleted file mode 100644 index 54b0dcd256..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp new file mode 100644 index 0000000000..cf14da400b Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg deleted file mode 100644 index b2ef366a29..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp new file mode 100644 index 0000000000..f1de37b2bc Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg deleted file mode 100644 index d7d600077a..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp new file mode 100644 index 0000000000..e18ac3037e Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg deleted file mode 100644 index c556423d88..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp new file mode 100644 index 0000000000..1ad15e6b8b Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg deleted file mode 100644 index 7fac2cc8c7..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp new file mode 100644 index 0000000000..5a2d4fa192 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg deleted file mode 100644 index 09fd903b04..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp new file mode 100644 index 0000000000..cddd929e0f Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg deleted file mode 100644 index c2a62ec721..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp new file mode 100644 index 0000000000..922a53bbca Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg deleted file mode 100644 index a95052b557..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp new file mode 100644 index 0000000000..9eeb3d9592 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg deleted file mode 100644 index 023544cc6c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp new file mode 100644 index 0000000000..dc65b2e1cd Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg deleted file mode 100644 index c8212d35a1..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp new file mode 100644 index 0000000000..8ce8b887bf Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg deleted file mode 100644 index b91bae044c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp new file mode 100644 index 0000000000..18c392f008 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg deleted file mode 100644 index e1d2fd3b1c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp new file mode 100644 index 0000000000..449311bae2 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg deleted file mode 100644 index a32e23198c..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp new file mode 100644 index 0000000000..9e7e962b9d Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg deleted file mode 100644 index b0e48f5126..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp new file mode 100644 index 0000000000..f35c93b849 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg deleted file mode 100644 index 3ee9b568ae..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp new file mode 100644 index 0000000000..4d852dc83b Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg deleted file mode 100644 index 7ad6ecb24d..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp new file mode 100644 index 0000000000..60b0b5b363 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg deleted file mode 100644 index 310686555f..0000000000 Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp new file mode 100644 index 0000000000..8a79b9f3f5 Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp differ diff --git a/packages/twenty-website-new/scripts/check-lottie-frames.mjs b/packages/twenty-website-new/scripts/check-lottie-frames.mjs index 02e56de4ad..1ef4821b63 100644 --- a/packages/twenty-website-new/scripts/check-lottie-frames.mjs +++ b/packages/twenty-website-new/scripts/check-lottie-frames.mjs @@ -23,12 +23,13 @@ const FRAME_MAP_PATH = path.join( 'src', 'sections', 'HomeStepper', - 'utils', - 'home-stepper-lottie-frame-map.ts', + 'components', + 'Visual', + 'use-dot-lottie-scroll-sync.ts', ); const EXPECTED_CONSTANT_REGEX = - /export\s+const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/; + /(?:export\s+)?const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/; function fail(message) { // eslint-disable-next-line no-console diff --git a/packages/twenty-website-new/scripts/check-section-shape.mjs b/packages/twenty-website-new/scripts/check-section-shape.mjs index ab49bf41a1..8f8106f70f 100644 --- a/packages/twenty-website-new/scripts/check-section-shape.mjs +++ b/packages/twenty-website-new/scripts/check-section-shape.mjs @@ -7,15 +7,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const SECTIONS_DIR = path.join(ROOT, 'src', 'sections'); -const SECTIONS_USING_NAMED_SLOTS = new Map([ - ['Marquee', new Set(['Heading'])], - ['TrustedBy', new Set(['Separator', 'Logos', 'ClientCount'])], -]); +const SECTIONS_USING_NAMED_SLOTS = new Map([]); const LEAF_SECTIONS = new Set([ 'CaseStudy', 'CaseStudyCatalog', + 'ContactCal', 'LegalDocument', + 'PartnerApplication', + 'Stepper', ]); async function listSections() { @@ -47,6 +47,8 @@ async function findBarrel(sectionDir) { const candidates = [ path.join(sectionDir, 'components', 'index.ts'), path.join(sectionDir, 'components', 'index.tsx'), + path.join(sectionDir, 'index.ts'), + path.join(sectionDir, 'index.tsx'), ]; for (const candidate of candidates) { if (await fileExists(candidate)) return candidate; @@ -55,8 +57,15 @@ async function findBarrel(sectionDir) { } async function findRoot(sectionDir) { - const candidate = path.join(sectionDir, 'components', 'Root.tsx'); - if (await fileExists(candidate)) return candidate; + const sectionName = path.basename(sectionDir); + const candidates = [ + path.join(sectionDir, 'components', 'Root.tsx'), + path.join(sectionDir, `${sectionName}.tsx`), + path.join(sectionDir, `${sectionName}.ts`), + ]; + for (const candidate of candidates) { + if (await fileExists(candidate)) return candidate; + } return null; } diff --git a/packages/twenty-website-new/scripts/convert-png-to-webp.mjs b/packages/twenty-website-new/scripts/convert-png-to-webp.mjs index 227fbab23d..520dcd86c8 100644 --- a/packages/twenty-website-new/scripts/convert-png-to-webp.mjs +++ b/packages/twenty-website-new/scripts/convert-png-to-webp.mjs @@ -7,6 +7,8 @@ import sharp from 'sharp'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const imagesRoot = path.join(__dirname, '..', 'public', 'images'); +const CONVERTIBLE_EXTENSIONS = /\.(png|jpe?g)$/i; + let totalBefore = 0; let totalAfter = 0; let count = 0; @@ -20,11 +22,11 @@ async function walkAndConvert(directoryPath) { continue; } - if (!entry.name.toLowerCase().endsWith('.png')) { + if (!CONVERTIBLE_EXTENSIONS.test(entry.name)) { continue; } - const outputPath = fullPath.replace(/\.png$/i, '.webp'); + const outputPath = fullPath.replace(CONVERTIBLE_EXTENSIONS, '.webp'); const beforeSize = fs.statSync(fullPath).size; await sharp(fullPath).webp({ quality: 92 }).toFile(outputPath); diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts index f1e83674ed..12e67db252 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts @@ -1,43 +1,26 @@ import { msg } from '@lingui/core/macro'; -import type { HelpedDataType } from '@/sections/Helped/types/HelpedData'; +import type { HeadingCardType } from '@/sections/Helped'; -export const HELPED_DATA: HelpedDataType = { - eyebrow: { - heading: { - text: msg`In production.`, - fontFamily: 'sans', - }, +export const HELPED_CARDS: HeadingCardType[] = [ + { + icon: 'w3villa', + heading: msg`Ship a product on Twenty`, + body: msg`W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.`, + illustration: 'target', + href: '/customers/w3villa', }, - cards: [ - { - icon: 'w3villa', - heading: { text: msg`Ship a product on Twenty`, fontFamily: 'sans' }, - body: { - text: msg`W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.`, - }, - illustration: 'target', - href: '/customers/w3villa', - }, - { - icon: 'act-education', - heading: { text: msg`Own your CRM end to end`, fontFamily: 'sans' }, - body: { - text: msg`AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.`, - }, - illustration: 'spaceship', - href: '/customers/act-education', - }, - { - icon: 'netzero', - heading: { - text: msg`Grow with a flexible foundation`, - fontFamily: 'sans', - }, - body: { - text: msg`NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.`, - }, - illustration: 'money', - href: '/customers/netzero', - }, - ], -}; + { + icon: 'act-education', + heading: msg`Own your CRM end to end`, + body: msg`AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.`, + illustration: 'spaceship', + href: '/customers/act-education', + }, + { + icon: 'netzero', + heading: msg`Grow with a flexible foundation`, + body: msg`NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.`, + illustration: 'money', + href: '/customers/netzero', + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts index c1aced9162..1d0397e8ab 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts @@ -1,12 +1,10 @@ -import { msg } from '@lingui/core/macro'; - import type { HeroDashboardDataType, HeroDashboardPageDefinition, HeroKanbanPageDefinition, HeroTablePageDefinition, HeroVisualType, -} from '@/sections/Hero/types'; +} from '@/sections/Hero'; import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths'; const PEOPLE_AVATAR_URLS = { @@ -325,10 +323,6 @@ function createTablePage({ }; } -export const HERO_COPY = { - body: msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`, -}; - export const HERO_DATA: { visual: HeroVisualType } = { visual: { workspace: { icon: 'apple', name: 'Apple' }, diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts deleted file mode 100644 index 348535eadd..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { HomeStepperDataType } from '@/sections/HomeStepper/types'; - -export const HOME_STEPPER_DATA: HomeStepperDataType = { - steps: [ - { - heading: [ - { - text: msg`Begin with production-grade`, - fontFamily: 'serif', - }, - { - text: msg`building blocks`, - fontFamily: 'sans', - }, - ], - body: { - text: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`, - }, - }, - { - heading: [ - { - text: msg`Continue iteration`, - fontFamily: 'serif', - }, - { - text: msg`without friction`, - fontFamily: 'sans', - }, - ], - body: { - text: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`, - }, - }, - { - heading: [ - { - text: msg`Stay in control with our`, - fontFamily: 'serif', - }, - { - text: msg`open-source software`, - fontFamily: 'sans', - }, - ], - body: { - text: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`, - }, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx index 1d9eab4cbd..fc38182263 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx @@ -1,15 +1,13 @@ import { msg } from '@lingui/core/macro'; -import { HELPED_DATA } from '@/app/[locale]/(home)/helped.data'; -import { HERO_COPY, HERO_DATA } from '@/app/[locale]/(home)/hero.data'; -import { HOME_STEPPER_DATA } from '@/app/[locale]/(home)/home-stepper.data'; -import { PROBLEM_DATA } from '@/app/[locale]/(home)/problem.data'; -import { TESTIMONIALS_DATA } from '@/app/[locale]/(home)/testimonials.data'; -import { THREE_CARDS_FEATURE_DATA } from '@/app/[locale]/(home)/three-cards-feature.data'; -import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/(home)/three-cards-illustration.data'; -import { TalkToUsButton } from '@/lib/contact-cal'; -import { FAQ_DATA } from '@/sections/Faq/data'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; +import { HELPED_CARDS } from '@/app/[locale]/(home)/helped.data'; +import { HERO_DATA } from '@/app/[locale]/(home)/hero.data'; +import { Problem, type ProblemPointType } from '@/sections/Problem'; +import { HOME_TESTIMONIALS } from '@/app/[locale]/(home)/testimonials.data'; +import { FEATURE_CARDS } from '@/app/[locale]/(home)/three-cards-feature.data'; +import { ILLUSTRATION_CARDS } from '@/app/[locale]/(home)/three-cards-illustration.data'; +import { TalkToUsButton } from '@/sections/ContactCal'; +import { Faq, FAQ_QUESTIONS } from '@/sections/Faq'; +import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy'; import { Body, Eyebrow, @@ -18,23 +16,19 @@ import { LinkButton, } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { Faq } from '@/sections/Faq/components'; -import { Helped } from '@/sections/Helped/components'; -import { Hero } from '@/sections/Hero/components'; -import { HomeStepper } from '@/sections/HomeStepper/components'; -import { Menu } from '@/sections/Menu/components'; -import { Problem } from '@/sections/Problem/components'; -import { Testimonials } from '@/sections/Testimonials/components'; -import { ThreeCards } from '@/sections/ThreeCards/components'; +import { Helped } from '@/sections/Helped'; +import { Hero } from '@/sections/Hero'; +import { HomeStepper, type HomeStepperStepType } from '@/sections/HomeStepper'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Testimonials } from '@/sections/Testimonials'; +import { ThreeCards } from '@/sections/ThreeCards'; import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo'; -import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; @@ -106,12 +100,72 @@ export default async function HomePage({ params }: HomePageProps) { getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); + + const PROBLEM_POINTS: ProblemPointType[] = [ + { + heading: ( + + {i18n._(msg`The Giant Monolith`)} + + ), + body: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`, + }, + { + heading: ( + + {i18n._(msg`The In-house Burden`)} + + ), + body: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`, + }, + ]; + + const HOME_STEPPER_STEPS: HomeStepperStepType[] = [ + { + heading: ( + + + {i18n._(msg`Begin with production-grade`)} + {' '} + + {i18n._(msg`building blocks`)} + + + ), + body: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`, + }, + { + heading: ( + + + {i18n._(msg`Continue iteration`)} + {' '} + + {i18n._(msg`without friction`)} + + + ), + body: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`, + }, + { + heading: ( + + + {i18n._(msg`Stay in control with our`)} + {' '} + + {i18n._(msg`open-source software`)} + + + ), + body: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`, + }, + ]; const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); return ( <> - + i18n._(d))} /> {/* * Above-the-fold home hero background texture. Preload warms the * HTTP cache so it is ready by the time HomeBackgroundHalftone @@ -119,9 +173,25 @@ export default async function HomePage({ params }: HomePageProps) { */} + + + - + - {renderText(msg`Build your Enterprise CRM`)} + {i18n._(msg`Build your Enterprise CRM`)} {' '} - {renderText(msg`at\u00A0AI\u00A0Speed`)} + {i18n._(msg`at\u00A0AI\u00A0Speed`)} - + + {i18n._( + msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`, + )} + - - - - - + - + + + {i18n._(msg`The Problem.`)} + + - {renderText(msg`A custom CRM gives your org an edge,`)} + {i18n._(msg`A custom CRM gives your org an edge,`)} {' '} - {renderText(msg`but building one`)} + {i18n._(msg`but building one`)} {' '} - {renderText(msg`comes with`)} + {i18n._(msg`comes with`)} {' '} - {renderText(msg`tradeoffs`)} + {i18n._(msg`tradeoffs`)} - + - + - + + + {i18n._(msg`Stop settling for trade-offs.`)} + + - {renderText(msg`Assemble, iterate and adapt a robust CRM,`)} + {i18n._(msg`Assemble, iterate and adapt a robust CRM,`)} {' '} - {renderText(msg`that's quick to flex`)} + {i18n._(msg`that's quick to flex`)} - {THREE_CARDS_ILLUSTRATION_DATA.body && ( - - )} + + {i18n._( + msg`Compose your CRM and internal apps with a single extensibility toolkit.`, + )} + - + - + - + - + + + {i18n._(msg`Skip the clunky UX that always comes with custom.`)} + + - {renderText(msg`Make your GTM team happy`)} + {i18n._(msg`Make your GTM team happy`)}
- {renderText(msg`with`)} + {i18n._(msg`with`)} {' '} - {renderText(msg`a CRM they'll love`)} + {i18n._(msg`a CRM they'll love`)}
- +
- - + + - + @@ -292,25 +342,25 @@ export default async function HomePage({ params }: HomePageProps) { - + + + {i18n._(msg`Any Questions?`)} + + - {renderText(msg`Stop fighting custom.`)} + {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)} + {i18n._(msg`Start building, with Twenty`)}
- +
); diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts deleted file mode 100644 index 483f80bcaa..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { ProblemDataType } from '@/sections/Problem/types'; - -export const PROBLEM_DATA: ProblemDataType = { - eyebrow: { heading: { text: msg`The Problem.`, fontFamily: 'sans' } }, - points: [ - { - heading: { text: msg`The Giant Monolith`, fontFamily: 'sans' }, - body: { - text: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`, - }, - }, - { - heading: { text: msg`The In-house Burden`, fontFamily: 'sans' }, - body: { - text: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`, - }, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts index ee091bb194..f3f33cd2cf 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts @@ -1,42 +1,26 @@ import { msg } from '@lingui/core/macro'; -import type { TestimonialsDataType } from '@/sections/Testimonials/types'; +import type { TestimonialCardType } from '@/sections/Testimonials'; -export const TESTIMONIALS_DATA: TestimonialsDataType = { - eyebrow: { - heading: { text: msg`They are the real sales`, fontFamily: 'sans' }, +export const HOME_TESTIMONIALS: TestimonialCardType[] = [ + { + heading: msg`The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.`, + author: { + name: msg`Olivier Reinaud`, + designation: msg`Co-founder at NetZero`, + }, }, - testimonials: [ - { - heading: { - text: msg`The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Olivier Reinaud` }, - designation: { text: msg`Co-founder at NetZero` }, - }, + { + heading: msg`We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.`, + author: { + name: msg`Amrendra Pratap Singh`, + designation: msg`VP of Engineering at W3villa Technologies`, }, - { - heading: { - text: msg`We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Amrendra Pratap Singh` }, - designation: { text: msg`VP of Engineering at W3villa Technologies` }, - }, + }, + { + heading: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.`, + author: { + name: msg`Justin Beadle`, + designation: msg`Director of Digital and Information, Elevate Consulting`, }, - { - heading: { - text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Justin Beadle` }, - designation: { - text: msg`Director of Digital and Information, Elevate Consulting`, - }, - }, - }, - ], -}; + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts index 50b2af9e44..a1285562fd 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts @@ -1,43 +1,29 @@ import { msg } from '@lingui/core/macro'; -import type { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types'; +import type { ThreeCardsFeatureCardType } from '@/sections/ThreeCards'; -export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = { - eyebrow: { - heading: { - text: msg`Skip the clunky UX that always comes with custom.`, - fontFamily: 'sans', - }, +export const FEATURE_CARDS: ThreeCardsFeatureCardType[] = [ + { + heading: msg`Familiar, modern interface`, + body: msg`Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.`, + backgroundImageSrc: + '/images/home/three-cards-feature/familiar-interface-gradient.webp', + icon: 'users-group', + illustration: 'familiar-interface', }, - featureCards: [ - { - heading: { text: msg`Familiar, modern interface`, fontFamily: 'sans' }, - body: { - text: msg`Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.`, - }, - backgroundImageSrc: - '/images/home/three-cards-feature/familiar-interface-gradient.webp', - icon: 'users-group', - illustration: 'familiar-interface', - }, - { - heading: { text: msg`Live data and AI built`, fontFamily: 'sans' }, - body: { - text: msg`Everything updates in real time, with AI chat always ready to help you move faster.`, - }, - backgroundImageSrc: - '/images/home/three-cards-feature/live-data-gradient.webp', - icon: 'live-data', - illustration: 'live-data', - }, - { - heading: { text: msg`Fast path to action`, fontFamily: 'sans' }, - body: { - text: msg`Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.`, - }, - backgroundImageSrc: - '/images/home/three-cards-feature/fast-path-gradient.webp', - icon: 'fast-path', - illustration: 'fast-path', - }, - ], -}; + { + heading: msg`Live data and AI built`, + body: msg`Everything updates in real time, with AI chat always ready to help you move faster.`, + backgroundImageSrc: + '/images/home/three-cards-feature/live-data-gradient.webp', + icon: 'live-data', + illustration: 'live-data', + }, + { + heading: msg`Fast path to action`, + body: msg`Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.`, + backgroundImageSrc: + '/images/home/three-cards-feature/fast-path-gradient.webp', + icon: 'fast-path', + illustration: 'fast-path', + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts index aeba6f3314..0bed80b871 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts @@ -1,52 +1,38 @@ import { msg } from '@lingui/core/macro'; -import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types'; +import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards'; -export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = { - eyebrow: { - heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' }, +export const ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [ + { + heading: msg`Production grade quality`, + body: msg`W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.`, + benefits: undefined, + attribution: { + role: msg`VP of Engineering`, + company: msg`W3villa Technologies`, + }, + illustration: 'diamond', + caseStudySlug: 'w3villa', }, - body: { - text: msg`Compose your CRM and internal apps with a single extensibility toolkit.`, + { + heading: msg`AI for rapid iterations`, + body: msg`Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.`, + benefits: undefined, + attribution: { + role: msg`Principal and Founder`, + company: msg`Alternative Partners`, + }, + illustration: 'flash', + caseStudySlug: 'alternative-partners', }, - illustrationCards: [ - { - heading: { text: msg`Production grade quality`, fontFamily: 'sans' }, - body: { - text: msg`W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.`, - }, - benefits: undefined, - attribution: { - role: { text: msg`VP of Engineering` }, - company: { text: msg`W3villa Technologies` }, - }, - illustration: 'diamond', - caseStudySlug: 'w3villa', + { + heading: msg`Control without drag`, + body: msg`AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.`, + benefits: undefined, + attribution: { + role: msg`CRM Engineer`, + company: msg`AC&T Education Migration`, }, - { - heading: { text: msg`AI for rapid iterations`, fontFamily: 'sans' }, - body: { - text: msg`Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.`, - }, - benefits: undefined, - attribution: { - role: { text: msg`Principal and Founder` }, - company: { text: msg`Alternative Partners` }, - }, - illustration: 'flash', - caseStudySlug: 'alternative-partners', - }, - { - heading: { text: msg`Control without drag`, fontFamily: 'sans' }, - body: { - text: msg`AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.`, - }, - benefits: undefined, - attribution: { - role: { text: msg`CRM Engineer` }, - company: { text: msg`AC&T Education Migration` }, - }, - illustration: 'lock', - caseStudySlug: 'act-education', - }, - ], -}; + illustration: 'lock', + caseStudySlug: 'act-education', + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx index da2de3b534..86dbd30539 100644 --- a/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx @@ -4,14 +4,13 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { getPublishedArticle, getPublishedArticles } from '@/lib/articles'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { type LocaleRouteParams } from '@/lib/i18n/get-route-i18n'; -import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments'; +import { type LocaleRouteParams } from '@/lib/i18n/utils/get-route-i18n'; +import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments'; import { resolveLocaleParam } from '@/lib/i18n'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { buildArticleJsonLd, buildPageMetadata, JsonLd } from '@/lib/seo'; -import { Articles } from '@/sections/Articles/components'; -import { Menu } from '@/sections/Menu/components'; -import { MENU_DATA } from '@/sections/Menu/data'; +import { Articles } from '@/sections/Articles'; +import { Menu, MENU_DATA } from '@/sections/Menu'; import { theme } from '@/theme'; type ArticleSlugParams = LocaleRouteParams & { slug: string }; diff --git a/packages/twenty-website-new/src/app/[locale]/articles/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx index 3064a3de78..486d8d29b4 100644 --- a/packages/twenty-website-new/src/app/[locale]/articles/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx @@ -1,32 +1,31 @@ +import { msg } from '@lingui/core/macro'; import { notFound } from 'next/navigation'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { HeadingPart } from '@/design-system/components'; import { getPublishedArticles } from '@/lib/articles'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; +import { resolveLocaleParam } from '@/lib/i18n'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { resolveLocaleParam } from '@/lib/i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; -import { buildArticleListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo'; -import { Articles } from '@/sections/Articles/components'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TrustedBy } from '@/sections/TrustedBy/components'; -import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; +import { + buildArticleListJsonLd, + buildBreadcrumbListJsonLd, + buildRouteMetadata, + JsonLd, +} from '@/lib/seo'; +import { Articles } from '@/sections/Articles'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy'; import { css } from '@linaria/core'; export const generateMetadata = buildRouteMetadata('articles'); -const ARTICLES_HERO_BODY = { - text: 'Ideas from the team building Twenty on open source CRM, GTM systems, and building software that lasts.', -}; - const ARTICLES_TOP_BACKGROUND_COLOR = '#F4F4F4'; const pageRevealClassName = css` @@ -71,12 +70,20 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) { getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); const posts = getPublishedArticles(); return ( <> + {posts.length > 0 ? ( ) : null} @@ -93,29 +100,25 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) {
- + Ideas on
open-source{' '} CRM
- + + Ideas from the team building Twenty on open source CRM, GTM systems, + and building software that lasts. +
- - - - + separator={i18n._(msg`trusted by`)} + logos={TRUSTED_BY_LOGOS} + clientCount={i18n._(msg`+10k others`)} + />
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx index c2ca86b168..35c916ccfa 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx @@ -1,119 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff'; -import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { type CaseStudyData } from '@/lib/customers'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudy } from '@/sections/CaseStudy/components'; -import { Menu } from '@/sections/Menu/components'; -import { theme } from '@/theme'; +} from '@/lib/i18n/utils/get-route-i18n'; import { buildLocalizedMetadata } from '@/lib/seo'; +import { Heading, HeadingPart } from '@/design-system/components'; +import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout'; const PLACEHOLDER_HERO = 'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80'; -const CASE_STUDY: CaseStudyData = { - meta: { - title: msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`, - description: msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`, - }, - hero: { - readingTime: '9 min', - title: [ - { - text: msg`A real estate agency on WhatsApp`, - fontFamily: 'serif', - }, - { text: msg`built a`, fontFamily: 'serif' }, - { text: msg`CRM`, fontFamily: 'sans', newLine: true }, - { text: msg`around it`, fontFamily: 'serif' }, - ], - author: 'Mike Babiy & Azmat Parveen', - authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png', - authorRole: msg`Founder, Nine Dots Ventures`, - clientIcon: 'nine-dots', - heroImageSrc: PLACEHOLDER_HERO, - industry: msg`Real Estate`, - kpis: [ - { value: msg`150 hrs`, label: msg`Saved / month` }, - { value: msg`2,000+`, label: msg`Daily messages` }, - { value: msg`Q1 2026`, label: msg`Record quarter` }, - ], - quote: { - text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`, - author: 'Mike Babiy', - role: msg`Founder, Nine Dots Ventures`, - }, - }, - sections: [ - { - type: 'text', - eyebrow: msg`Homeseller`, - heading: [ - { - text: msg`When the channel is`, - fontFamily: 'serif', - }, - { text: msg`the business`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.`, - msg`That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.`, - msg`Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.`, - ], - callout: - '"Twenty lets us build a CRM around the business and not the business around the CRM." - Mike Babiy, Founder, Nine Dots Ventures', - }, - { - type: 'text', - eyebrow: msg`Architecture`, - heading: [ - { text: msg`The CRM as a`, fontFamily: 'serif' }, - { text: msg`control hub`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.`, - msg`Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.`, - ], - callout: - '"Twenty is the heart of the system. Everything branches from it." - Azmat Parveen, Nine Dots Ventures', - }, - { - type: 'text', - eyebrow: msg`The result`, - heading: [ - { text: msg`150 hours`, fontFamily: 'sans' }, - { - text: msg`saved every month`, - fontFamily: 'serif', - }, - ], - paragraphs: [ - msg`About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.`, - msg`The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.`, - ], - }, - ], - tableOfContents: [ - msg`When the channel is the business`, - msg`The CRM as a control hub`, - msg`The result`, - ], - catalogCard: { - summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`, - date: msg`Jul 2025`, - }, -}; +const META_TITLE = msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`; +const META_DESCRIPTION = msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`; export const generateMetadata = buildLocalizedMetadata({ path: '/customers/9dots', - title: CASE_STUDY.meta.title, - description: CASE_STUDY.meta.description, + title: META_TITLE, + description: META_DESCRIPTION, }); type CaseStudyPageProps = { @@ -123,69 +27,114 @@ type CaseStudyPageProps = { export default async function NineDotsCaseStudyPage({ params, }: CaseStudyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - const palette = getCaseStudyPalette('/customers/9dots'); + const i18n = await getRouteI18n(params); - let storySectionIndex = 0; - const sectionBlocks = CASE_STUDY.sections.map((block, index) => { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '9 min', + title: ( + + + {i18n._(msg`A real estate agency on WhatsApp`)} + {' '} + {i18n._(msg`built a`)} +
+ {i18n._(msg`CRM`)}{' '} + {i18n._(msg`around it`)} +
+ ), + author: 'Mike Babiy & Azmat Parveen', + authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp', + authorRole: msg`Founder, Nine Dots Ventures`, + clientIcon: 'nine-dots', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`Real Estate`, + kpis: [ + { value: msg`150 hrs`, label: msg`Saved / month` }, + { value: msg`2,000+`, label: msg`Daily messages` }, + { value: msg`Q1 2026`, label: msg`Record quarter` }, + ], + quote: { + text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`, + author: 'Mike Babiy', + role: msg`Founder, Nine Dots Ventures`, + }, + }, + sections: [ + { + type: 'text', + eyebrow: msg`Homeseller`, + heading: ( + + + {i18n._(msg`When the channel is`)} + {' '} + + {i18n._(msg`the business`)} + + + ), + paragraphs: [ + msg`Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.`, + msg`That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.`, + msg`Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.`, + ], + callout: + '"Twenty lets us build a CRM around the business and not the business around the CRM." - Mike Babiy, Founder, Nine Dots Ventures', + }, + { + type: 'text', + eyebrow: msg`Architecture`, + heading: ( + + + {i18n._(msg`The CRM as a`)} + {' '} + + {i18n._(msg`control hub`)} + + + ), + paragraphs: [ + msg`Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.`, + msg`Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.`, + ], + callout: + '"Twenty is the heart of the system. Everything branches from it." - Azmat Parveen, Nine Dots Ventures', + }, + { + type: 'text', + eyebrow: msg`The result`, + heading: ( + + + {i18n._(msg`150 hours`)} + {' '} + + {i18n._(msg`saved every month`)} + + + ), + paragraphs: [ + msg`About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.`, + msg`The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.`, + ], + }, + ], + tableOfContents: [ + msg`When the channel is the business`, + msg`The CRM as a control hub`, + msg`The result`, + ], + catalogCard: { + summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`, + date: msg`Jul 2025`, + }, + }; - return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - - ); + return ; } diff --git a/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx b/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx new file mode 100644 index 0000000000..2a0ae8bf80 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx @@ -0,0 +1,77 @@ +import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; +import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; +import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; +import { CaseStudy } from '@/sections/CaseStudy'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { theme } from '@/theme'; + +import { CustomersCaseStudySignoff } from './CustomersCaseStudySignoff'; + +type CaseStudyPageLayoutProps = { + caseStudy: CaseStudyData; + path: string; +}; + +export async function CaseStudyPageLayout({ + caseStudy, + path, +}: CaseStudyPageLayoutProps) { + const stats = await fetchCommunityStats(); + const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); + const palette = getCaseStudyPalette(path); + + let storySectionIndex = 0; + const sectionBlocks = caseStudy.sections.map((block, index) => { + if (block.type === 'text') { + const sectionId = `case-study-section-${storySectionIndex}`; + storySectionIndex += 1; + return ( + + ); + } + return ( + + ); + }); + + return ( + <> + + + + + + + + + + + + {sectionBlocks} + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx b/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx index f23e2c28bd..1783a952f6 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx @@ -1,43 +1,29 @@ import { msg } from '@lingui/core/macro'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { HeadingPart, LinkButton } from '@/design-system/components'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { Pages } from '@/lib/pages'; -import { Signoff } from '@/sections/Signoff/components'; -import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; +import { Signoff } from '@/sections/Signoff'; -const SIGNOFF_BODY = { - text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`, -}; - -type CustomersCaseStudySignoffProps = { - renderText: (descriptor: MessageDescriptor) => string; -}; - -export function CustomersCaseStudySignoff({ - renderText, -}: CustomersCaseStudySignoffProps) { +export function CustomersCaseStudySignoff() { + const i18n = getServerI18n(); return ( - + - {renderText(msg`Ready to grow\nwith Twenty?`)} + {i18n._(msg`Ready to grow\nwith Twenty?`)} - + + {i18n._( + msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`, + )} + { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '7 min', + title: ( + + + {i18n._(msg`A CRM they`)} + +
+ + {i18n._(msg`actually own`)} + +
+ ), + author: 'Joseph Chiang', + authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp', + authorRole: msg`CRM Engineer, AC&T Education Migration`, + clientIcon: 'act-education', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`Education`, + kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }], + }, + sections: [ + { + type: 'text', + eyebrow: msg`AC&T Education Migration`, + heading: ( + + + {i18n._(msg`When the vendor`)} + {' '} + + {i18n._(msg`pulled the plug`)} + + + ), + paragraphs: [ + msg`AC&T Education Migration (actimmi.com) is an education agency in Australia. They help international students with applications to education providers and visas. They had been on a previous CRM until the vendor shut the system down, leaving nothing but a CSV export.`, + msg`Whatever came next had to be something they could own.`, + ], + callout: + '"They did not want to learn someone else\'s system. They wanted to keep working the way they already did and make it smoother." - Joseph Chiang, CRM Engineer, AC&T Education Migration', + }, + { + type: 'text', + eyebrow: msg`Implementation`, + heading: ( + + + {i18n._(msg`No more renting someone else's`)} + {' '} + + {i18n._(msg`structure`)} + + + ), + paragraphs: [ + msg`They evaluated Salesforce, Zoho, Pipedrive, and SuiteCRM. Each came with the same tradeoffs: too expensive, too rigid, or too generic, and none fixed the underlying problem. They were still renting a structure they did not control.`, + msg`Flycoder, a full-stack development partner, helped them set up Twenty as a self-hosted instance shaped around how AC&T actually operates. The data model centers on students, not a generic contact-and-deal pipeline. Statuses update automatically: a workflow runs nightly to keep enrollment records current. Automated email reminders cover important dates. Adding a new record takes under a minute.`, + msg`The result is a system that fits how AC&T already worked, instead of the other way around.`, + ], + }, + { + type: 'text', + eyebrow: msg`Control`, + heading: ( + + + {i18n._(msg`Control without`)} + {' '} + + {i18n._(msg`the overhead`)} + + + ), + paragraphs: [ + msg`Self-hosted means AC&T carries no vendor risk: no pricing model that can change, no platform that can disappear, no forced migration. The system is theirs.`, + msg`Because everything is built on Twenty's open foundation, Flycoder could wire the exact logic AC&T needed without fighting the platform.`, + ], + }, + { + type: 'text', + eyebrow: msg`The result`, + heading: ( + + + {i18n._(msg`Costs down more than`)} + {' '} + {i18n._(msg`90%`)} + + ), + paragraphs: [ + msg`CRM costs dropped by more than 90%. Manual overhead tied to the old system is gone. For the first time, AC&T has a CRM they will not lose again.`, + msg`They did not just replace a tool. They took back ownership of how their business runs.`, + ], + }, + ], + tableOfContents: [ + msg`When the vendor pulled the plug`, + msg`No more renting someone else's structure`, + msg`Control without the overhead`, + msg`The result`, + ], + catalogCard: { + summary: msg`AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.`, + date: msg`2025`, + }, + }; return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx index 0230c936ac..5b9ecc9b89 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx @@ -1,84 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff'; -import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { Heading, HeadingPart } from '@/design-system/components'; +import type { CaseStudyData } from '@/lib/customers'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudy } from '@/sections/CaseStudy/components'; -import { Menu } from '@/sections/Menu/components'; -import { theme } from '@/theme'; +} from '@/lib/i18n/utils/get-route-i18n'; import { buildLocalizedMetadata } from '@/lib/seo'; +import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout'; const PLACEHOLDER_HERO = - 'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80'; + 'https://images.unsplash.com/photo-1702047149248-a6049168a2a8?w=1600&q=80'; -const CASE_STUDY: CaseStudyData = { - meta: { - title: msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`, - description: msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`, - }, - hero: { - readingTime: '7 min', - title: [ - { text: msg`From Salesforce to`, fontFamily: 'serif' }, - { text: msg`self-hosted Twenty`, fontFamily: 'sans', newLine: true }, - ], - author: 'Benjamin Reynolds', - authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp', - authorRole: msg`Principal and Founder, Alternative Partners`, - clientIcon: 'alternative-partners', - heroImageSrc: PLACEHOLDER_HERO, - industry: msg`Consulting`, - kpis: [ - { value: msg`AI-assisted`, label: msg`Salesforce migration` }, - { value: msg`Self-hosted`, label: msg`Full ownership` }, - ], - }, - sections: [ - { - type: 'text', - eyebrow: msg`Alternative Partners`, - heading: [ - { text: msg`AI in the`, fontFamily: 'serif' }, - { text: msg`migration workflow`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.`, - msg`His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.`, - msg`It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.`, - ], - }, - { - type: 'text', - eyebrow: msg`Ownership`, - heading: [ - { text: msg`Self-hosted`, fontFamily: 'serif' }, - { text: msg`means control`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.`, - ], - }, - ], - tableOfContents: [ - msg`AI in the migration workflow`, - msg`Self-hosted means control`, - ], - catalogCard: { - summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`, - date: msg`2025`, - }, -}; +const META_TITLE = msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`; +const META_DESCRIPTION = msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`; export const generateMetadata = buildLocalizedMetadata({ path: '/customers/alternative-partners', - title: CASE_STUDY.meta.title, - description: CASE_STUDY.meta.description, + title: META_TITLE, + description: META_DESCRIPTION, }); type CaseStudyPageProps = { @@ -88,69 +27,89 @@ type CaseStudyPageProps = { export default async function AlternativePartnersCaseStudyPage({ params, }: CaseStudyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - const palette = getCaseStudyPalette('/customers/alternative-partners'); + const i18n = await getRouteI18n(params); - let storySectionIndex = 0; - const sectionBlocks = CASE_STUDY.sections.map((block, index) => { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '7 min', + title: ( + + + {i18n._(msg`From Salesforce to`)} + +
+ + {i18n._(msg`self-hosted Twenty`)} + +
+ ), + author: 'Benjamin Reynolds', + authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp', + authorRole: msg`Principal and Founder, Alternative Partners`, + clientIcon: 'alternative-partners', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`Consulting`, + kpis: [ + { value: msg`AI-assisted`, label: msg`Salesforce migration` }, + { value: msg`Self-hosted`, label: msg`Full ownership` }, + ], + }, + sections: [ + { + type: 'text', + eyebrow: msg`Alternative Partners`, + heading: ( + + + {i18n._(msg`AI in the`)} + {' '} + + {i18n._(msg`migration workflow`)} + + + ), + paragraphs: [ + msg`Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.`, + msg`His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.`, + msg`It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.`, + ], + }, + { + type: 'text', + eyebrow: msg`Ownership`, + heading: ( + + + {i18n._(msg`Self-hosted`)} + {' '} + + {i18n._(msg`means control`)} + + + ), + paragraphs: [ + msg`The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.`, + ], + }, + ], + tableOfContents: [ + msg`AI in the migration workflow`, + msg`Self-hosted means control`, + ], + catalogCard: { + summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`, + date: msg`2025`, + }, + }; return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx index 842c4ac0d7..e9213f44b1 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx @@ -1,127 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff'; -import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { type CaseStudyData } from '@/lib/customers'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudy } from '@/sections/CaseStudy/components'; -import { Menu } from '@/sections/Menu/components'; -import { theme } from '@/theme'; +} from '@/lib/i18n/utils/get-route-i18n'; import { buildLocalizedMetadata } from '@/lib/seo'; +import { Heading, HeadingPart } from '@/design-system/components'; +import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout'; const PLACEHOLDER_HERO = 'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80'; -const CASE_STUDY: CaseStudyData = { - meta: { - title: msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`, - description: msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`, - }, - hero: { - readingTime: '8 min', - title: [ - { text: msg`Twenty as the`, fontFamily: 'serif' }, - { text: msg`API backbone`, fontFamily: 'sans', newLine: true }, - { - text: msg`of a go-to-market stack`, - fontFamily: 'serif', - }, - ], - author: 'Justin Beadle', - authorRole: msg`Director of Digital and Information, Elevate Consulting`, - clientIcon: 'elevate-consulting', - heroImageSrc: PLACEHOLDER_HERO, - industry: msg`Management Consulting`, - kpis: [ - { value: msg`1 click`, label: msg`Proposal automation` }, - { value: msg`4 tools`, label: msg`Connected via API` }, - { value: msg`API-first`, label: msg`Tool integration` }, - ], - quote: { - text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`, - author: 'Justin Beadle', - role: msg`Director of Digital and Information, Elevate Consulting`, - }, - }, - sections: [ - { - type: 'text', - eyebrow: msg`The situation`, - heading: [ - { text: msg`From documents to`, fontFamily: 'serif' }, - { text: msg`open APIs`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.`, - msg`The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.`, - msg`In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.`, - ], - callout: - '"It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not." - Justin Beadle, Director of Digital and Information, Elevate Consulting', - }, - { - type: 'text', - eyebrow: msg`Integration`, - heading: [ - { text: msg`One`, fontFamily: 'serif' }, - { text: msg`API`, fontFamily: 'sans' }, - { text: msg`to rule them all`, fontFamily: 'serif' }, - ], - paragraphs: [ - msg`Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.`, - msg`That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.`, - msg`Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.`, - ], - callout: - '"Because Twenty\'s API is fully open, I could connect it to every other tool in our stack. When a proposal is won, what used to take four people manually setting things up across four different tools now happens in a single click. That is the kind of time saving that only becomes possible when everything is connected." - Justin Beadle, Director of Digital and Information, Elevate Consulting', - }, - { - type: 'text', - eyebrow: msg`Adoption`, - heading: [ - { text: msg`Workflows that`, fontFamily: 'serif' }, - { text: msg`actually get used`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.`, - msg`Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.`, - msg`The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.`, - ], - }, - { - type: 'text', - eyebrow: msg`What is next`, - heading: [ - { text: msg`Beyond`, fontFamily: 'serif' }, - { text: msg`internal rollout`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.`, - msg`For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.`, - ], - }, - ], - tableOfContents: [ - msg`From documents to open APIs`, - msg`One API to rule them all`, - msg`Workflows that actually get used`, - msg`What is next`, - ], - catalogCard: { - summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`, - date: msg`Jun 2025`, - }, -}; +const META_TITLE = msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`; +const META_DESCRIPTION = msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`; export const generateMetadata = buildLocalizedMetadata({ path: '/customers/elevate-consulting', - title: CASE_STUDY.meta.title, - description: CASE_STUDY.meta.description, + title: META_TITLE, + description: META_DESCRIPTION, }); type CaseStudyPageProps = { @@ -131,69 +27,139 @@ type CaseStudyPageProps = { export default async function ElevateConsultingCaseStudyPage({ params, }: CaseStudyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - const palette = getCaseStudyPalette('/customers/elevate-consulting'); + const i18n = await getRouteI18n(params); - let storySectionIndex = 0; - const sectionBlocks = CASE_STUDY.sections.map((block, index) => { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '8 min', + title: ( + + + {i18n._(msg`Twenty as the`)} + +
+ + {i18n._(msg`API backbone`)} + {' '} + + {i18n._(msg`of a go-to-market stack`)} + +
+ ), + author: 'Justin Beadle', + authorRole: msg`Director of Digital and Information, Elevate Consulting`, + clientIcon: 'elevate-consulting', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`Management Consulting`, + kpis: [ + { value: msg`1 click`, label: msg`Proposal automation` }, + { value: msg`4 tools`, label: msg`Connected via API` }, + { value: msg`API-first`, label: msg`Tool integration` }, + ], + quote: { + text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`, + author: 'Justin Beadle', + role: msg`Director of Digital and Information, Elevate Consulting`, + }, + }, + sections: [ + { + type: 'text', + eyebrow: msg`The situation`, + heading: ( + + + {i18n._(msg`From documents to`)} + {' '} + + {i18n._(msg`open APIs`)} + + + ), + paragraphs: [ + msg`Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.`, + msg`The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.`, + msg`In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.`, + ], + callout: + '"It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not." - Justin Beadle, Director of Digital and Information, Elevate Consulting', + }, + { + type: 'text', + eyebrow: msg`Integration`, + heading: ( + + {i18n._(msg`One`)}{' '} + {i18n._(msg`API`)}{' '} + + {i18n._(msg`to rule them all`)} + + + ), + paragraphs: [ + msg`Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.`, + msg`That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.`, + msg`Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.`, + ], + callout: + '"Because Twenty\'s API is fully open, I could connect it to every other tool in our stack. When a proposal is won, what used to take four people manually setting things up across four different tools now happens in a single click. That is the kind of time saving that only becomes possible when everything is connected." - Justin Beadle, Director of Digital and Information, Elevate Consulting', + }, + { + type: 'text', + eyebrow: msg`Adoption`, + heading: ( + + + {i18n._(msg`Workflows that`)} + {' '} + + {i18n._(msg`actually get used`)} + + + ), + paragraphs: [ + msg`The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.`, + msg`Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.`, + msg`The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.`, + ], + }, + { + type: 'text', + eyebrow: msg`What is next`, + heading: ( + + {i18n._(msg`Beyond`)}{' '} + + {i18n._(msg`internal rollout`)} + + + ), + paragraphs: [ + msg`Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.`, + msg`For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.`, + ], + }, + ], + tableOfContents: [ + msg`From documents to open APIs`, + msg`One API to rule them all`, + msg`Workflows that actually get used`, + msg`What is next`, + ], + catalogCard: { + summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`, + date: msg`Jun 2025`, + }, + }; return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx index be61519d52..ea9ac5ba20 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx @@ -1,118 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff'; -import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { type CaseStudyData } from '@/lib/customers'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudy } from '@/sections/CaseStudy/components'; -import { Menu } from '@/sections/Menu/components'; -import { theme } from '@/theme'; +} from '@/lib/i18n/utils/get-route-i18n'; import { buildLocalizedMetadata } from '@/lib/seo'; +import { Heading, HeadingPart } from '@/design-system/components'; +import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout'; const PLACEHOLDER_HERO = 'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80'; -const CASE_STUDY: CaseStudyData = { - meta: { - title: msg`A CRM that grows with you | NetZero & Twenty`, - description: msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`, - }, - hero: { - readingTime: '8 min', - title: [ - { text: msg`A CRM that`, fontFamily: 'serif' }, - { text: msg`grows`, fontFamily: 'sans', newLine: true }, - { text: msg`with you`, fontFamily: 'serif' }, - ], - author: 'Olivier Reinaud', - authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg', - authorRole: msg`Co-founder, NetZero`, - clientIcon: 'netzero', - heroImageSrc: PLACEHOLDER_HERO, - industry: msg`Agribusiness`, - kpis: [ - { value: msg`3 product lines`, label: msg`On a single CRM` }, - { value: msg`No-code`, label: msg`Customizations` }, - ], - }, - sections: [ - { - type: 'text', - eyebrow: msg`NetZero`, - heading: [ - { text: msg`The right`, fontFamily: 'serif' }, - { text: msg`foundation`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.`, - ], - callout: - '"Twenty delivers on what CRMs should have always been: fairly priced software with a fully modular and customizable model, a clean and modern UI, granular permissions, automations, enterprise features. A compelling solution with high potential to rightfully disrupt the CRM market." - Olivier Reinaud, co-founder of NetZero', - }, - { - type: 'text', - eyebrow: msg`Flexibility`, - heading: [ - { - text: msg`A business that does not fit a`, - fontFamily: 'serif', - }, - { text: msg`template`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.`, - msg`With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.`, - ], - callout: - '"The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly." - Olivier Reinaud, co-founder of NetZero', - }, - { - type: 'text', - eyebrow: msg`Roadmap`, - heading: [ - { text: msg`From simple to`, fontFamily: 'serif' }, - { text: msg`advanced`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.`, - msg`What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.`, - msg`What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.`, - ], - }, - { - type: 'text', - eyebrow: msg`Results`, - heading: [ - { text: msg`The bet is`, fontFamily: 'serif' }, - { text: msg`paying off`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.`, - msg`The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.`, - ], - }, - ], - tableOfContents: [ - msg`The right foundation`, - msg`A business that does not fit a template`, - msg`From simple to advanced`, - msg`The bet is paying off`, - ], - catalogCard: { - summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`, - date: msg`2025`, - }, -}; +const META_TITLE = msg`A CRM that grows with you | NetZero & Twenty`; +const META_DESCRIPTION = msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`; export const generateMetadata = buildLocalizedMetadata({ path: '/customers/netzero', - title: CASE_STUDY.meta.title, - description: CASE_STUDY.meta.description, + title: META_TITLE, + description: META_DESCRIPTION, }); type CaseStudyPageProps = { @@ -122,69 +27,123 @@ type CaseStudyPageProps = { export default async function NetZeroCaseStudyPage({ params, }: CaseStudyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - const palette = getCaseStudyPalette('/customers/netzero'); + const i18n = await getRouteI18n(params); - let storySectionIndex = 0; - const sectionBlocks = CASE_STUDY.sections.map((block, index) => { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '8 min', + title: ( + + + {i18n._(msg`A CRM that`)} + +
+ {i18n._(msg`grows`)}{' '} + {i18n._(msg`with you`)} +
+ ), + author: 'Olivier Reinaud', + authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp', + authorRole: msg`Co-founder, NetZero`, + clientIcon: 'netzero', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`Agribusiness`, + kpis: [ + { value: msg`3 product lines`, label: msg`On a single CRM` }, + { value: msg`No-code`, label: msg`Customizations` }, + ], + }, + sections: [ + { + type: 'text', + eyebrow: msg`NetZero`, + heading: ( + + + {i18n._(msg`The right`)} + {' '} + + {i18n._(msg`foundation`)} + + + ), + paragraphs: [ + msg`NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.`, + ], + callout: + '"Twenty delivers on what CRMs should have always been: fairly priced software with a fully modular and customizable model, a clean and modern UI, granular permissions, automations, enterprise features. A compelling solution with high potential to rightfully disrupt the CRM market." - Olivier Reinaud, co-founder of NetZero', + }, + { + type: 'text', + eyebrow: msg`Flexibility`, + heading: ( + + + {i18n._(msg`A business that does not fit a`)} + {' '} + {i18n._(msg`template`)} + + ), + paragraphs: [ + msg`What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.`, + msg`With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.`, + ], + callout: + '"The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly." - Olivier Reinaud, co-founder of NetZero', + }, + { + type: 'text', + eyebrow: msg`Roadmap`, + heading: ( + + + {i18n._(msg`From simple to`)} + {' '} + {i18n._(msg`advanced`)} + + ), + paragraphs: [ + msg`Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.`, + msg`What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.`, + msg`What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.`, + ], + }, + { + type: 'text', + eyebrow: msg`Results`, + heading: ( + + + {i18n._(msg`The bet is`)} + {' '} + + {i18n._(msg`paying off`)} + + + ), + paragraphs: [ + msg`While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.`, + msg`The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.`, + ], + }, + ], + tableOfContents: [ + msg`The right foundation`, + msg`A business that does not fit a template`, + msg`From simple to advanced`, + msg`The bet is paying off`, + ], + catalogCard: { + summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`, + date: msg`2025`, + }, + }; return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx index 14b668f1af..0612ce01f2 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx @@ -1,38 +1,25 @@ import { msg } from '@lingui/core/macro'; -import { FAQ_DATA } from '@/sections/Faq/data'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { Faq, FAQ_QUESTIONS } from '@/sections/Faq'; +import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers'; import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components'; -import { Faq } from '@/sections/Faq/components'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { Signoff } from '@/sections/Signoff/components'; -import { TrustedBy } from '@/sections/TrustedBy/components'; -import { theme } from '@/theme'; +import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Signoff } from '@/sections/Signoff'; import { buildRouteMetadata } from '@/lib/seo'; import { css } from '@linaria/core'; export const generateMetadata = buildRouteMetadata('customers'); -const HERO_BODY = { - text: msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`, -}; - -const SIGNOFF_BODY = { - text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`, -}; - const CUSTOMERS_TOP_BACKGROUND_COLOR = '#F4F4F4'; const pageRevealClassName = css` @@ -72,7 +59,6 @@ export default async function CaseStudiesCatalogPage({ getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); return ( @@ -90,71 +76,56 @@ export default async function CaseStudiesCatalogPage({
- + - {renderText(msg`See how teams`)} + {i18n._(msg`See how teams`)}
- {renderText(msg`build`)} + {i18n._(msg`build`)} {' '} - {renderText(msg`on Twenty`)} + {i18n._(msg`on Twenty`)}
- + + {i18n._( + msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`, + )} +
- - - - + separator={i18n._(msg`trusted by`)} + logos={TRUSTED_BY_LOGOS} + clientCount={i18n._(msg`+10k others`)} + />
- + - + - {renderText(msg`Ready to build`)} + {i18n._(msg`Ready to build`)}
- {renderText(msg`your own story?`)} + {i18n._(msg`your own story?`)}
- + + {i18n._( + msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`, + )} + - + + + {i18n._(msg`Any Questions?`)} + + - {renderText(msg`Stop fighting custom.`)} + {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)} + {i18n._(msg`Start building, with Twenty`)}
- + ); diff --git a/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx index 0314103f80..6ae2884076 100644 --- a/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx @@ -1,117 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff'; -import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { type CaseStudyData } from '@/lib/customers'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudy } from '@/sections/CaseStudy/components'; -import { Menu } from '@/sections/Menu/components'; -import { theme } from '@/theme'; +} from '@/lib/i18n/utils/get-route-i18n'; import { buildLocalizedMetadata } from '@/lib/seo'; +import { Heading, HeadingPart } from '@/design-system/components'; +import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout'; const PLACEHOLDER_HERO = 'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80'; -const CASE_STUDY: CaseStudyData = { - meta: { - title: msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`, - description: msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`, - }, - hero: { - readingTime: '8 min', - title: [ - { text: msg`When your CRM is`, fontFamily: 'serif' }, - { text: msg`the product`, fontFamily: 'sans', newLine: true }, - ], - author: 'Amrendra Pratap Singh', - authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp', - authorRole: msg`VP of Engineering, W3villa Technologies`, - clientIcon: 'w3villa', - heroImageSrc: PLACEHOLDER_HERO, - industry: msg`EdTech`, - kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }], - }, - sections: [ - { - type: 'text', - eyebrow: msg`W3Grads`, - heading: [ - { text: msg`Scale without`, fontFamily: 'serif' }, - { text: msg`breaking operations`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.`, - msg`W3villa Technologies set out to solve it properly, not with a workaround, but with a product.`, - ], - callout: - '"We did not want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind." - Amrendra Pratap Singh, VP of Engineering, W3villa Technologies', - }, - { - type: 'text', - eyebrow: msg`Architecture`, - heading: [ - { - text: msg`Focus on the use case, not the`, - fontFamily: 'serif', - }, - { text: msg`plumbing`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.`, - msg`The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.`, - msg`When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.`, - ], - }, - { - type: 'text', - eyebrow: msg`Scale`, - heading: [ - { - text: msg`A platform ready to`, - fontFamily: 'serif', - }, - { text: msg`grow`, fontFamily: 'sans' }, - ], - paragraphs: [ - msg`Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.`, - ], - callout: - '"Twenty gave us the flexibility to model the entire interview lifecycle as custom objects and workflows. We could build something genuinely complex without fighting the platform to do it." - Piyush Khandelwal, Director, W3villa Technologies, Partner', - }, - { - type: 'text', - eyebrow: msg`The result`, - heading: [ - { text: msg`Zero manual work`, fontFamily: 'sans' }, - { text: msg`at the core`, fontFamily: 'serif' }, - ], - paragraphs: [ - msg`Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.`, - msg`Zero manual work at the core. Full automation. Built on Twenty.`, - ], - }, - ], - tableOfContents: [ - msg`Scale without breaking operations`, - msg`Focus on the use case, not the plumbing`, - msg`A platform ready to grow`, - msg`The result`, - ], - catalogCard: { - summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`, - date: msg`2025`, - }, -}; +const META_TITLE = msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`; +const META_DESCRIPTION = msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`; export const generateMetadata = buildLocalizedMetadata({ path: '/customers/w3villa', - title: CASE_STUDY.meta.title, - description: CASE_STUDY.meta.description, + title: META_TITLE, + description: META_DESCRIPTION, }); type CaseStudyPageProps = { @@ -121,69 +27,121 @@ type CaseStudyPageProps = { export default async function W3villaCaseStudyPage({ params, }: CaseStudyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - const palette = getCaseStudyPalette('/customers/w3villa'); + const i18n = await getRouteI18n(params); - let storySectionIndex = 0; - const sectionBlocks = CASE_STUDY.sections.map((block, index) => { - if (block.type === 'text') { - const sectionId = `case-study-section-${storySectionIndex}`; - storySectionIndex += 1; - return ( - - ); - } - return ( - - ); - }); + const caseStudy: CaseStudyData = { + meta: { + title: META_TITLE, + description: META_DESCRIPTION, + }, + hero: { + readingTime: '8 min', + title: ( + + + {i18n._(msg`When your CRM is`)} + +
+ + {i18n._(msg`the product`)} + +
+ ), + author: 'Amrendra Pratap Singh', + authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp', + authorRole: msg`VP of Engineering, W3villa Technologies`, + clientIcon: 'w3villa', + heroImageSrc: PLACEHOLDER_HERO, + industry: msg`EdTech`, + kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }], + }, + sections: [ + { + type: 'text', + eyebrow: msg`W3Grads`, + heading: ( + + + {i18n._(msg`Scale without`)} + {' '} + + {i18n._(msg`breaking operations`)} + + + ), + paragraphs: [ + msg`Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.`, + msg`W3villa Technologies set out to solve it properly, not with a workaround, but with a product.`, + ], + callout: + '"We did not want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind." - Amrendra Pratap Singh, VP of Engineering, W3villa Technologies', + }, + { + type: 'text', + eyebrow: msg`Architecture`, + heading: ( + + + {i18n._(msg`Focus on the use case, not the`)} + {' '} + {i18n._(msg`plumbing`)} + + ), + paragraphs: [ + msg`W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.`, + msg`The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.`, + msg`When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.`, + ], + }, + { + type: 'text', + eyebrow: msg`Scale`, + heading: ( + + + {i18n._(msg`A platform ready to`)} + {' '} + {i18n._(msg`grow`)} + + ), + paragraphs: [ + msg`Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.`, + ], + callout: + '"Twenty gave us the flexibility to model the entire interview lifecycle as custom objects and workflows. We could build something genuinely complex without fighting the platform to do it." - Piyush Khandelwal, Director, W3villa Technologies, Partner', + }, + { + type: 'text', + eyebrow: msg`The result`, + heading: ( + + + {i18n._(msg`Zero manual work`)} + {' '} + + {i18n._(msg`at the core`)} + + + ), + paragraphs: [ + msg`Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.`, + msg`Zero manual work at the core. Full automation. Built on Twenty.`, + ], + }, + ], + tableOfContents: [ + msg`Scale without breaking operations`, + msg`Focus on the use case, not the plumbing`, + msg`A platform ready to grow`, + msg`The result`, + ], + catalogCard: { + summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`, + date: msg`2025`, + }, + }; return ( - <> - - - - - - - - - - - - {sectionBlocks} - - - - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx index 27ef265792..bd02d9ffe8 100644 --- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx +++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx @@ -5,20 +5,16 @@ import { BaseButton, buttonBaseStyles, } from '@/design-system/components/Button/BaseButton'; -import { Body, Heading } from '@/design-system/components'; -import { useRenderMessage } from '@/lib/i18n/use-render-message'; +import { Body, Heading, HeadingPart } from '@/design-system/components'; +import { useLingui } from '@lingui/react'; import { useTimeoutRegistry } from '@/lib/react'; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; -type ActivationResult = { - enterpriseKey: string; - licensee: string; - subscriptionId: string; -}; +import { useEnterpriseActivation } from './use-enterprise-activation'; const ContentStack = styled.div` display: flex; @@ -140,73 +136,13 @@ const nextStepItemClassName = css` `; export function EnterpriseActivateClient() { - const renderText = useRenderMessage(); + const { i18n } = useLingui(); const searchParams = useSearchParams(); const sessionId = searchParams.get('session_id'); const timeoutRegistry = useTimeoutRegistry(); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); + const { result, error, loading } = useEnterpriseActivation(sessionId); const [copied, setCopied] = useState(false); - useEffect(() => { - if (!sessionId) { - setError('No session ID provided. Please complete the checkout first.'); - setLoading(false); - - return; - } - - const abortController = new AbortController(); - - const activate = async () => { - try { - const response = await fetch( - `/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`, - { signal: abortController.signal }, - ); - const data: { error?: string } & Partial = - await response.json(); - - if (abortController.signal.aborted) { - return; - } - - if (!response.ok) { - setError(data.error ?? 'Activation failed'); - - return; - } - - if (data.enterpriseKey && data.licensee && data.subscriptionId) { - setResult({ - enterpriseKey: data.enterpriseKey, - licensee: data.licensee, - subscriptionId: data.subscriptionId, - }); - } else { - setError('Activation response was incomplete.'); - } - } catch { - if (abortController.signal.aborted) { - return; - } - - setError('Failed to activate enterprise key. Please try again.'); - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } - } - }; - - void activate(); - - return () => { - abortController.abort(); - }; - }, [sessionId]); - const handleCopy = async () => { if (!result) { return; @@ -222,55 +158,39 @@ export function EnterpriseActivateClient() { return ( {loading && ( - + + {i18n._(msg`Activating your enterprise license…`)} + )} {error !== null && {error}} {result !== null && ( <> - + + {i18n._( + msg`Your enterprise license has been activated successfully.`, + )} + - {' '} + + {i18n._(msg`Licensee:`)} + {' '} {result.licensee} - - + + + {i18n._(msg`Your enterprise key`)} + + + + {i18n._( + msg`Copy this key and paste it into your Twenty self-hosted instance settings.`, + )} + {result.enterpriseKey} @@ -284,7 +204,7 @@ export function EnterpriseActivateClient() { > @@ -293,36 +213,28 @@ export function EnterpriseActivateClient() { - + + + {i18n._(msg`Next steps`)} + +
  • - + + {i18n._(msg`Copy the enterprise key above.`)} +
  • - + + {i18n._( + msg`Open your Twenty self-hosted instance Settings → Enterprise.`, + )} +
  • - + + {i18n._(msg`Paste the key and click Activate.`)} +
  • diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx index c53a9edad7..dceb039881 100644 --- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx @@ -1,5 +1,4 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; import { EnterpriseActivateClient } from '@/app/[locale]/enterprise/activate/EnterpriseActivateClient'; import { Body, @@ -8,15 +7,14 @@ import { HeadingPart, } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; import { theme } from '@/theme'; import { buildRouteMetadata } from '@/lib/seo'; import { Suspense } from 'react'; @@ -24,10 +22,6 @@ import { styled } from '@linaria/react'; export const generateMetadata = buildRouteMetadata('enterpriseActivate'); -const ENTERPRISE_ACTIVATE_BODY = { - text: msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`, -}; - const ActivatePageContent = styled.section` background-color: ${theme.colors.primary.background[100]}; flex: 1; @@ -51,7 +45,9 @@ function EnterpriseActivateFallback({ loadingLabel, }: EnterpriseActivateFallbackProps) { return ( - + + {loadingLabel} + ); } @@ -66,7 +62,6 @@ export default async function EnterpriseActivatePage({ getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); return ( @@ -83,28 +78,23 @@ export default async function EnterpriseActivatePage({ - - + + + + {i18n._(msg`Self-hosting`)} + + - {renderText(msg`Enterprise`)} + {i18n._(msg`Enterprise`)} {' '} - - {renderText(msg`activation`)} - + {i18n._(msg`activation`)} - + + {i18n._( + msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`, + )} + @@ -113,7 +103,7 @@ export default async function EnterpriseActivatePage({ } > diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts new file mode 100644 index 0000000000..7f0fc55446 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type ActivationResult = { + enterpriseKey: string; + licensee: string; + subscriptionId: string; +}; + +type EnterpriseActivationState = { + error: string | null; + loading: boolean; + result: ActivationResult | null; +}; + +export function useEnterpriseActivation( + sessionId: string | null, +): EnterpriseActivationState { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!sessionId) { + setError('No session ID provided. Please complete the checkout first.'); + setLoading(false); + return; + } + + const abortController = new AbortController(); + + const activate = async () => { + try { + const response = await fetch( + `/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`, + { signal: abortController.signal }, + ); + const data: { error?: string } & Partial = + await response.json(); + + if (abortController.signal.aborted) { + return; + } + + if (!response.ok) { + setError(data.error ?? 'Activation failed'); + return; + } + + if (data.enterpriseKey && data.licensee && data.subscriptionId) { + setResult({ + enterpriseKey: data.enterpriseKey, + licensee: data.licensee, + subscriptionId: data.subscriptionId, + }); + } else { + setError('Activation response was incomplete.'); + } + } catch { + if (abortController.signal.aborted) { + return; + } + setError('Failed to activate enterprise key. Please try again.'); + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + + void activate(); + + return () => { + abortController.abort(); + }; + }, [sessionId]); + + return { error, loading, result }; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx index 223715f717..25d39e1b8e 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx @@ -1,7 +1,7 @@ 'use client'; -import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters'; -import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react'; +import type { ReactExportSettings } from '@/lib/halftone/utils/exporters'; +import { IconLayoutSidebarRightCollapse } from '@tabler/icons-react'; import { styled } from '@linaria/react'; import type { HalftoneBackgroundSettings, @@ -9,7 +9,7 @@ import type { HalftoneSourceMode, HalftoneStudioSettings, HalftoneTabId, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; import { AnimationsTab } from './controls/AnimationsTab'; import { DesignTab } from './controls/DesignTab'; import { ExportTab } from './controls/ExportTab'; @@ -25,7 +25,6 @@ type ControlsPanelProps = { value: Partial, ) => void; onBackgroundChange: (value: Partial) => void; - onCopyShareLink: () => void; onDashColorChange: (value: string) => void; onHoverDashColorChange: (value: string) => void; onCopyHalftoneImage: (width: number, height: number) => void; @@ -133,7 +132,6 @@ export function ControlsPanel({ imageFileName, onAnimationSettingsChange, onBackgroundChange, - onCopyShareLink, onDashColorChange, onHoverDashColorChange, onCopyHalftoneImage, @@ -184,14 +182,6 @@ export function ControlsPanel({ ) : null} - - - ; -const DESKTOP_CONTROLS_PANEL_WIDTH = 320; -const DESKTOP_CONTROLS_PANEL_OFFSET = 20; -const DESKTOP_CONTROLS_PANEL_FOOTPRINT = - DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET; +import { CopyHalftoneShortcutEffect } from './effect-components/CopyHalftoneShortcutEffect'; +import { PasteImageEffect } from './effect-components/PasteImageEffect'; +import { useDefaultHalftoneImage } from '../_hooks/use-default-halftone-image'; +import { useGeometryLoader } from '../_hooks/use-geometry-loader'; +import { useImageElement } from '../_hooks/use-image-element'; + +import { + CanvasLayer, + ContentRegion, + ControlsPanelFrame, + ControlsPositioner, + HiddenFileInput, + Hint, + LogoLink, + Status, + StudioShell, +} from './halftone-studio.styles'; + const STATUS_CLEAR_DELAY_MS = 2000; -const StudioShell = styled.div<{ $background: string }>` - background: ${(props) => props.$background}; - /* Full-screen tool: track the visible viewport so the bottom controls bar - * is reachable on mobile Safari (where 100vh extends behind the URL bar). */ - height: 100vh; - height: 100dvh; - overflow: hidden; - position: relative; - width: 100%; -`; - -const ContentRegion = styled.div<{ $panelOpen: boolean }>` - height: 100%; - margin-right: auto; - position: relative; - transition: width 0.18s ease; - width: ${(props) => - props.$panelOpen - ? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)` - : '100%'}; - - @media (max-width: ${theme.breakpoints.md - 1}px) { - width: 100%; - } -`; - -const CanvasLayer = styled.div` - height: 100%; - width: 100%; -`; - -const LogoLink = styled(LocalizedLink)` - display: grid; - left: 24px; - position: absolute; - top: 24px; - z-index: 2; -`; - -const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>` - color: ${(props) => - props.$tone === 'dark' - ? 'rgba(255, 255, 255, 0.34)' - : 'rgba(0, 0, 0, 0.3)'}; - font-family: ${theme.font.family.mono}; - font-size: 11px; - left: 50%; - opacity: ${(props) => (props.$visible ? 1 : 0)}; - pointer-events: none; - position: absolute; - top: 20px; - transform: translateX(-50%); - transition: opacity 0.3s ease; - z-index: 1; -`; - -const Status = styled.div<{ - $error: boolean; - $tone: 'dark' | 'light'; - $visible: boolean; -}>` - color: ${(props) => { - if (props.$error) { - return 'rgba(214, 66, 66, 0.92)'; - } - - return props.$tone === 'dark' - ? 'rgba(255, 255, 255, 0.62)' - : 'rgba(0, 0, 0, 0.55)'; - }}; - font-family: ${theme.font.family.mono}; - font-size: 11px; - left: 50%; - opacity: ${(props) => (props.$visible ? 1 : 0)}; - pointer-events: none; - position: absolute; - top: 44px; - transform: translateX(-50%); - transition: opacity 0.2s ease; - z-index: 1; -`; - -const ControlsPositioner = styled.div` - align-items: flex-start; - bottom: 20px; - display: flex; - height: calc(100vh - 40px); - height: calc(100dvh - 40px); - justify-content: flex-end; - pointer-events: none; - position: fixed; - right: 20px; - top: 20px; - z-index: 3; - - @media (max-width: ${theme.breakpoints.md - 1}px) { - bottom: 16px; - height: 48vh; - left: 16px; - right: 16px; - top: auto; - } -`; - -const ControlsPanelFrame = styled.div<{ $visible: boolean }>` - height: ${(props) => (props.$visible ? '100%' : 'auto')}; - pointer-events: auto; - @media (max-width: ${theme.breakpoints.md - 1}px) { - width: ${(props) => (props.$visible ? '100%' : 'auto')}; - } -`; - -const HiddenFileInput = styled.input` - display: none; -`; - const DEFAULT_PREVIEW_DISTANCE = 6; const DEFAULT_IMAGE_ASSET_PATH = '/images/shared/halftone/twenty-logo.svg'; const DEFAULT_IMAGE_FILENAME = 'twenty-logo.svg'; -const CLIPBOARD_IMAGE_EXTENSION_BY_MIME: Record = { - 'image/bmp': '.bmp', - 'image/gif': '.gif', - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/svg+xml': '.svg', - 'image/webp': '.webp', -}; type PendingFilePicker = { resolve: (file: File | null) => void; }; @@ -239,42 +115,6 @@ function getAssetFilenameFromUrl(assetUrl: string, fallbackFilename: string) { : fallbackFilename; } -function isEditablePasteTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) { - return false; - } - - return ( - target.isContentEditable || - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target.closest('[contenteditable=""], [contenteditable="true"]') !== null - ); -} - -function createPastedImageFile(file: File) { - const hasExtension = /\.[^.]+$/i.test(file.name); - - if (hasExtension) { - return file; - } - - const extension = - CLIPBOARD_IMAGE_EXTENSION_BY_MIME[file.type] ?? - CLIPBOARD_IMAGE_EXTENSION_BY_MIME['image/png']; - - return new File([file], `pasted-image-${Date.now()}${extension}`, { - type: file.type || 'image/png', - lastModified: Date.now(), - }); -} - -function hasTextSelection() { - const selection = window.getSelection(); - - return selection !== null && selection.toString().trim().length > 0; -} - function createInitialExportPose(): HalftoneExportPose { return { autoElapsed: 0, @@ -358,18 +198,12 @@ export function HalftoneStudio() { const [previewDistance, setPreviewDistance] = useState( DEFAULT_PREVIEW_DISTANCE, ); - const [activeGeometry, setActiveGeometry] = useState( - () => createFallbackGeometry(), - ); const [exportBackground, setExportBackground] = useState(false); const [exportName, setExportName] = useState(''); const [reactExportSettings, setReactExportSettings] = useState(DEFAULT_REACT_EXPORT_SETTINGS); const [reactAssetPublicUrl, setReactAssetPublicUrl] = useState(''); const [imageFile, setImageFile] = useState(null); - const [imageElement, setImageElement] = useState( - null, - ); const [canvasInitialPose, setCanvasInitialPose] = useState(); const defaultImageFileReference = useRef(null); @@ -380,10 +214,6 @@ export function HalftoneStudio() { const presetFileInputReference = useRef(null); const pendingFilePickerReference = useRef(null); const pendingPresetPickerReference = useRef(null); - const geometryCacheReference = useRef>( - new Map([['torusKnot', activeGeometry]]), - ); - const lastSuccessfulShapeReference = useRef(state.settings.shapeKey); const exportPoseReference = useRef( createInitialExportPose(), ); @@ -400,6 +230,8 @@ export function HalftoneStudio() { ); }, [timeoutRegistry]); + const imageElement = useImageElement(imageFile); + const selectedShape = useMemo( () => state.geometrySpecs.find( @@ -407,6 +239,26 @@ export function HalftoneStudio() { ), [state.geometrySpecs, state.settings.shapeKey], ); + + const geometryLoaderCallbacks = useMemo( + () => ({ + onLoadStart: (label: string) => + dispatch({ type: 'setStatus', message: `Loading ${label}…` }), + onLoadSuccess: () => dispatch({ type: 'clearStatus' }), + onLoadError: (message: string) => + dispatch({ type: 'setStatus', message, isError: true }), + onFallbackShape: (key: string) => + dispatch({ type: 'setShapeKey', value: key }), + }), + [], + ); + + const activeGeometry = useGeometryLoader( + selectedShape, + state.importedFiles, + geometryLoaderCallbacks, + ); + const shapeOptions = useMemo( () => state.geometrySpecs.map((shape) => ({ @@ -485,67 +337,6 @@ export function HalftoneStudio() { }; }, [state.settings.halftone, state.settings.sourceMode]); - const hashHydratedReference = useRef(false); - useEffect(() => { - if (hashHydratedReference.current) { - return; - } - - hashHydratedReference.current = true; - - const decoded = decodeShareState(window.location.hash); - - if (!decoded) { - return; - } - - dispatch({ type: 'replaceSettings', value: decoded.settings }); - setPreviewDistance(decoded.previewDistance); - setExportName(decoded.exportName); - }, []); - - const hashSyncInitializedReference = useRef(false); - useEffect(() => { - if (!hashSyncInitializedReference.current) { - hashSyncInitializedReference.current = true; - return; - } - - const timeout = window.setTimeout(() => { - const hash = encodeShareState({ - settings: state.settings, - previewDistance, - exportName, - }); - const nextUrl = `${window.location.pathname}${window.location.search}#${hash}`; - window.history.replaceState(null, '', nextUrl); - }, 250); - - return () => window.clearTimeout(timeout); - }, [state.settings, previewDistance, exportName]); - - const handleCopyShareLink = useCallback(() => { - const url = buildShareUrl({ - settings: state.settings, - previewDistance, - exportName, - }); - - void navigator.clipboard - .writeText(url) - .then(() => { - dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' }); - clearStatusLater(); - }) - .catch(() => { - dispatch({ - type: 'setStatus', - message: 'Could not copy the share link.', - isError: true, - }); - }); - }, [clearStatusLater, exportName, previewDistance, state.settings]); - const handleCopyHalftoneImage = useCallback( async (width: number, height: number) => { const snapshotFn = snapshotReference.current; @@ -602,37 +393,6 @@ export function HalftoneStudio() { [clearStatusLater, exportBackground, state.settings.background.color], ); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isCopyShortcut = - (event.metaKey || event.ctrlKey) && - !event.shiftKey && - !event.altKey && - event.key.toLowerCase() === 'c'; - - if (!isCopyShortcut) { - return; - } - - if (isEditablePasteTarget(event.target) || hasTextSelection()) { - return; - } - - const canvasLayer = canvasLayerReference.current; - const width = Math.max(canvasLayer?.clientWidth ?? 0, 1); - const height = Math.max(canvasLayer?.clientHeight ?? 0, 1); - - event.preventDefault(); - void handleCopyHalftoneImage(width, height); - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [handleCopyHalftoneImage]); - const openFilePicker = useCallback( (accept: string) => { return new Promise((resolve) => { @@ -829,47 +589,6 @@ export function HalftoneStudio() { [state.settings.halftone, state.settings.sourceMode], ); - useEffect(() => { - const handlePaste = (event: ClipboardEvent) => { - if (isEditablePasteTarget(event.target)) { - return; - } - - const imageItem = Array.from(event.clipboardData?.items ?? []).find( - (item) => item.type.startsWith('image/'), - ); - - if (!imageItem) { - return; - } - - const pastedFile = imageItem.getAsFile(); - - if (!pastedFile) { - dispatch({ - type: 'setStatus', - message: 'Could not read the pasted image.', - isError: true, - }); - return; - } - - event.preventDefault(); - activateUploadedImage(createPastedImageFile(pastedFile)); - dispatch({ - type: 'setStatus', - message: 'Image pasted from clipboard.', - }); - clearStatusLater(); - }; - - window.addEventListener('paste', handlePaste); - - return () => { - window.removeEventListener('paste', handlePaste); - }; - }, [activateUploadedImage, clearStatusLater]); - const handleUploadSource = useCallback(async () => { const file = await openFilePicker( '.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg', @@ -1337,96 +1056,7 @@ export function HalftoneStudio() { state.settings, ]); - useEffect(() => { - void loadDefaultImageFile() - .then((file) => { - setImageFile((currentFile) => currentFile ?? file); - }) - .catch((error) => { - console.error(error); - }); - }, [loadDefaultImageFile]); - - useEffect(() => { - if (!imageFile) { - setImageElement(null); - return; - } - - const url = URL.createObjectURL(imageFile); - const img = new Image(); - img.onload = () => setImageElement(img); - img.src = url; - - return () => URL.revokeObjectURL(url); - }, [imageFile]); - - useEffect(() => { - const geometryCache = geometryCacheReference.current; - - return () => { - disposeGeometryCache(geometryCache); - }; - }, []); - - useEffect(() => { - if (!selectedShape) { - return; - } - - let cancelled = false; - - if (selectedShape.kind === 'imported') { - dispatch({ - type: 'setStatus', - message: `Loading ${selectedShape.label}…`, - }); - } else { - dispatch({ type: 'clearStatus' }); - } - - void getGeometryForSpec( - selectedShape, - state.importedFiles[selectedShape.key], - geometryCacheReference.current, - ) - .then((geometry) => { - if (cancelled) { - return; - } - - lastSuccessfulShapeReference.current = selectedShape.key; - setActiveGeometry(geometry); - dispatch({ type: 'clearStatus' }); - }) - .catch((error) => { - if (cancelled) { - return; - } - - console.error(error); - - dispatch({ - type: 'setStatus', - message: - error instanceof Error - ? error.message - : `${selectedShape.label} failed to load.`, - isError: true, - }); - - if (lastSuccessfulShapeReference.current !== selectedShape.key) { - dispatch({ - type: 'setShapeKey', - value: lastSuccessfulShapeReference.current, - }); - } - }); - - return () => { - cancelled = true; - }; - }, [selectedShape, state.importedFiles]); + useDefaultHalftoneImage({ loadDefaultImageFile, setImageFile }); const background = state.settings.background.color; const backgroundTone = isLightColor(background) ? 'light' : 'dark'; @@ -1434,8 +1064,40 @@ export function HalftoneStudio() { dispatch({ type: 'hideHint' }); }, []); + const handlePasteImage = useCallback( + (file: File) => { + activateUploadedImage(file); + dispatch({ type: 'setStatus', message: 'Image pasted from clipboard.' }); + clearStatusLater(); + }, + [activateUploadedImage, clearStatusLater], + ); + + const handlePasteError = useCallback(() => { + dispatch({ + type: 'setStatus', + message: 'Could not read the pasted image.', + isError: true, + }); + }, []); + + const handleCopyShortcut = useCallback( + (width: number, height: number) => { + void handleCopyHalftoneImage(width, height); + }, + [handleCopyHalftoneImage], + ); + return ( + + dispatch({ type: 'patchAnimation', value }) } - onCopyShareLink={handleCopyShareLink} onDashColorChange={(value) => dispatch({ type: 'patchHalftone', diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx index e1e281122a..8d0b61722d 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx @@ -4,8 +4,8 @@ import { formatAngle, formatDecimal, formatPercent, -} from '@/app/[locale]/halftone/_lib/formatters'; -import type { HalftoneStudioSettings } from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/formatters'; +import type { HalftoneStudioSettings } from '@/lib/halftone/utils/state'; import { ColorControlLabel, ColorControlRow, diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx new file mode 100644 index 0000000000..41b1a4180e --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useEffect, useRef, useState } from 'react'; + +import { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export const ColorControlRow = styled.div` + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); +`; + +export const ColorControlLabel = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 11px; +`; + +export const ColorPickerField = styled.div` + align-items: center; + cursor: text; + display: flex; + gap: 8px; + justify-content: flex-end; + justify-self: end; + min-height: 16px; + min-width: fit-content; + padding: 0; + position: relative; + width: auto; +`; + +export const ColorSwatch = styled.span` + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); + flex-shrink: 0; + height: 16px; + width: 16px; +`; + +export const ColorHexInput = styled.input` + appearance: none; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.85); + font-family: ${theme.font.family.mono}; + font-size: 11px; + height: 16px; + letter-spacing: 0.02em; + line-height: 16px; + min-width: 7ch; + outline: none; + padding: 0; + text-align: right; + text-transform: uppercase; + width: 7ch; + + &::placeholder { + color: rgba(255, 255, 255, 0.32); + } +`; + +export const ColorSwatchButton = styled.label` + align-items: center; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + height: 16px; + justify-content: center; + position: relative; +`; + +export const ColorInput = styled.input` + appearance: none; + border: none; + cursor: pointer; + height: 100%; + inset: 0; + outline: none; + opacity: 0; + padding: 0; + position: absolute; + width: 100%; +`; + +function normalizeHexColor(value: string): string | null { + const normalizedValue = value.trim().replace(/^#/, ''); + + if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue)) { + return null; + } + + return `#${normalizedValue.toUpperCase()}`; +} + +type ColorFieldProps = { + ariaLabel: string; + onChange: (value: string) => void; + value: string; +}; + +export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) { + const normalizedValue = value.toUpperCase(); + const [draftValue, setDraftValue] = useState(normalizedValue); + const escapePressedReference = useRef(false); + + useEffect(() => { + setDraftValue(normalizedValue); + }, [normalizedValue]); + + const commitDraftValue = () => { + if (escapePressedReference.current) { + escapePressedReference.current = false; + return; + } + + const nextValue = normalizeHexColor(draftValue); + + if (!nextValue) { + setDraftValue(normalizedValue); + return; + } + + setDraftValue(nextValue); + + if (nextValue !== normalizedValue) { + onChange(nextValue); + } + }; + + return ( + + event.currentTarget.select()} + onChange={(event) => setDraftValue(event.target.value.toUpperCase())} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + escapePressedReference.current = true; + setDraftValue(normalizedValue); + event.currentTarget.blur(); + } + }} + spellCheck={false} + type="text" + value={draftValue} + /> + + + onChange(event.target.value)} + type="color" + value={value} + /> + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx new file mode 100644 index 0000000000..d134655387 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx @@ -0,0 +1,188 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +import { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export const PanelShell = styled.aside<{ $collapsed?: boolean }>` + background: rgba(18, 18, 22, 0.88); + /* -webkit- prefix is required for the blur to render on Safari < 18. */ + -webkit-backdrop-filter: blur(24px) saturate(1.4); + backdrop-filter: blur(24px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + color: rgba(255, 255, 255, 0.7); + display: flex; + flex-direction: column; + font-family: ${theme.font.family.sans}; + font-size: 11px; + height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; + overflow: hidden; + width: ${(props) => + props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'}; + + @media (max-width: ${theme.breakpoints.md - 1}px) { + height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; + width: ${(props) => (props.$collapsed ? 'auto' : '100%')}; + } +`; + +export const ControlGrid = styled.div` + display: grid; + gap: 10px; +`; + +export const ValueDisplay = styled.div` + align-items: center; + background-color: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + display: flex; + font-family: ${theme.font.family.sans}; + font-size: 11px; + min-height: 31px; + overflow: hidden; + padding: 7px 10px; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +`; + +export const ShapeRow = styled.div` + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; +`; + +export const UploadButton = styled.button` + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; + flex-shrink: 0; + font-size: 13px; + height: 24px; + justify-content: center; + transition: all 0.15s ease; + width: 24px; + + &:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.22); + color: rgba(255, 255, 255, 0.8); + } +`; + +export const ExportNameInput = styled.input` + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + font-family: ${theme.font.family.sans}; + font-size: 11px; + margin-bottom: 14px; + outline: none; + padding: 7px 10px; + transition: border-color 0.15s ease; + width: 100%; + + &:hover { + border-color: rgba(255, 255, 255, 0.25); + } + + &:focus { + border-color: #4a38f5; + } + + &::placeholder { + color: rgba(255, 255, 255, 0.3); + } +`; + +export const ExportPreview = styled.div` + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + color: rgba(255, 255, 255, 0.55); + font-family: ${theme.font.family.mono}; + font-size: 10px; + line-height: 1.5; + margin-bottom: 14px; + max-height: 200px; + overflow-y: auto; + padding: 12px; +`; + +export const ExportButton = styled.button<{ $primary?: boolean }>` + align-items: center; + background: ${(props) => + props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'}; + border: none; + border-radius: 8px; + color: ${(props) => + props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'}; + cursor: pointer; + display: flex; + font-family: ${theme.font.family.sans}; + font-size: 11px; + font-weight: ${theme.font.weight.medium}; + gap: 8px; + justify-content: center; + line-height: normal; + margin-top: ${(props) => (props.$primary ? '0' : '8px')}; + padding: 7px 12px; + transition: + background-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; + width: 100%; + + &:hover { + background: ${(props) => + props.$primary + ? 'rgba(255, 255, 255, 0.28)' + : 'rgba(255, 255, 255, 0.24)'}; + color: rgba(255, 255, 255, 0.92); + } + + &:active { + background: ${(props) => + props.$primary + ? 'rgba(255, 255, 255, 0.3)' + : 'rgba(255, 255, 255, 0.28)'}; + transform: translateY(1px); + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.36); + outline-offset: 2px; + } + + &:disabled { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.36); + cursor: not-allowed; + transform: none; + } +`; + +export const ExportNote = styled.div` + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + line-height: 1.5; + margin-top: 12px; +`; + +export const SmallBody = styled.p` + color: rgba(255, 255, 255, 0.45); + font-size: 10px; + line-height: 1.6; + margin-bottom: 14px; +`; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx new file mode 100644 index 0000000000..ea6b024d69 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx @@ -0,0 +1,64 @@ +import { styled } from '@linaria/react'; +import { type ChangeEventHandler, type ReactNode, useId } from 'react'; + +import { Toggle, ToggleInput, ToggleTrack } from './ToggleControl'; + +export const Section = styled.section<{ $first?: boolean }>` + border-top: ${(props) => + props.$first ? 'none' : '1px solid rgba(255, 255, 255, 0.06)'}; + margin-top: ${(props) => (props.$first ? '0' : '14px')}; + padding-top: ${(props) => (props.$first ? '0' : '14px')}; +`; + +export const SectionTitle = styled.div<{ $preserveCase?: boolean }>` + color: rgba(255, 255, 255, 0.36); + font-size: 10px; + font-weight: 600; + letter-spacing: ${(props) => (props.$preserveCase ? '0.02em' : '0.08em')}; + margin-bottom: 10px; + text-transform: ${(props) => (props.$preserveCase ? 'none' : 'uppercase')}; +`; + +export const SectionHeader = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 10px; + + & > ${SectionTitle} { + margin-bottom: 0; + } +`; + +type SectionToggleHeaderProps = { + checked: boolean; + children: ReactNode; + onChange: ChangeEventHandler; + preserveCase?: boolean; +}; + +export function SectionToggleHeader({ + checked, + children, + onChange, + preserveCase, +}: SectionToggleHeaderProps) { + const titleId = useId(); + + return ( + + + {children} + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx new file mode 100644 index 0000000000..06eedfbc88 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx @@ -0,0 +1,50 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +export const TabsBar = styled.div<{ $collapsed?: boolean }>` + align-items: center; + display: flex; + flex-shrink: 0; + gap: 6px; + margin: 0; + padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')}; +`; + +export const TabButton = styled.button<{ $active: boolean }>` + background: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.08)' : 'transparent'}; + border: none; + border-radius: 7px; + color: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.52)'}; + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 12px; + font-weight: ${(props) => (props.$active ? 600 : 500)}; + letter-spacing: 0; + line-height: 1; + padding: 8px 10px; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'}; + color: ${(props) => + props.$active + ? 'rgba(255, 255, 255, 0.94)' + : 'rgba(255, 255, 255, 0.74)'}; + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; + +export const TabContent = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; +`; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx index cd11a060ec..d4cc8ad83e 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx @@ -1,9 +1,6 @@ 'use client'; -import { - formatAngle, - formatDecimal, -} from '@/app/[locale]/halftone/_lib/formatters'; +import { formatAngle, formatDecimal } from '@/lib/halftone/utils/formatters'; import { DEFAULT_GLASS_ANIMATION_SETTINGS, DEFAULT_GLASS_LIGHTING_SETTINGS, @@ -14,7 +11,7 @@ import { type HalftoneBackgroundSettings, type HalftoneSourceMode, type HalftoneStudioSettings, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; import { styled } from '@linaria/react'; import { ColorControlLabel, diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx index d35b66ffa8..26dcf2fef5 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx @@ -1,12 +1,12 @@ 'use client'; -import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters'; -import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames'; -import { formatAnimationName } from '@/app/[locale]/halftone/_lib/formatters'; +import type { ReactExportSettings } from '@/lib/halftone/utils/exporters'; +import { resolveExportArtifactNames } from '@/lib/halftone/utils/export-names'; +import { formatAnimationName } from '@/lib/halftone/utils/formatters'; import type { HalftoneGeometrySpec, HalftoneStudioSettings, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; import { useState } from 'react'; import { ExportButton, diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx new file mode 100644 index 0000000000..d673f066f0 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +import { TooltipPositionEffect } from './effect-components/TooltipPositionEffect'; + +const LabelWithInfoRow = styled.span` + align-items: center; + display: inline-flex; + gap: 6px; + min-width: 0; +`; + +const InfoTooltipRoot = styled.span` + display: inline-flex; + position: relative; +`; + +const InfoTooltipButton = styled.button` + align-items: center; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.32); + cursor: help; + display: inline-flex; + height: 14px; + justify-content: center; + margin: 0; + padding: 0; + transition: color 0.15s ease; + width: 14px; + + &:hover, + &:focus-visible { + color: rgba(255, 255, 255, 0.7); + outline: none; + } + + &:hover + span, + &:focus-visible + span { + opacity: 1; + transform: translate(-50%, 0); + } +`; + +const InfoTooltipBubble = styled.span` + background: rgba(9, 9, 13, 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); + color: rgba(255, 255, 255, 0.78); + font-size: 10px; + line-height: 1.45; + max-width: 220px; + padding: 8px 10px; + pointer-events: none; + position: fixed; + width: max-content; + z-index: 4; + + &::before { + background: rgba(9, 9, 13, 0.96); + border-left: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.08); + content: ''; + height: 8px; + left: 50%; + position: absolute; + top: -5px; + transform: translateX(-50%) rotate(45deg); + width: 8px; + } +`; + +type LabelWithTooltipProps = { + description: string; + label: ReactNode; +}; + +export function LabelWithTooltip({ + description, + label, +}: LabelWithTooltipProps) { + const buttonReference = useRef(null); + const tooltipId = useId(); + const [isMounted, setIsMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ + left: 0, + top: 0, + }); + + useEffect(() => { + setIsMounted(true); + }, []); + + return ( + + + {label} + + setIsOpen(false)} + onFocus={() => setIsOpen(true)} + onMouseEnter={() => setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + ref={buttonReference} + type="button" + > + + + {isMounted && isOpen + ? createPortal( + + {description} + , + document.body, + ) + : null} + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx new file mode 100644 index 0000000000..8e54cd9740 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useRef, type ReactNode } from 'react'; + +import { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export const SegmentedLabel = styled.div` + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); +`; + +const SegmentedGroup = styled.div` + align-items: stretch; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + display: grid; + gap: 1px; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + height: 24px; + padding: 1px; + width: 100%; +`; + +const SegmentedButton = styled.button<{ $active: boolean }>` + background: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'}; + border: none; + border-radius: 6px; + color: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'}; + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 11px; + font-weight: ${(props) => (props.$active ? 600 : 500)}; + height: 100%; + padding: 0 10px; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + color: rgba(255, 255, 255, 0.86); + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; + +type SegmentedControlProps = { + children: ReactNode; + onChange: (value: string) => void; + value: string; + options: Array<{ label: string; value: string }>; +}; + +export function SegmentedControl({ + children, + onChange, + options, + value, +}: SegmentedControlProps) { + const groupReference = useRef(null); + + const handleKeyDown = (event: React.KeyboardEvent, currentIndex: number) => { + let nextIndex: number | null = null; + + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + nextIndex = (currentIndex + 1) % options.length; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + nextIndex = (currentIndex - 1 + options.length) % options.length; + } + + if (nextIndex === null) { + return; + } + + event.preventDefault(); + onChange(options[nextIndex].value); + + const buttons = + groupReference.current?.querySelectorAll( + '[role="radio"]', + ); + buttons?.[nextIndex]?.focus(); + }; + + return ( + + {children} + + {options.map((option, index) => { + const isActive = option.value === value; + + return ( + { + if (!isActive) { + onChange(option.value); + } + }} + onKeyDown={(event) => handleKeyDown(event, index)} + role="radio" + tabIndex={isActive ? 0 : -1} + type="button" + > + {option.label} + + ); + })} + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx new file mode 100644 index 0000000000..f0ae5eb101 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import type { ChangeEventHandler, ReactNode } from 'react'; + +import { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export const SelectLabel = styled.label` + align-items: center; + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); +`; + +export const SelectInput = styled.select` + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: rgba(255, 255, 255, 0.07); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.52)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-position: right 12px center; + background-repeat: no-repeat; + background-size: 12px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 11px; + height: 24px; + outline: none; + padding: 0 34px 0 10px; + transition: border-color 0.15s ease; + width: 100%; + + &:hover { + border-color: rgba(255, 255, 255, 0.25); + } + + &:focus-visible { + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15); + } +`; + +type SelectControlProps = { + children: ReactNode; + onChange: ChangeEventHandler; + value: string; + options: Array<{ label: string; value: string }>; +}; + +export function SelectControl({ + children, + onChange, + options, + value, +}: SelectControlProps) { + return ( + + {children} + + {options.map((option) => ( + + ))} + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx new file mode 100644 index 0000000000..4e8c2052b0 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { + useEffect, + useRef, + useState, + type ChangeEventHandler, + type ReactNode, +} from 'react'; + +import { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export const SliderLabel = styled.label` + align-items: center; + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; +`; + +export const ControlValue = styled.span` + color: rgba(255, 255, 255, 0.5); + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + text-align: right; +`; + +export const SliderInput = styled.input<{ $fillPercent: number }>` + appearance: none; + background: transparent; + cursor: pointer; + height: 16px; + outline: none; + transition: transform 0.2s ease; + width: 100%; + -webkit-tap-highlight-color: transparent; + + &::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + rgba(255, 255, 255, 0.35) ${({ $fillPercent }) => $fillPercent}%, + rgba(255, 255, 255, 0.08) ${({ $fillPercent }) => $fillPercent}% + ); + border-radius: 999px; + height: 6px; + } + + &::-webkit-slider-thumb { + appearance: none; + background: transparent; + border: none; + border-radius: 999px; + height: 16px; + margin-top: -5px; + width: 16px; + } + + &::-moz-range-progress { + background: rgba(255, 255, 255, 0.35); + border-radius: 999px; + height: 6px; + } + + &::-moz-range-thumb { + background: transparent; + border: none; + border-radius: 999px; + height: 16px; + width: 16px; + } + + &::-moz-range-track { + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + height: 6px; + } + + &:active { + transform: scaleY(2); + } +`; + +const EditableControlValueButton = styled.button` + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: text; + font-family: inherit; + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + padding: 0; + text-align: right; + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.25); + outline-offset: 2px; + } +`; + +const EditableControlValueInput = styled.input` + appearance: textfield; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.62); + font-family: inherit; + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + outline: none; + padding: 0; + text-align: right; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } +`; + +function getStepPrecision(step: number | undefined) { + if (!step || Number.isInteger(step)) { + return 0; + } + + const stepString = step.toString().toLowerCase(); + + if (stepString.includes('e-')) { + const exponent = stepString.split('e-')[1]; + + return exponent ? Number.parseInt(exponent, 10) : 0; + } + + return stepString.split('.')[1]?.length ?? 0; +} + +function formatEditableValue(value: number, step: number | undefined) { + const precision = getStepPrecision(step); + + if (precision === 0) { + return String(Math.round(value)); + } + + return String(Number(value.toFixed(precision))); +} + +function clampAndSnapValue( + value: number, + min: number, + max: number, + step: number | undefined, +) { + const clampedValue = Math.min(Math.max(value, min), max); + + if (!step || step <= 0) { + return clampedValue; + } + + const precision = getStepPrecision(step); + const snappedValue = Math.round((clampedValue - min) / step) * step + min; + + return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision)); +} + +type SliderControlProps = { + children: ReactNode; + max: number; + min: number; + onChange: ChangeEventHandler; + step?: number; + value: number; + valueLabel: string; +}; + +export function SliderControl({ + children, + max, + min, + onChange, + step, + value, + valueLabel, +}: SliderControlProps) { + const [draftValue, setDraftValue] = useState(null); + const valueInputReference = useRef(null); + const fillPercent = ((value - min) / (max - min)) * 100; + const isEditing = draftValue !== null; + + useEffect(() => { + if (!isEditing) { + return; + } + + valueInputReference.current?.focus(); + valueInputReference.current?.select(); + }, [isEditing]); + + const commitDraftValue = () => { + if (draftValue === null) { + return; + } + + const trimmed = draftValue.trim(); + setDraftValue(null); + + if (trimmed === '') { + return; + } + + const nextValue = Number(trimmed); + + if (!Number.isFinite(nextValue)) { + return; + } + + const normalizedValue = clampAndSnapValue(nextValue, min, max, step); + + onChange({ + target: { value: String(normalizedValue) }, + } as React.ChangeEvent); + }; + + return ( + + {children} + + {isEditing ? ( + setDraftValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitDraftValue(); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + setDraftValue(null); + } + }} + ref={valueInputReference} + step={step} + type="number" + value={draftValue} + /> + ) : ( + { + event.preventDefault(); + event.stopPropagation(); + setDraftValue(formatEditableValue(value, step)); + }} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + type="button" + > + {valueLabel} + + )} + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx new file mode 100644 index 0000000000..aa1ad13d5c --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { styled } from '@linaria/react'; +import type { ChangeEventHandler, ReactNode } from 'react'; + +export const ToggleRow = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + padding: 2px 0; +`; + +export const ToggleText = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 11px; +`; + +export const Toggle = styled.label` + display: block; + flex-shrink: 0; + height: 18px; + position: relative; + width: 28px; +`; + +export const ToggleInput = styled.input` + height: 0; + opacity: 0; + position: absolute; + width: 0; + + &:checked + span { + background: #4a38f5; + } + + &:checked + span::after { + transform: translateX(12px); + } +`; + +export const ToggleTrack = styled.span` + background: rgba(255, 255, 255, 0.14); + border-radius: 999px; + cursor: pointer; + inset: 0; + position: absolute; + transition: background 0.2s ease; + + &::after { + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + content: ''; + height: 12px; + left: 2px; + position: absolute; + top: 3px; + transition: transform 0.2s ease; + width: 12px; + } +`; + +type ToggleControlProps = { + checked: boolean; + label: ReactNode; + onChange: ChangeEventHandler; +}; + +export function ToggleControl({ + checked, + label, + onChange, +}: ToggleControlProps) { + return ( + + {label} + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts new file mode 100644 index 0000000000..f0c3e62512 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts @@ -0,0 +1 @@ +export const TAB_LABEL_WIDTH = 72; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts new file mode 100644 index 0000000000..a179441244 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts @@ -0,0 +1,56 @@ +export { TAB_LABEL_WIDTH } from './controls-form-constants'; + +export { + ColorControlLabel, + ColorControlRow, + ColorField, + ColorHexInput, + ColorInput, + ColorPickerField, + ColorSwatch, + ColorSwatchButton, +} from './ColorField'; + +export { LabelWithTooltip } from './LabelWithTooltip'; + +export { + ControlGrid, + ExportButton, + ExportNameInput, + ExportNote, + ExportPreview, + PanelShell, + ShapeRow, + SmallBody, + UploadButton, + ValueDisplay, +} from './ControlsPanelShell'; + +export { SegmentedControl, SegmentedLabel } from './SegmentedControl'; + +export { SelectControl, SelectInput, SelectLabel } from './SelectControl'; + +export { + ControlValue, + SliderControl, + SliderInput, + SliderLabel, +} from './SliderControl'; + +export { TabButton, TabContent, TabsBar } from './ControlsTabs'; + +export { + Toggle, + ToggleControl, + ToggleInput, + ToggleRow, + ToggleText, + ToggleTrack, +} from './ToggleControl'; + +export { + Section, + SectionHeader, + SectionTitle, + SectionToggleHeader, +} from './ControlsSections'; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx deleted file mode 100644 index ed947b2158..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx +++ /dev/null @@ -1,1120 +0,0 @@ -'use client'; - -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { - useEffect, - useId, - useRef, - useState, - type ChangeEventHandler, - type ReactNode, -} from 'react'; -import { createPortal } from 'react-dom'; - -const TAB_LABEL_WIDTH = 72; - -export const PanelShell = styled.aside<{ $collapsed?: boolean }>` - background: rgba(18, 18, 22, 0.88); - /* -webkit- prefix is required for the blur to render on Safari < 18. */ - -webkit-backdrop-filter: blur(24px) saturate(1.4); - backdrop-filter: blur(24px) saturate(1.4); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 16px; - box-shadow: - 0 24px 64px rgba(0, 0, 0, 0.4), - 0 0 0 1px rgba(255, 255, 255, 0.04) inset; - color: rgba(255, 255, 255, 0.7); - display: flex; - flex-direction: column; - font-family: ${theme.font.family.sans}; - font-size: 11px; - height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; - overflow: hidden; - width: ${(props) => - props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'}; - - @media (max-width: ${theme.breakpoints.md - 1}px) { - height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; - width: ${(props) => (props.$collapsed ? 'auto' : '100%')}; - } -`; - -export const TabsBar = styled.div<{ $collapsed?: boolean }>` - align-items: center; - display: flex; - flex-shrink: 0; - gap: 6px; - margin: 0; - padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')}; -`; - -export const TabButton = styled.button<{ $active: boolean }>` - background: ${(props) => - props.$active ? 'rgba(255, 255, 255, 0.08)' : 'transparent'}; - border: none; - border-radius: 7px; - color: ${(props) => - props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.52)'}; - cursor: pointer; - font-family: ${theme.font.family.sans}; - font-size: 12px; - font-weight: ${(props) => (props.$active ? 600 : 500)}; - letter-spacing: 0; - line-height: 1; - padding: 8px 10px; - transition: - background-color 0.15s ease, - color 0.15s ease; - - &:hover { - background: ${(props) => - props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'}; - color: ${(props) => - props.$active - ? 'rgba(255, 255, 255, 0.94)' - : 'rgba(255, 255, 255, 0.74)'}; - } - - &:focus-visible { - outline: 1px solid rgba(255, 255, 255, 0.35); - outline-offset: 1px; - } -`; - -export const TabContent = styled.div` - flex: 1; - overflow-y: auto; - padding: 16px; -`; - -export const Section = styled.section<{ $first?: boolean }>` - border-top: ${(props) => - props.$first ? 'none' : '1px solid rgba(255, 255, 255, 0.06)'}; - margin-top: ${(props) => (props.$first ? '0' : '14px')}; - padding-top: ${(props) => (props.$first ? '0' : '14px')}; -`; - -export const SectionTitle = styled.div<{ $preserveCase?: boolean }>` - color: rgba(255, 255, 255, 0.36); - font-size: 10px; - font-weight: 600; - letter-spacing: ${(props) => (props.$preserveCase ? '0.02em' : '0.08em')}; - margin-bottom: 10px; - text-transform: ${(props) => (props.$preserveCase ? 'none' : 'uppercase')}; -`; - -export const SectionHeader = styled.div` - align-items: center; - display: flex; - justify-content: space-between; - margin-bottom: 10px; - - & > ${SectionTitle} { - margin-bottom: 0; - } -`; - -export const ControlGrid = styled.div` - display: grid; - gap: 10px; -`; - -export const SliderLabel = styled.label` - align-items: center; - cursor: pointer; - display: grid; - gap: 10px; - grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; -`; - -export const SelectLabel = styled.label` - align-items: center; - cursor: pointer; - display: grid; - gap: 10px; - grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); -`; - -export const SegmentedLabel = styled.div` - align-items: center; - display: grid; - gap: 10px; - grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); -`; - -export const ControlValue = styled.span` - color: rgba(255, 255, 255, 0.5); - font-size: 10px; - font-variant-numeric: tabular-nums; - min-width: 34px; - text-align: right; -`; - -const SegmentedGroup = styled.div` - align-items: stretch; - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - display: grid; - gap: 1px; - grid-auto-columns: minmax(0, 1fr); - grid-auto-flow: column; - height: 24px; - padding: 1px; - width: 100%; -`; - -const SegmentedButton = styled.button<{ $active: boolean }>` - background: ${(props) => - props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'}; - border: none; - border-radius: 6px; - color: ${(props) => - props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'}; - cursor: pointer; - font-family: ${theme.font.family.sans}; - font-size: 11px; - font-weight: ${(props) => (props.$active ? 600 : 500)}; - height: 100%; - padding: 0 10px; - transition: - background-color 0.15s ease, - color 0.15s ease; - - &:hover { - color: rgba(255, 255, 255, 0.86); - } - - &:focus-visible { - outline: 1px solid rgba(255, 255, 255, 0.35); - outline-offset: 1px; - } -`; - -const EditableControlValueButton = styled.button` - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.5); - cursor: text; - font-family: inherit; - font-size: 10px; - font-variant-numeric: tabular-nums; - min-width: 34px; - padding: 0; - text-align: right; - - &:focus-visible { - outline: 1px solid rgba(255, 255, 255, 0.25); - outline-offset: 2px; - } -`; - -const EditableControlValueInput = styled.input` - appearance: textfield; - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.62); - font-family: inherit; - font-size: 10px; - font-variant-numeric: tabular-nums; - min-width: 34px; - outline: none; - padding: 0; - text-align: right; - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - margin: 0; - } -`; - -export const SliderInput = styled.input<{ $fillPercent: number }>` - appearance: none; - background: transparent; - cursor: pointer; - height: 16px; - outline: none; - transition: transform 0.2s ease; - width: 100%; - -webkit-tap-highlight-color: transparent; - - &::-webkit-slider-runnable-track { - background: linear-gradient( - to right, - rgba(255, 255, 255, 0.35) ${({ $fillPercent }) => $fillPercent}%, - rgba(255, 255, 255, 0.08) ${({ $fillPercent }) => $fillPercent}% - ); - border-radius: 999px; - height: 6px; - } - - &::-webkit-slider-thumb { - appearance: none; - background: transparent; - border: none; - border-radius: 999px; - height: 16px; - margin-top: -5px; - width: 16px; - } - - &::-moz-range-progress { - background: rgba(255, 255, 255, 0.35); - border-radius: 999px; - height: 6px; - } - - &::-moz-range-thumb { - background: transparent; - border: none; - border-radius: 999px; - height: 16px; - width: 16px; - } - - &::-moz-range-track { - background: rgba(255, 255, 255, 0.08); - border-radius: 999px; - height: 6px; - } - - &:active { - transform: scaleY(2); - } -`; - -export const SelectInput = styled.select` - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: rgba(255, 255, 255, 0.07); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.52)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); - background-position: right 12px center; - background-repeat: no-repeat; - background-size: 12px 12px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: rgba(255, 255, 255, 0.85); - cursor: pointer; - font-family: ${theme.font.family.sans}; - font-size: 11px; - height: 24px; - outline: none; - padding: 0 34px 0 10px; - transition: border-color 0.15s ease; - width: 100%; - - &:hover { - border-color: rgba(255, 255, 255, 0.25); - } -`; - -export const ValueDisplay = styled.div` - align-items: center; - background-color: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: rgba(255, 255, 255, 0.85); - display: flex; - font-family: ${theme.font.family.sans}; - font-size: 11px; - min-height: 31px; - overflow: hidden; - padding: 7px 10px; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -`; - -export const ToggleRow = styled.div` - align-items: center; - display: flex; - justify-content: space-between; - padding: 2px 0; -`; - -export const ToggleText = styled.span` - color: rgba(255, 255, 255, 0.7); - font-size: 11px; -`; - -const LabelWithInfoRow = styled.span` - align-items: center; - display: inline-flex; - gap: 6px; - min-width: 0; -`; - -const InfoTooltipRoot = styled.span` - display: inline-flex; - position: relative; -`; - -const InfoTooltipButton = styled.button` - align-items: center; - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.32); - cursor: help; - display: inline-flex; - height: 14px; - justify-content: center; - margin: 0; - padding: 0; - transition: color 0.15s ease; - width: 14px; - - &:hover, - &:focus-visible { - color: rgba(255, 255, 255, 0.7); - outline: none; - } - - &:hover + span, - &:focus-visible + span { - opacity: 1; - transform: translate(-50%, 0); - } -`; - -const InfoTooltipBubble = styled.span` - background: rgba(9, 9, 13, 0.96); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); - color: rgba(255, 255, 255, 0.78); - font-size: 10px; - line-height: 1.45; - max-width: 220px; - padding: 8px 10px; - pointer-events: none; - position: fixed; - width: max-content; - z-index: 4; - - &::before { - background: rgba(9, 9, 13, 0.96); - border-left: 1px solid rgba(255, 255, 255, 0.08); - border-top: 1px solid rgba(255, 255, 255, 0.08); - content: ''; - height: 8px; - left: 50%; - position: absolute; - top: -5px; - transform: translateX(-50%) rotate(45deg); - width: 8px; - } -`; - -export const Toggle = styled.label` - display: block; - flex-shrink: 0; - height: 18px; - position: relative; - width: 28px; -`; - -export const ToggleInput = styled.input` - height: 0; - opacity: 0; - position: absolute; - width: 0; - - &:checked + span { - background: #4a38f5; - } - - &:checked + span::after { - transform: translateX(12px); - } -`; - -export const ToggleTrack = styled.span` - background: rgba(255, 255, 255, 0.14); - border-radius: 999px; - cursor: pointer; - inset: 0; - position: absolute; - transition: background 0.2s ease; - - &::after { - background: #fff; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); - content: ''; - height: 12px; - left: 2px; - position: absolute; - top: 3px; - transition: transform 0.2s ease; - width: 12px; - } -`; - -export const ColorControlRow = styled.div` - align-items: center; - display: grid; - gap: 10px; - grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); -`; - -export const ColorControlLabel = styled.span` - color: rgba(255, 255, 255, 0.7); - font-size: 11px; -`; - -export const ColorPickerField = styled.div` - align-items: center; - cursor: text; - display: flex; - gap: 8px; - justify-content: flex-end; - justify-self: end; - min-height: 16px; - min-width: fit-content; - padding: 0; - position: relative; - width: auto; -`; - -export const ColorSwatch = styled.span` - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 4px; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); - flex-shrink: 0; - height: 16px; - width: 16px; -`; - -export const ColorHexInput = styled.input` - appearance: none; - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.85); - font-family: ${theme.font.family.mono}; - font-size: 11px; - height: 16px; - letter-spacing: 0.02em; - line-height: 16px; - min-width: 7ch; - outline: none; - padding: 0; - text-align: right; - text-transform: uppercase; - width: 7ch; - - &::placeholder { - color: rgba(255, 255, 255, 0.32); - } -`; - -export const ColorSwatchButton = styled.label` - align-items: center; - cursor: pointer; - display: inline-flex; - flex-shrink: 0; - height: 16px; - justify-content: center; - position: relative; -`; - -export const ColorInput = styled.input` - appearance: none; - border: none; - cursor: pointer; - height: 100%; - inset: 0; - outline: none; - opacity: 0; - padding: 0; - position: absolute; - width: 100%; -`; - -function normalizeHexColor(value: string): string | null { - const normalizedValue = value.trim().replace(/^#/, ''); - - if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue)) { - return null; - } - - return `#${normalizedValue.toUpperCase()}`; -} - -type ColorFieldProps = { - ariaLabel: string; - onChange: (value: string) => void; - value: string; -}; - -export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) { - const normalizedValue = value.toUpperCase(); - const [draftValue, setDraftValue] = useState(normalizedValue); - - useEffect(() => { - setDraftValue(normalizedValue); - }, [normalizedValue]); - - const commitDraftValue = () => { - const nextValue = normalizeHexColor(draftValue); - - if (!nextValue) { - setDraftValue(normalizedValue); - return; - } - - setDraftValue(nextValue); - - if (nextValue !== normalizedValue) { - onChange(nextValue); - } - }; - - return ( - - event.currentTarget.select()} - onChange={(event) => setDraftValue(event.target.value.toUpperCase())} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.currentTarget.blur(); - } - - if (event.key === 'Escape') { - event.preventDefault(); - setDraftValue(normalizedValue); - event.currentTarget.blur(); - } - }} - spellCheck={false} - type="text" - value={draftValue} - /> - - - onChange(event.target.value)} - type="color" - value={value} - /> - - - ); -} - -export const ShapeRow = styled.div` - align-items: center; - display: grid; - gap: 10px; - grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; -`; - -export const UploadButton = styled.button` - align-items: center; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - cursor: pointer; - display: flex; - flex-shrink: 0; - font-size: 13px; - height: 24px; - justify-content: center; - transition: all 0.15s ease; - width: 24px; - - &:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.22); - color: rgba(255, 255, 255, 0.8); - } -`; - -export const ExportNameInput = styled.input` - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: rgba(255, 255, 255, 0.85); - font-family: ${theme.font.family.sans}; - font-size: 11px; - margin-bottom: 14px; - outline: none; - padding: 7px 10px; - transition: border-color 0.15s ease; - width: 100%; - - &:hover { - border-color: rgba(255, 255, 255, 0.25); - } - - &:focus { - border-color: #4a38f5; - } - - &::placeholder { - color: rgba(255, 255, 255, 0.3); - } -`; - -export const ExportPreview = styled.div` - background: rgba(0, 0, 0, 0.4); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - color: rgba(255, 255, 255, 0.55); - font-family: ${theme.font.family.mono}; - font-size: 10px; - line-height: 1.5; - margin-bottom: 14px; - max-height: 200px; - overflow-y: auto; - padding: 12px; -`; - -export const ExportButton = styled.button<{ $primary?: boolean }>` - align-items: center; - background: ${(props) => - props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'}; - border: none; - border-radius: 8px; - color: ${(props) => - props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'}; - cursor: pointer; - display: flex; - font-family: ${theme.font.family.sans}; - font-size: 11px; - font-weight: ${theme.font.weight.medium}; - gap: 8px; - justify-content: center; - line-height: normal; - margin-top: ${(props) => (props.$primary ? '0' : '8px')}; - padding: 7px 12px; - transition: - background-color 0.15s ease, - color 0.15s ease, - transform 0.15s ease; - width: 100%; - - &:hover { - background: ${(props) => - props.$primary - ? 'rgba(255, 255, 255, 0.28)' - : 'rgba(255, 255, 255, 0.24)'}; - color: rgba(255, 255, 255, 0.92); - } - - &:active { - background: ${(props) => - props.$primary - ? 'rgba(255, 255, 255, 0.3)' - : 'rgba(255, 255, 255, 0.28)'}; - transform: translateY(1px); - } - - &:focus-visible { - outline: 1px solid rgba(255, 255, 255, 0.36); - outline-offset: 2px; - } - - &:disabled { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.36); - cursor: not-allowed; - transform: none; - } -`; - -export const ExportNote = styled.div` - color: rgba(255, 255, 255, 0.3); - font-size: 10px; - line-height: 1.5; - margin-top: 12px; -`; - -export const SmallBody = styled.p` - color: rgba(255, 255, 255, 0.45); - font-size: 10px; - line-height: 1.6; - margin-bottom: 14px; -`; - -type SliderControlProps = { - children: ReactNode; - max: number; - min: number; - onChange: ChangeEventHandler; - step?: number; - value: number; - valueLabel: string; -}; - -function getStepPrecision(step: number | undefined) { - if (!step || Number.isInteger(step)) { - return 0; - } - - const stepString = step.toString().toLowerCase(); - - if (stepString.includes('e-')) { - const exponent = stepString.split('e-')[1]; - - return exponent ? Number.parseInt(exponent, 10) : 0; - } - - return stepString.split('.')[1]?.length ?? 0; -} - -function formatEditableValue(value: number, step: number | undefined) { - const precision = getStepPrecision(step); - - if (precision === 0) { - return String(Math.round(value)); - } - - return String(Number(value.toFixed(precision))); -} - -function clampAndSnapValue( - value: number, - min: number, - max: number, - step: number | undefined, -) { - const clampedValue = Math.min(Math.max(value, min), max); - - if (!step || step <= 0) { - return clampedValue; - } - - const precision = getStepPrecision(step); - const snappedValue = Math.round((clampedValue - min) / step) * step + min; - - return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision)); -} - -export function SliderControl({ - children, - max, - min, - onChange, - step, - value, - valueLabel, -}: SliderControlProps) { - const [draftValue, setDraftValue] = useState(null); - const valueInputReference = useRef(null); - const fillPercent = ((value - min) / (max - min)) * 100; - const isEditing = draftValue !== null; - - useEffect(() => { - if (!isEditing) { - return; - } - - valueInputReference.current?.focus(); - valueInputReference.current?.select(); - }, [isEditing]); - - const commitDraftValue = () => { - if (draftValue === null) { - return; - } - - const nextValue = Number.parseFloat(draftValue); - setDraftValue(null); - - if (!Number.isFinite(nextValue)) { - return; - } - - const normalizedValue = clampAndSnapValue(nextValue, min, max, step); - - onChange({ - target: { value: String(normalizedValue) }, - } as React.ChangeEvent); - }; - - return ( - - {children} - - {isEditing ? ( - setDraftValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - commitDraftValue(); - return; - } - - if (event.key === 'Escape') { - event.preventDefault(); - setDraftValue(null); - } - }} - ref={valueInputReference} - step={step} - type="number" - value={draftValue} - /> - ) : ( - { - event.preventDefault(); - event.stopPropagation(); - setDraftValue(formatEditableValue(value, step)); - }} - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - type="button" - > - {valueLabel} - - )} - - ); -} - -type SelectControlProps = { - children: ReactNode; - onChange: ChangeEventHandler; - value: string; - options: Array<{ label: string; value: string }>; -}; - -export function SelectControl({ - children, - onChange, - options, - value, -}: SelectControlProps) { - return ( - - {children} - - {options.map((option) => ( - - ))} - - - ); -} - -type SegmentedControlProps = { - children: ReactNode; - onChange: (value: string) => void; - value: string; - options: Array<{ label: string; value: string }>; -}; - -export function SegmentedControl({ - children, - onChange, - options, - value, -}: SegmentedControlProps) { - return ( - - {children} - - {options.map((option) => { - const isActive = option.value === value; - - return ( - { - if (!isActive) { - onChange(option.value); - } - }} - role="radio" - type="button" - > - {option.label} - - ); - })} - - - ); -} - -type ToggleControlProps = { - checked: boolean; - label: ReactNode; - onChange: ChangeEventHandler; -}; - -export function ToggleControl({ - checked, - label, - onChange, -}: ToggleControlProps) { - return ( - - {label} - - - - - - ); -} - -type LabelWithTooltipProps = { - description: string; - label: ReactNode; -}; - -export function LabelWithTooltip({ - description, - label, -}: LabelWithTooltipProps) { - const buttonReference = useRef(null); - const tooltipId = useId(); - const [isMounted, setIsMounted] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [tooltipPosition, setTooltipPosition] = useState({ - left: 0, - top: 0, - }); - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - if (!isOpen) { - return; - } - - const updateTooltipPosition = () => { - const button = buttonReference.current; - - if (!button) { - return; - } - - const rect = button.getBoundingClientRect(); - setTooltipPosition({ - left: rect.left + rect.width / 2, - top: rect.bottom + 12, - }); - }; - - updateTooltipPosition(); - - window.addEventListener('resize', updateTooltipPosition); - window.addEventListener('scroll', updateTooltipPosition, true); - - return () => { - window.removeEventListener('resize', updateTooltipPosition); - window.removeEventListener('scroll', updateTooltipPosition, true); - }; - }, [isOpen]); - - return ( - - {label} - - setIsOpen(false)} - onFocus={() => setIsOpen(true)} - onMouseEnter={() => setIsOpen(true)} - onMouseLeave={() => setIsOpen(false)} - ref={buttonReference} - type="button" - > - - - {isMounted && isOpen - ? createPortal( - - {description} - , - document.body, - ) - : null} - - - ); -} - -type SectionToggleHeaderProps = { - checked: boolean; - children: ReactNode; - onChange: ChangeEventHandler; - preserveCase?: boolean; -}; - -export function SectionToggleHeader({ - checked, - children, - onChange, - preserveCase, -}: SectionToggleHeaderProps) { - return ( - - {children} - - - - - - ); -} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx new file mode 100644 index 0000000000..0fcc05263d --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect, type RefObject } from 'react'; + +type TooltipPositionEffectProps = { + buttonRef: RefObject; + isOpen: boolean; + setTooltipPosition: (position: { left: number; top: number }) => void; +}; + +export function TooltipPositionEffect({ + buttonRef, + isOpen, + setTooltipPosition, +}: TooltipPositionEffectProps) { + useEffect(() => { + if (!isOpen) { + return; + } + + const updateTooltipPosition = () => { + const button = buttonRef.current; + + if (!button) { + return; + } + + const rect = button.getBoundingClientRect(); + setTooltipPosition({ + left: rect.left + rect.width / 2, + top: rect.bottom + 12, + }); + }; + + updateTooltipPosition(); + + window.addEventListener('resize', updateTooltipPosition); + window.addEventListener('scroll', updateTooltipPosition, true); + + return () => { + window.removeEventListener('resize', updateTooltipPosition); + window.removeEventListener('scroll', updateTooltipPosition, true); + }; + }, [isOpen, buttonRef, setTooltipPosition]); + + return null; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx new file mode 100644 index 0000000000..7aa8da8b42 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useEffect } from 'react'; + +function isEditablePasteTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.closest('[contenteditable=""], [contenteditable="true"]') !== null + ); +} + +function hasTextSelection() { + const selection = window.getSelection(); + + return selection !== null && selection.toString().trim().length > 0; +} + +type CopyHalftoneShortcutEffectProps = { + canvasLayerRef: React.RefObject; + onCopy: (width: number, height: number) => void; +}; + +export function CopyHalftoneShortcutEffect({ + canvasLayerRef, + onCopy, +}: CopyHalftoneShortcutEffectProps) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isCopyShortcut = + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey && + event.key.toLowerCase() === 'c'; + + if (!isCopyShortcut) { + return; + } + + if (isEditablePasteTarget(event.target) || hasTextSelection()) { + return; + } + + const canvasLayer = canvasLayerRef.current; + const width = Math.max(canvasLayer?.clientWidth ?? 0, 1); + const height = Math.max(canvasLayer?.clientHeight ?? 0, 1); + + event.preventDefault(); + onCopy(width, height); + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [canvasLayerRef, onCopy]); + + return null; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx new file mode 100644 index 0000000000..b8da6edb85 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect } from 'react'; + +const CLIPBOARD_IMAGE_EXTENSION_BY_MIME: Record = { + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/webp': '.webp', +}; + +function isEditablePasteTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.closest('[contenteditable=""], [contenteditable="true"]') !== null + ); +} + +function createPastedImageFile(file: File) { + const hasExtension = /\.[^.]+$/i.test(file.name); + + if (hasExtension) { + return file; + } + + const extension = + CLIPBOARD_IMAGE_EXTENSION_BY_MIME[file.type] ?? + CLIPBOARD_IMAGE_EXTENSION_BY_MIME['image/png']; + + return new File([file], `pasted-image-${Date.now()}${extension}`, { + type: file.type || 'image/png', + lastModified: Date.now(), + }); +} + +type PasteImageEffectProps = { + onPasteImage: (file: File) => void; + onPasteError: () => void; +}; + +export function PasteImageEffect({ + onPasteImage, + onPasteError, +}: PasteImageEffectProps) { + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + if (isEditablePasteTarget(event.target)) { + return; + } + + const imageItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.type.startsWith('image/'), + ); + + if (!imageItem) { + return; + } + + const pastedFile = imageItem.getAsFile(); + + if (!pastedFile) { + onPasteError(); + return; + } + + event.preventDefault(); + onPasteImage(createPastedImageFile(pastedFile)); + }; + + window.addEventListener('paste', handlePaste); + + return () => { + window.removeEventListener('paste', handlePaste); + }; + }, [onPasteImage, onPasteError]); + + return null; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts new file mode 100644 index 0000000000..cb82c6e90f --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts @@ -0,0 +1,124 @@ +import { LocalizedLink } from '@/lib/i18n'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +const DESKTOP_CONTROLS_PANEL_WIDTH = 320; +const DESKTOP_CONTROLS_PANEL_OFFSET = 20; +const DESKTOP_CONTROLS_PANEL_FOOTPRINT = + DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET; + +export const StudioShell = styled.div<{ $background: string }>` + background: ${(props) => props.$background}; + /* Full-screen tool: track the visible viewport so the bottom controls bar + * is reachable on mobile Safari (where 100vh extends behind the URL bar). */ + height: 100vh; + height: 100dvh; + overflow: hidden; + position: relative; + width: 100%; +`; + +export const ContentRegion = styled.div<{ $panelOpen: boolean }>` + height: 100%; + margin-right: auto; + position: relative; + transition: width 0.18s ease; + width: ${(props) => + props.$panelOpen + ? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)` + : '100%'}; + + @media (max-width: ${theme.breakpoints.md - 1}px) { + width: 100%; + } +`; + +export const CanvasLayer = styled.div` + height: 100%; + width: 100%; +`; + +export const LogoLink = styled(LocalizedLink)` + display: grid; + left: 24px; + position: absolute; + top: 24px; + z-index: 2; +`; + +export const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>` + color: ${(props) => + props.$tone === 'dark' + ? 'rgba(255, 255, 255, 0.34)' + : 'rgba(0, 0, 0, 0.3)'}; + font-family: ${theme.font.family.mono}; + font-size: 11px; + left: 50%; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + pointer-events: none; + position: absolute; + top: 20px; + transform: translateX(-50%); + transition: opacity 0.3s ease; + z-index: 1; +`; + +export const Status = styled.div<{ + $error: boolean; + $tone: 'dark' | 'light'; + $visible: boolean; +}>` + color: ${(props) => { + if (props.$error) { + return 'rgba(214, 66, 66, 0.92)'; + } + + return props.$tone === 'dark' + ? 'rgba(255, 255, 255, 0.62)' + : 'rgba(0, 0, 0, 0.55)'; + }}; + font-family: ${theme.font.family.mono}; + font-size: 11px; + left: 50%; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + pointer-events: none; + position: absolute; + top: 44px; + transform: translateX(-50%); + transition: opacity 0.2s ease; + z-index: 1; +`; + +export const ControlsPositioner = styled.div` + align-items: flex-start; + bottom: 20px; + display: flex; + height: calc(100vh - 40px); + height: calc(100dvh - 40px); + justify-content: flex-end; + pointer-events: none; + position: fixed; + right: 20px; + top: 20px; + z-index: 3; + + @media (max-width: ${theme.breakpoints.md - 1}px) { + bottom: 16px; + height: 48vh; + left: 16px; + right: 16px; + top: auto; + } +`; + +export const ControlsPanelFrame = styled.div<{ $visible: boolean }>` + height: ${(props) => (props.$visible ? '100%' : 'auto')}; + pointer-events: auto; + @media (max-width: ${theme.breakpoints.md - 1}px) { + width: ${(props) => (props.$visible ? '100%' : 'auto')}; + } +`; + +export const HiddenFileInput = styled.input` + display: none; +`; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts new file mode 100644 index 0000000000..ad6f426630 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; + +type UseDefaultHalftoneImageOptions = { + loadDefaultImageFile: () => Promise; + setImageFile: (updater: (currentFile: File | null) => File | null) => void; +}; + +export function useDefaultHalftoneImage({ + loadDefaultImageFile, + setImageFile, +}: UseDefaultHalftoneImageOptions) { + useEffect(() => { + void loadDefaultImageFile() + .then((file) => { + setImageFile((currentFile) => currentFile ?? file); + }) + .catch((error) => { + console.error(error); + }); + }, [loadDefaultImageFile, setImageFile]); +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts new file mode 100644 index 0000000000..436e434883 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts @@ -0,0 +1,97 @@ +import { + createFallbackGeometry, + disposeGeometryCache, + getGeometryForSpec, +} from '@/lib/halftone/utils/geometry-registry'; +import type { HalftoneGeometrySpec } from '@/lib/halftone/utils/state'; +import { useEffect, useRef, useState } from 'react'; +import type * as THREE from 'three'; + +type GeometryCacheEntry = THREE.BufferGeometry | Promise; + +type GeometryLoaderCallbacks = { + onLoadStart: (label: string) => void; + onLoadSuccess: () => void; + onLoadError: (message: string) => void; + onFallbackShape: (key: string) => void; +}; + +export function useGeometryLoader( + selectedShape: HalftoneGeometrySpec | undefined, + importedFiles: Record, + callbacks: GeometryLoaderCallbacks, +) { + const [activeGeometry, setActiveGeometry] = useState( + () => createFallbackGeometry(), + ); + const geometryCacheReference = useRef>( + new Map([['torusKnot', activeGeometry]]), + ); + const lastSuccessfulShapeReference = useRef( + selectedShape?.key ?? 'torusKnot', + ); + + useEffect(() => { + const geometryCache = geometryCacheReference.current; + + return () => { + disposeGeometryCache(geometryCache); + }; + }, []); + + useEffect(() => { + if (!selectedShape) { + return; + } + + let cancelled = false; + const geometryCache = geometryCacheReference.current; + + // Invalidate cached geometry when an imported file changes so we don't + // serve stale geometry from a previous file under the same key. + if (selectedShape.kind === 'imported') { + geometryCache.delete(selectedShape.key); + callbacks.onLoadStart(selectedShape.label); + } else { + callbacks.onLoadSuccess(); + } + + void getGeometryForSpec( + selectedShape, + importedFiles[selectedShape.key], + geometryCache, + ) + .then((geometry) => { + if (cancelled) { + return; + } + + lastSuccessfulShapeReference.current = selectedShape.key; + setActiveGeometry(geometry); + callbacks.onLoadSuccess(); + }) + .catch((error) => { + if (cancelled) { + return; + } + + console.error(error); + + callbacks.onLoadError( + error instanceof Error + ? error.message + : `${selectedShape.label} failed to load.`, + ); + + if (lastSuccessfulShapeReference.current !== selectedShape.key) { + callbacks.onFallbackShape(lastSuccessfulShapeReference.current); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedShape, importedFiles, callbacks]); + + return activeGeometry; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts new file mode 100644 index 0000000000..e39efaa1b5 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; + +export function useImageElement(imageFile: File | null) { + const [imageElement, setImageElement] = useState( + null, + ); + + useEffect(() => { + if (!imageFile) { + setImageElement(null); + return; + } + + let cancelled = false; + const url = URL.createObjectURL(imageFile); + const img = new Image(); + + img.onload = () => { + if (!cancelled) { + setImageElement(img); + } + }; + + img.onerror = () => { + if (!cancelled) { + setImageElement(null); + } + }; + + img.src = url; + + return () => { + cancelled = true; + img.onload = null; + img.onerror = null; + URL.revokeObjectURL(url); + }; + }, [imageFile]); + + return imageElement; +} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts deleted file mode 100644 index 1dd512a252..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts +++ /dev/null @@ -1,3467 +0,0 @@ -import { normalizeExportComponentName } from '@/app/[locale]/halftone/_lib/exportNames'; -import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/[locale]/halftone/_lib/glassEnvironmentData'; -import { - HALFTONE_FOOTPRINT_RUNTIME_SOURCE, - REFERENCE_PREVIEW_DISTANCE, - VIRTUAL_RENDER_HEIGHT, -} from '@/lib/halftone/footprint'; -import { - LEGACY_HALFTONE_SETTING_KEYS, - isRoundedBandHalftoneSettings, - type HalftoneExportPose, - type HalftoneGeometrySpec, - type HalftoneStudioSettings, -} from '@/lib/halftone/state'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; - -export type ReactExportSettings = { - includeNamedAndDefaultExport: boolean; - includePublicAssetUrl: boolean; - includeRegistryComment: boolean; - includeTsNoCheck: boolean; - includeUseClientDirective: boolean; - includeStyledMount: boolean; -}; - -export type ReactExportOptions = { - assetUrl?: string; - background?: string; - exportSettings?: Partial; - imageFilename?: string; - importedFile?: File; - initialPose?: HalftoneExportPose; - modelFilenameOverride?: string; - previewDistance?: number; -}; - -export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = { - includeNamedAndDefaultExport: true, - includePublicAssetUrl: true, - includeRegistryComment: true, - includeTsNoCheck: true, - includeUseClientDirective: true, - includeStyledMount: true, -}; - -const passThroughVertexShader = ` - varying vec2 vUv; - - void main() { - vUv = uv; - gl_Position = vec4(position, 1.0); - } -`; - -const blurFragmentShader = ` - precision highp float; - - uniform sampler2D tInput; - uniform vec2 dir; - uniform vec2 res; - - varying vec2 vUv; - - void main() { - vec4 sum = vec4(0.0); - vec2 px = dir / res; - - float w[5]; - w[0] = 0.227027; - w[1] = 0.1945946; - w[2] = 0.1216216; - w[3] = 0.054054; - w[4] = 0.016216; - - sum += texture2D(tInput, vUv) * w[0]; - - for (int i = 1; i < 5; i++) { - float fi = float(i) * 3.0; - sum += texture2D(tInput, vUv + px * fi) * w[i]; - sum += texture2D(tInput, vUv - px * fi) * w[i]; - } - - gl_FragColor = sum; - } -`; - -const imagePassthroughFragmentShader = ` - precision highp float; - - uniform sampler2D tImage; - uniform vec2 imageSize; - uniform vec2 viewportSize; - uniform float zoom; - uniform float contrast; - - varying vec2 vUv; - - void main() { - float imageAspect = imageSize.x / imageSize.y; - float viewAspect = viewportSize.x / viewportSize.y; - - vec2 uv = vUv; - - if (imageAspect > viewAspect) { - float scale = viewAspect / imageAspect; - uv.y = (uv.y - 0.5) / scale + 0.5; - } else { - float scale = imageAspect / viewAspect; - uv.x = (uv.x - 0.5) / scale + 0.5; - } - - uv = (uv - 0.5) / zoom + 0.5; - - float inBounds = step(0.0, uv.x) * step(uv.x, 1.0) - * step(0.0, uv.y) * step(uv.y, 1.0); - - vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0)); - vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0); - - gl_FragColor = vec4(contrastColor, inBounds); - } -`; - -const halftoneFragmentShader = ` - precision highp float; - - uniform sampler2D tScene; - uniform sampler2D tGlow; - uniform vec2 effectResolution; - uniform vec2 logicalResolution; - uniform float tile; - uniform float s_3; - uniform float s_4; - uniform float applyToDarkAreas; - uniform vec3 dashColor; - uniform vec3 hoverDashColor; - uniform float time; - uniform float waveAmount; - uniform float waveSpeed; - uniform float footprintScale; - uniform vec2 interactionUv; - uniform vec2 interactionVelocity; - uniform vec2 dragOffset; - uniform float hoverHalftoneActive; - uniform float hoverHalftonePowerShift; - uniform float hoverHalftoneRadius; - uniform float hoverHalftoneWidthShift; - uniform float hoverLightStrength; - uniform float hoverLightRadius; - uniform float hoverFlowStrength; - uniform float hoverFlowRadius; - uniform float dragFlowStrength; - uniform float cropToBounds; - - varying vec2 vUv; - - float distSegment(in vec2 p, in vec2 a, in vec2 b) { - vec2 pa = p - a; - vec2 ba = b - a; - float denom = max(dot(ba, ba), 0.000001); - float h = clamp(dot(pa, ba) / denom, 0.0, 1.0); - return length(pa - ba * h); - } - - float lineSimpleEt(in vec2 p, in float r, in float thickness) { - vec2 a = vec2(0.5) + vec2(-r, 0.0); - vec2 b = vec2(0.5) + vec2(r, 0.0); - float distToSegment = distSegment(p, a, b); - float halfThickness = thickness * r; - return distToSegment - halfThickness; - } - - void main() { - if (cropToBounds > 0.5) { - vec4 boundsCheck = texture2D(tScene, vUv); - if (boundsCheck.a < 0.01) { - gl_FragColor = vec4(0.0); - return; - } - } - - vec2 fragCoord = - (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution; - float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0); - vec2 pointerPx = interactionUv * logicalResolution; - vec2 fragDelta = fragCoord - pointerPx; - float fragDist = length(fragDelta); - vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); - float velocityMagnitude = length(interactionVelocity); - vec2 motionDir = velocityMagnitude > 0.001 - ? interactionVelocity / velocityMagnitude - : vec2(0.0, 0.0); - float motionBias = velocityMagnitude > 0.001 - ? dot(-radialDir, motionDir) * 0.5 + 0.5 - : 0.5; - - float hoverLightMask = 0.0; - if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * logicalResolution.y; - hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); - } - - float hoverHalftoneMask = 0.0; - if (hoverHalftoneActive > 0.0) { - float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y; - hoverHalftoneMask = - smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) * - clamp(hoverHalftoneActive, 0.0, 1.0); - } - - float hoverFlowMask = 0.0; - if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * logicalResolution.y; - hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); - } - - vec2 hoverDisplacement = - radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 + - motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15; - vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45; - vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement; - - float bandRow = floor(effectCoord.y / halftoneSize); - float waveOffset = - waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize; - effectCoord.x += waveOffset; - - vec2 cellIndex = floor(effectCoord / halftoneSize); - vec2 sampleUv = clamp( - (cellIndex + 0.5) * halftoneSize / logicalResolution, - vec2(0.0), - vec2(1.0) - ); - vec2 cellUv = fract(effectCoord / halftoneSize); - - vec4 sceneSample = texture2D(tScene, sampleUv); - float mask = smoothstep(0.02, 0.08, sceneSample.a); - float localPower = clamp( - s_3 + hoverHalftonePowerShift * hoverHalftoneMask, - -1.5, - 1.5 - ); - float localWidth = clamp( - s_4 + hoverHalftoneWidthShift * hoverHalftoneMask, - 0.05, - 1.4 - ); - float lightLift = - hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22; - float toneValue = - (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0); - if (applyToDarkAreas > 0.5) { - toneValue = 1.0 - toneValue; - } - // Preserve the pre-toneTarget light-mode response by keeping the power - // bias inside the averaged tone calculation. - float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0); - float bandRadius = clamp( - toneValue + powerBias + lightLift, - 0.0, - 1.0 - ) * 1.86 * 0.5; - - float alpha = 0.0; - if (bandRadius > 0.0001) { - float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth); - float edge = 0.02; - alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; - } - - vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask); - vec3 color = activeDashColor * alpha; - gl_FragColor = vec4(color, alpha); - - #include - #include - } -`; - -const HALFTONE_TRANSMISSION_SHADER_PREFIX = String.raw` -uniform float chromaticAberration; -uniform float anisotropicBlur; -uniform float time; -uniform float distortion; -uniform float distortionScale; -uniform float temporalDistortion; -uniform sampler2D buffer; - -vec3 random3(vec3 c) { - float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0))); - vec3 r; - r.z = fract(512.0 * j); - j *= 0.125; - r.x = fract(512.0 * j); - j *= 0.125; - r.y = fract(512.0 * j); - return r - 0.5; -} - -uint hash(uint x) { - x += (x << 10u); - x ^= (x >> 6u); - x += (x << 3u); - x ^= (x >> 11u); - x += (x << 15u); - return x; -} - -uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); } -uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); } -uint hash(uvec4 v) { - return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w)); -} - -float floatConstruct(uint m) { - const uint ieeeMantissa = 0x007FFFFFu; - const uint ieeeOne = 0x3F800000u; - m &= ieeeMantissa; - m |= ieeeOne; - float f = uintBitsToFloat(m); - return f - 1.0; -} - -float randomBase(float x) { - return floatConstruct(hash(floatBitsToUint(x))); -} -float randomBase(vec2 v) { - return floatConstruct(hash(floatBitsToUint(v))); -} -float randomBase(vec3 v) { - return floatConstruct(hash(floatBitsToUint(v))); -} -float randomBase(vec4 v) { - return floatConstruct(hash(floatBitsToUint(v))); -} - -float rand(float seed) { - return randomBase(vec3(gl_FragCoord.xy, seed)); -} - -const float F3 = 0.3333333; -const float G3 = 0.1666667; - -float snoise(vec3 p) { - vec3 s = floor(p + dot(p, vec3(F3))); - vec3 x = p - s + dot(s, vec3(G3)); - vec3 e = step(vec3(0.0), x - x.yzx); - vec3 i1 = e * (1.0 - e.zxy); - vec3 i2 = 1.0 - e.zxy * (1.0 - e); - vec3 x1 = x - i1 + G3; - vec3 x2 = x - i2 + 2.0 * G3; - vec3 x3 = x - 1.0 + 3.0 * G3; - vec4 w; - vec4 d; - w.x = dot(x, x); - w.y = dot(x1, x1); - w.z = dot(x2, x2); - w.w = dot(x3, x3); - w = max(0.6 - w, 0.0); - d.x = dot(random3(s), x); - d.y = dot(random3(s + i1), x1); - d.z = dot(random3(s + i2), x2); - d.w = dot(random3(s + 1.0), x3); - w *= w; - w *= w; - d *= w; - return dot(d, vec4(52.0)); -} - -float snoiseFractal(vec3 m) { - return 0.5333333 * snoise(m) - + 0.2666667 * snoise(2.0 * m) - + 0.1333333 * snoise(4.0 * m) - + 0.0666667 * snoise(8.0 * m); -} -`; - -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = String.raw` -#ifdef USE_TRANSMISSION - uniform float _transmission; - uniform float thickness; - uniform float attenuationDistance; - uniform vec3 attenuationColor; - uniform sampler2D refractionEnvMap; - uniform float useEnvMapRefraction; - #ifdef USE_TRANSMISSIONMAP - uniform sampler2D transmissionMap; - #endif - #ifdef USE_THICKNESSMAP - uniform sampler2D thicknessMap; - #endif - uniform vec2 transmissionSamplerSize; - uniform sampler2D transmissionSamplerMap; - uniform mat4 modelMatrix; - uniform mat4 projectionMatrix; - varying vec3 vWorldPosition; - - vec3 getVolumeTransmissionRay( - const in vec3 n, - const in vec3 v, - const in float thicknessValue, - const in float ior, - const in mat4 modelMatrix - ) { - vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior); - vec3 modelScale; - modelScale.x = length(vec3(modelMatrix[0].xyz)); - modelScale.y = length(vec3(modelMatrix[1].xyz)); - modelScale.z = length(vec3(modelMatrix[2].xyz)); - return normalize(refractionVector) * thicknessValue * modelScale; - } - - float applyIorToRoughness( - const in float roughnessValue, - const in float ior - ) { - return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0); - } - - vec2 directionToEquirectUv(const in vec3 direction) { - vec3 dir = normalize(direction); - vec2 uv = vec2( - atan(dir.z, dir.x) * 0.15915494309189535 + 0.5, - asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5 - ); - - return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0)); - } - - vec4 getTransmissionSample( - const in vec2 fragCoord, - const in vec3 transmissionDirection, - const in float roughnessValue, - const in float ior - ) { - if (useEnvMapRefraction > 0.5) { - return texture2D( - refractionEnvMap, - directionToEquirectUv(transmissionDirection) - ); - } - - float framebufferLod = - log2(transmissionSamplerSize.x) * - applyIorToRoughness(roughnessValue, ior); - return texture2D(buffer, fragCoord.xy); - } - - vec3 applyVolumeAttenuation( - const in vec3 radiance, - const in float transmissionDistance, - const in vec3 attenuationColorValue, - const in float attenuationDistanceValue - ) { - if (isinf(attenuationDistanceValue)) { - return radiance; - } - - vec3 attenuationCoefficient = - -log(attenuationColorValue) / attenuationDistanceValue; - vec3 transmittance = - exp(-attenuationCoefficient * transmissionDistance); - - return transmittance * radiance; - } - - vec4 getIBLVolumeRefraction( - const in vec3 n, - const in vec3 v, - const in float roughnessValue, - const in vec3 diffuseColor, - const in vec3 specularColor, - const in float specularF90, - const in vec3 position, - const in mat4 modelMatrix, - const in mat4 viewMatrix, - const in mat4 projMatrix, - const in float ior, - const in float thicknessValue, - const in vec3 attenuationColorValue, - const in float attenuationDistanceValue - ) { - vec3 transmissionRay = getVolumeTransmissionRay( - n, - v, - thicknessValue, - ior, - modelMatrix - ); - vec3 refractedRayExit = position + transmissionRay; - vec4 ndcPos = - projMatrix * viewMatrix * vec4(refractedRayExit, 1.0); - vec2 refractionCoords = ndcPos.xy / ndcPos.w; - refractionCoords += 1.0; - refractionCoords /= 2.0; - vec3 transmissionDirection = normalize(transmissionRay); - vec4 transmittedLight = getTransmissionSample( - refractionCoords, - transmissionDirection, - roughnessValue, - ior - ); - vec3 attenuatedColor = applyVolumeAttenuation( - transmittedLight.rgb, - length(transmissionRay), - attenuationColorValue, - attenuationDistanceValue - ); - vec3 F = EnvironmentBRDF( - n, - v, - specularColor, - specularF90, - roughnessValue - ); - return vec4( - (1.0 - F) * attenuatedColor * diffuseColor, - transmittedLight.a - ); - } -#endif -`; - -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = String.raw` -material.transmission = _transmission; -material.transmissionAlpha = 1.0; -material.thickness = thickness; -material.attenuationDistance = attenuationDistance; -material.attenuationColor = attenuationColor; -#ifdef USE_TRANSMISSIONMAP - material.transmission *= texture2D(transmissionMap, vUv).r; -#endif -#ifdef USE_THICKNESSMAP - material.thickness *= texture2D(thicknessMap, vUv).g; -#endif - -vec3 pos = vWorldPosition; -float runningSeed = 0.0; -vec3 v = normalize(cameraPosition - pos); -vec3 n = inverseTransformDirection(normal, viewMatrix); -vec3 transmission = vec3(0.0); -float transmissionR; -float transmissionG; -float transmissionB; -float randomCoords = rand(runningSeed++); -float thicknessSmear = - thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur); -vec3 distortionNormal = vec3(0.0); -vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion; - -if (distortion > 0.0) { - distortionNormal = distortion * vec3( - snoiseFractal(vec3(pos * distortionScale + temporalOffset)), - snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)), - snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset)) - ); -} - -for (float i = 0.0; i < __SAMPLES__.0; i++) { - vec3 sampleNorm = normalize( - n + - roughnessFactor * roughnessFactor * 2.0 * - normalize( - vec3( - rand(runningSeed++) - 0.5, - rand(runningSeed++) - 0.5, - rand(runningSeed++) - 0.5 - ) - ) * - pow(rand(runningSeed++), 0.33) + - distortionNormal - ); - - transmissionR = getIBLVolumeRefraction( - sampleNorm, - v, - material.roughness, - material.diffuseColor, - material.specularColor, - material.specularF90, - pos, - modelMatrix, - viewMatrix, - projectionMatrix, - material.ior, - material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), - material.attenuationColor, - material.attenuationDistance - ).r; - - transmissionG = getIBLVolumeRefraction( - sampleNorm, - v, - material.roughness, - material.diffuseColor, - material.specularColor, - material.specularF90, - pos, - modelMatrix, - viewMatrix, - projectionMatrix, - material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), - material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), - material.attenuationColor, - material.attenuationDistance - ).g; - - transmissionB = getIBLVolumeRefraction( - sampleNorm, - v, - material.roughness, - material.diffuseColor, - material.specularColor, - material.specularF90, - pos, - modelMatrix, - viewMatrix, - projectionMatrix, - material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), - material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), - material.attenuationColor, - material.attenuationDistance - ).b; - - transmission.r += transmissionR; - transmission.g += transmissionG; - transmission.b += transmissionB; -} - -transmission /= __SAMPLES__.0; -totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission); -`; - -const GEOMETRY_RUNTIME_SOURCE = String.raw` -function makePolarShape(radiusFunction, segments = 320) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry(shape, options = {}) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function mergeGeometries(geometries) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -function makeArrowTarget() { - const targetParts = []; - const arrowParts = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1); - shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0)); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006)); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0)); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function createBuiltinGeometry(shapeKey) { - switch (shapeKey) { - case 'torusKnot': - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - case 'sphere': - return new THREE.SphereGeometry(1.4, 64, 64); - case 'torus': - return new THREE.TorusGeometry(1, 0.45, 64, 100); - case 'icosahedron': - return new THREE.IcosahedronGeometry(1.4, 4); - case 'box': - return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); - case 'cone': - return new THREE.ConeGeometry(1.2, 2.4, 64, 10); - case 'cylinder': - return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); - case 'octahedron': - return new THREE.OctahedronGeometry(1.5, 2); - case 'dodecahedron': - return new THREE.DodecahedronGeometry(1.35, 1); - case 'tetrahedron': - return new THREE.TetrahedronGeometry(1.7, 1); - case 'sunCoin': - return makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ); - case 'lotusCoin': - return makeReliefGeometry( - makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ); - case 'arrowTarget': - return makeArrowTarget(); - case 'dollarCoin': - return makeDollarCoin(); - default: - return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); - } -} -`; - -const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw` -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - return loadingManager; -} - -function normalizeImportedGeometry(geometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root, emptyMessage) { - root.updateMatrixWorld(true); - const geometries = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} -`; - -const IMPORTED_FBX_RUNTIME_SOURCE = String.raw` - -function parseFbxGeometry(buffer, label) { - const originalWarn = console.warn; - - console.warn = (...args) => { - if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { - return; - } - - originalWarn(...args); - }; - - try { - const root = new FBXLoader(createLoadingManager()).parse(buffer, ''); - return extractMergedGeometry(root, label + ' did not contain any mesh geometry.'); - } finally { - console.warn = originalWarn; - } -} -`; - -const IMPORTED_GLB_RUNTIME_SOURCE = String.raw` -const DRACO_DECODER_PATH = ${JSON.stringify(DRACO_DECODER_PATH)}; - -function parseGlbGeometry(buffer, label) { - return new Promise((resolve, reject) => { - const loadingManager = createLoadingManager(); - const dracoLoader = new DRACOLoader(loadingManager); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const loader = new GLTFLoader(loadingManager); - loader.setDRACOLoader(dracoLoader); - - const cleanup = () => { - dracoLoader.dispose(); - }; - - loader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - label + ' did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - cleanup(); - } - }, - (error) => { - cleanup(); - reject(error); - }, - ); - }); -} -`; - -function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) { - const loaderSource = - loader === 'fbx' - ? IMPORTED_FBX_RUNTIME_SOURCE - : IMPORTED_GLB_RUNTIME_SOURCE; - - return `${IMPORTED_RUNTIME_SHARED_SOURCE} -${loaderSource} - -async function loadImportedGeometryFromUrl(modelUrl, label) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); - } - - const buffer = await response.arrayBuffer(); - - ${ - loader === 'fbx' - ? 'return parseFbxGeometry(buffer, label);' - : 'return parseGlbGeometry(buffer, label);' - } -} -`; -} - -type ExportedShapeDescriptor = { - filename: string | null; - key: string; - kind: HalftoneGeometrySpec['kind']; - label: string; - loader: HalftoneGeometrySpec['loader'] | null; -}; - -export type ParsedExportedPreset = { - componentName: string | null; - imageAssetReference: string | null; - initialPose: HalftoneExportPose; - modelAssetReference: string | null; - previewDistance: number; - settings: HalftoneStudioSettings; - shape: ExportedShapeDescriptor; -}; - -function getExportedShapeLoader( - shape: ExportedShapeDescriptor, -): HalftoneGeometrySpec['loader'] | null { - if (shape.loader) { - return shape.loader; - } - - const filename = shape.filename?.toLowerCase() ?? ''; - - if (filename.endsWith('.fbx')) { - return 'fbx'; - } - - if (filename.endsWith('.glb')) { - return 'glb'; - } - - return null; -} - -function createImportedGeometryRuntimeSource(shape: ExportedShapeDescriptor) { - if (shape.kind !== 'imported') { - return ''; - } - - const loader = getExportedShapeLoader(shape) ?? 'glb'; - - return createImportedRuntimeSource(loader); -} - -function toIllustrationRegistryKey(componentName: string) { - return componentName.charAt(0).toLowerCase() + componentName.slice(1); -} - -function toPublicAssetDestination(assetUrl: string) { - return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl; -} - -function normalizeReactExportSettings( - exportSettings?: Partial, -): ReactExportSettings { - return { - ...DEFAULT_REACT_EXPORT_SETTINGS, - ...exportSettings, - }; -} - -function getReactImportBlock( - exportSettings: ReactExportSettings, - isImageMode: boolean, - shape: ExportedShapeDescriptor, -) { - const imports = new Set(); - - imports.add("import { useEffect, useRef, type CSSProperties } from 'react';"); - imports.add("import * as THREE from 'three';"); - - if (exportSettings.includeStyledMount) { - imports.add("import { styled } from '@linaria/react';"); - } - - if (!isImageMode) { - imports.add( - "import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';", - ); - } - - const loader = getExportedShapeLoader(shape); - - if (shape.kind === 'imported' && loader === 'fbx') { - imports.add( - "import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';", - ); - } - - if (shape.kind === 'imported' && loader === 'glb') { - imports.add( - "import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';", - ); - imports.add( - "import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';", - ); - } - - return Array.from(imports).join('\n'); -} - -function getStandaloneThreeImports( - isImageMode: boolean, - shape: ExportedShapeDescriptor, -) { - if (isImageMode) { - return `import * as THREE from 'three';`; - } - - const imports = [ - `import * as THREE from 'three';`, - `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`, - ]; - - const loader = getExportedShapeLoader(shape); - - if (shape.kind === 'imported' && loader === 'fbx') { - imports.push( - `import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`, - ); - } - - if (shape.kind === 'imported' && loader === 'glb') { - imports.push( - `import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`, - `import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`, - ); - } - - return imports.join('\n '); -} - -function getTwentyReactHeaderComment( - componentName: string, - registryKey: string, - assetUrl?: string, -) { - const lines = [ - `// Suggested component destination: src/illustrations/${componentName}.tsx`, - ]; - - if (assetUrl) { - lines.push( - `// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`, - ); - } - - lines.push( - `// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`, - `// illustrations-registry.tsx: ${registryKey}: ${componentName},`, - ); - - return `${lines.join('\n')}\n`; -} - -function createDefaultExportPose(): HalftoneExportPose { - return { - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0, - rotationY: 0, - rotationZ: 0, - targetRotationX: 0, - targetRotationY: 0, - timeElapsed: 0, - }; -} - -function normalizeExportPose( - pose: HalftoneExportPose | undefined, -): HalftoneExportPose { - const fallback = createDefaultExportPose(); - - if (!pose) { - return fallback; - } - - return { - autoElapsed: Number.isFinite(pose.autoElapsed) ? pose.autoElapsed : 0, - rotateElapsed: Number.isFinite(pose.rotateElapsed) ? pose.rotateElapsed : 0, - rotationX: Number.isFinite(pose.rotationX) ? pose.rotationX : 0, - rotationY: Number.isFinite(pose.rotationY) ? pose.rotationY : 0, - rotationZ: Number.isFinite(pose.rotationZ) ? pose.rotationZ : 0, - targetRotationX: Number.isFinite(pose.targetRotationX) - ? pose.targetRotationX - : 0, - targetRotationY: Number.isFinite(pose.targetRotationY) - ? pose.targetRotationY - : 0, - timeElapsed: Number.isFinite(pose.timeElapsed) ? pose.timeElapsed : 0, - }; -} - -function normalizePreviewDistance(previewDistance: number | undefined) { - return Number.isFinite(previewDistance) - ? Math.max(previewDistance ?? REFERENCE_PREVIEW_DISTANCE, 0.001) - : REFERENCE_PREVIEW_DISTANCE; -} - -function extractSerializedJson( - content: string, - name: string, - nextName: string, -) { - const pattern = new RegExp( - String.raw`const\s+${name}\s*=\s*([\s\S]*?)\s*;\s*\r?\nconst\s+${nextName}\s*=`, - ); - const match = content.match(pattern); - - if (!match) { - throw new Error(`Could not find ${name} in the exported preset.`); - } - - try { - return JSON.parse(match[1]) as T; - } catch (error) { - throw new Error( - error instanceof Error - ? `Could not parse ${name}: ${error.message}` - : `Could not parse ${name}.`, - ); - } -} - -function extractFirstMatch(content: string, patterns: RegExp[]) { - for (const pattern of patterns) { - const match = content.match(pattern); - - if (match && match.length > 1) { - return match[match.length - 1] ?? null; - } - } - - return null; -} - -function assertRoundedBandPreset( - settings: HalftoneStudioSettings, -): asserts settings is HalftoneStudioSettings { - if (isRoundedBandHalftoneSettings(settings.halftone)) { - return; - } - - const halftoneValue = - settings.halftone && typeof settings.halftone === 'object' - ? (settings.halftone as Record) - : null; - const detectedLegacyKeys = LEGACY_HALFTONE_SETTING_KEYS.filter((key) => - halftoneValue ? key in halftoneValue : false, - ); - const suffix = - detectedLegacyKeys.length > 0 - ? ` Detected legacy keys: ${detectedLegacyKeys.join(', ')}.` - : ''; - - throw new Error( - `This preset uses the legacy halftone effect and can no longer be imported.${suffix}`, - ); -} - -export function parseExportedPreset(content: string): ParsedExportedPreset { - const settings = extractSerializedJson( - content, - 'settings', - 'shape', - ); - assertRoundedBandPreset(settings); - const shape = extractSerializedJson( - content, - 'shape', - 'initialPose', - ); - const initialPose = normalizeExportPose( - (() => { - try { - return extractSerializedJson( - content, - 'initialPose', - 'previewDistance', - ); - } catch { - return extractSerializedJson( - content, - 'initialPose', - 'VIRTUAL_RENDER_HEIGHT', - ); - } - })(), - ); - const previewDistanceMatch = content.match( - /const\s+previewDistance\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;/, - ); - const previewDistance = normalizePreviewDistance( - previewDistanceMatch ? Number(previewDistanceMatch[1]) : undefined, - ); - const componentName = - extractFirstMatch(content, [ - /export\s+default\s+function\s+([A-Za-z0-9_]+)/, - /export\s+function\s+([A-Za-z0-9_]+)/, - /export\s+default\s+([A-Za-z0-9_]+)\s*;/, - /([^<]+)<\/title>/i, - ]) ?? null; - const modelAssetReference = extractFirstMatch(content, [ - /modelUrl\s*=\s*(['"`])([\s\S]*?)\1/, - /modelUrl\s*:\s*(['"`])([\s\S]*?)\1/, - ]); - const imageAssetReference = extractFirstMatch(content, [ - /imageUrl\s*=\s*(['"`])([\s\S]*?)\1/, - /imageUrl\s*:\s*(['"`])([\s\S]*?)\1/, - ]); - - return { - componentName, - imageAssetReference, - initialPose, - modelAssetReference, - previewDistance, - settings, - shape, - }; -} - -export function deriveExportComponentName( - shape: HalftoneGeometrySpec | undefined, - importedFile: File | undefined, -) { - const source = - importedFile?.name ?? - shape?.filename ?? - shape?.label ?? - shape?.key ?? - 'HalftoneDashes'; - - return normalizeExportComponentName(source); -} - -function createShapeDescriptor( - shape: HalftoneGeometrySpec | undefined, - settings: HalftoneStudioSettings, - importedFile?: File, - modelFilenameOverride?: string, -): ExportedShapeDescriptor { - if (!shape) { - return { - filename: null, - key: settings.shapeKey, - kind: 'builtin', - label: settings.shapeKey, - loader: null, - }; - } - - const effectiveImportedFilename = - modelFilenameOverride ?? importedFile?.name ?? shape.filename ?? null; - - return { - filename: - shape.kind === 'imported' - ? effectiveImportedFilename - : (shape.filename ?? null), - key: shape.key, - kind: shape.kind, - label: - shape.kind === 'imported' - ? (effectiveImportedFilename ?? shape.label) - : shape.label, - loader: shape.loader ?? null, - }; -} - -function getModelMimeType( - file: File, - loader: HalftoneGeometrySpec['loader'] | null, -) { - if (file.type) { - return file.type; - } - - if (loader === 'glb' || file.name.toLowerCase().endsWith('.glb')) { - return 'model/gltf-binary'; - } - - if (loader === 'fbx' || file.name.toLowerCase().endsWith('.fbx')) { - return 'application/octet-stream'; - } - - return 'application/octet-stream'; -} - -async function fileToDataUrl( - file: File, - loader: HalftoneGeometrySpec['loader'] | null, -) { - const buffer = await file.arrayBuffer(); - const blob = new Blob([buffer], { - type: getModelMimeType(file, loader), - }); - - return await new Promise<string>((resolve, reject) => { - const reader = new FileReader(); - - reader.onerror = () => { - reject(reader.error ?? new Error(`Unable to read ${file.name}.`)); - }; - reader.onload = () => { - if (typeof reader.result !== 'string') { - reject(new Error(`Unable to encode ${file.name}.`)); - return; - } - - resolve(reader.result); - }; - - reader.readAsDataURL(blob); - }); -} - -const GLASS_MATERIAL_RUNTIME_SOURCE = String.raw` -const GLASS_ENVIRONMENT_DATA_URL = ${JSON.stringify(GLASS_ENVIRONMENT_DATA_URL)}; -const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; -const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; -const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; -const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; -const GLASS_ENVIRONMENT_ZOOM = 1.55; -const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); -const MAX_TEXTURE_ANISOTROPY = 8; -const HALFTONE_TRANSMISSION_SHADER_PREFIX = ${JSON.stringify( - HALFTONE_TRANSMISSION_SHADER_PREFIX, -)}; -const HALFTONE_TRANSMISSION_PARS_FRAGMENT = ${JSON.stringify( - HALFTONE_TRANSMISSION_PARS_FRAGMENT, -)}; -const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = ${JSON.stringify( - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE, -)}; - -class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { - constructor(samples = 10) { - super(); - - this.halftoneUniforms = { - chromaticAberration: { value: 0.05 }, - transmission: { value: 0 }, - _transmission: { value: 1 }, - transmissionMap: { value: null }, - refractionEnvMap: { value: null }, - useEnvMapRefraction: { value: 0 }, - roughness: { value: 0 }, - thickness: { value: 0 }, - thicknessMap: { value: null }, - attenuationDistance: { value: Infinity }, - attenuationColor: { value: new THREE.Color('white') }, - anisotropicBlur: { value: 0.1 }, - time: { value: 0 }, - distortion: { value: 0 }, - distortionScale: { value: 0.5 }, - temporalDistortion: { value: 0 }, - buffer: { value: null }, - }; - - this.customProgramCacheKey = () => 'halftone-transmission-' + samples; - - this.onBeforeCompile = (shader) => { - shader.uniforms = { - ...shader.uniforms, - ...this.halftoneUniforms, - }; - shader.defines ??= {}; - - if (this.anisotropy > 0) { - shader.defines.USE_ANISOTROPY = ''; - } - - shader.defines.USE_TRANSMISSION = ''; - shader.fragmentShader = - HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; - shader.fragmentShader = shader.fragmentShader.replace( - '#include <transmission_pars_fragment>', - HALFTONE_TRANSMISSION_PARS_FRAGMENT, - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include <transmission_fragment>', - HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( - '__SAMPLES__', - String(samples), - ), - ); - }; - - Object.keys(this.halftoneUniforms).forEach((key) => { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.halftoneUniforms[key]?.value, - set: (value) => { - this.halftoneUniforms[key].value = value; - }, - }); - }); - } -} - -function setTextureSampling(texture, renderer) { - texture.generateMipmaps = true; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearMipmapLinearFilter; - texture.anisotropy = Math.min( - renderer.capabilities.getMaxAnisotropy(), - MAX_TEXTURE_ANISOTROPY, - ); -} - -function disposeEnvironmentScene(scene) { - scene.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (Array.isArray(object.material)) { - object.material.forEach((material) => material.dispose()); - return; - } - - object.material?.dispose?.(); - }); -} - -function createSolidEnvironmentTexture(renderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function getTextureImageSize(texture) { - const image = texture.image; - - return { - height: - image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, - width: - image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, - }; -} - -function createZoomedGlassTexture(sourceTexture, renderer, zoom) { - if (zoom <= 1) { - return sourceTexture; - } - - const { width, height } = getTextureImageSize(sourceTexture); - - if (!width || !height) { - return sourceTexture; - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - - if (!context) { - return sourceTexture; - } - - const cropWidth = width / zoom; - const cropHeight = height / zoom; - const sourceX = (width - cropWidth) / 2; - const sourceY = (height - cropHeight) / 2; - - context.drawImage( - sourceTexture.image, - sourceX, - sourceY, - cropWidth, - cropHeight, - 0, - 0, - width, - height, - ); - - const zoomedTexture = new THREE.CanvasTexture(canvas); - zoomedTexture.colorSpace = sourceTexture.colorSpace; - zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; - zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; - setTextureSampling(zoomedTexture, renderer); - zoomedTexture.needsUpdate = true; - - return zoomedTexture; -} - -function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = backdropTexture - ? pmremGenerator.fromEquirectangular(backdropTexture).texture - : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createFallbackGlassBackdropTexture(renderer) { - const texture = new THREE.DataTexture( - new Uint8Array([3, 3, 3, 255]), - 1, - 1, - THREE.RGBAFormat, - ); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.ClampToEdgeWrapping; - texture.wrapT = THREE.ClampToEdgeWrapping; - texture.mapping = THREE.EquirectangularReflectionMapping; - setTextureSampling(texture, renderer); - texture.needsUpdate = true; - - return texture; -} - -function loadTexture(url, renderer, colorSpace) { - const loader = new THREE.TextureLoader(); - - return new Promise((resolve, reject) => { - loader.load( - url, - (texture) => { - texture.colorSpace = colorSpace; - setTextureSampling(texture, renderer); - resolve(texture); - }, - undefined, - reject, - ); - }); -} - -async function loadGlassEnvironmentTexture(renderer) { - const sourceBackgroundTexture = await loadTexture( - GLASS_ENVIRONMENT_DATA_URL, - renderer, - THREE.SRGBColorSpace, - ); - const backgroundTexture = createZoomedGlassTexture( - sourceBackgroundTexture, - renderer, - GLASS_ENVIRONMENT_ZOOM, - ); - if (backgroundTexture !== sourceBackgroundTexture) { - sourceBackgroundTexture.dispose(); - } - backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; - backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; - backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; - backgroundTexture.needsUpdate = true; - const environmentTexture = createStudioGlassEnvironmentTexture( - renderer, - backgroundTexture, - ); - - return { - backgroundTexture, - environmentTexture, - }; -} - -async function createHalftoneMaterialAssets(renderer) { - const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); - - try { - const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, - glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, - solidEnvironmentTexture, - }; - } catch { - const fallbackGlassBackdropTexture = - createFallbackGlassBackdropTexture(renderer); - const fallbackGlassEnvironmentTexture = - createStudioGlassEnvironmentTexture(renderer); - - return { - glassBackgroundTexture: fallbackGlassBackdropTexture, - glassEnvironmentTexture: fallbackGlassEnvironmentTexture, - solidEnvironmentTexture, - }; - } -} - -function createHalftoneMaterial() { - return new HalftoneTransmissionMaterial(); -} - -function applyHalftoneMaterialSettings(material, materialSettings, materialAssets) { - const isGlass = materialSettings.surface === 'glass'; - const glassThickness = - materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; - const glassEnvironmentIntensity = - GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; - const glassAttenuationDistance = Math.max( - glassThickness * 4, - GLASS_ATTENUATION_DISTANCE_MIN, - ); - - material.color.set(isGlass ? '#ffffff' : materialSettings.color); - material.roughness = materialSettings.roughness; - material.metalness = materialSettings.metalness; - material.envMap = isGlass - ? materialAssets.glassEnvironmentTexture - : materialAssets.solidEnvironmentTexture; - material.envMapIntensity = isGlass - ? GLASS_ENVIRONMENT_INTENSITY_BASE + - materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER - : 0.25; - material.clearcoat = isGlass ? 1 : 0; - material.clearcoatRoughness = isGlass - ? Math.max(materialSettings.roughness * 0.25, 0.01) - : 0.08; - material.reflectivity = isGlass ? 0.98 : 0.5; - material.transmission = 0; - material._transmission = isGlass ? 1 : 0; - material.refractionEnvMap = isGlass ? materialAssets.glassBackgroundTexture : null; - material.useEnvMapRefraction = isGlass ? 1 : 0; - material.thickness = isGlass ? glassThickness : 0; - material.ior = isGlass ? materialSettings.refraction : 1.5; - material.buffer = null; - material.bumpMap = null; - material.bumpScale = 0; - material.roughnessMap = null; - material.side = THREE.FrontSide; - material.transparent = false; - material.opacity = 1; - material.depthWrite = true; - material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); - material.attenuationDistance = isGlass - ? glassAttenuationDistance - : Infinity; - material.anisotropicBlur = isGlass - ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) - : 0.1; - material.chromaticAberration = isGlass ? 0 : 0.05; - material.distortion = 0; - material.distortionScale = 0.5; - material.temporalDistortion = 0; - material.userData.halftoneIsGlass = isGlass; - material.userData.halftoneGlassBacksideThickness = isGlass - ? glassThickness * 2 - : 0; - material.userData.halftoneGlassBacksideEnvIntensity = isGlass - ? glassEnvironmentIntensity * 2.8 - : 0; - material.userData.halftoneUseEnvironmentRefraction = isGlass; - material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; - - material.needsUpdate = true; -} - -function disposeHalftoneMaterialAssets(materialAssets) { - materialAssets.glassBackgroundTexture.dispose(); - - if (materialAssets.glassEnvironmentTexture !== materialAssets.glassBackgroundTexture) { - materialAssets.glassEnvironmentTexture.dispose(); - } - - materialAssets.solidEnvironmentTexture.dispose(); -} -`; - -function serializeRuntimeSource( - settings: HalftoneStudioSettings, - shape: ExportedShapeDescriptor, - initialPose: HalftoneExportPose, - previewDistance: number | undefined, -) { - const isImageMode = settings.sourceMode === 'image'; - const normalizedPreviewDistance = normalizePreviewDistance(previewDistance); - - return ` -const settings = ${JSON.stringify(settings, null, 2)}; -const shape = ${JSON.stringify(shape, null, 2)}; -const initialPose = ${JSON.stringify(initialPose, null, 2)}; -const previewDistance = ${JSON.stringify(normalizedPreviewDistance)}; -const VIRTUAL_RENDER_HEIGHT = ${VIRTUAL_RENDER_HEIGHT}; -const passThroughVertexShader = ${JSON.stringify(passThroughVertexShader)}; -const blurFragmentShader = ${JSON.stringify(blurFragmentShader)}; -const halftoneFragmentShader = ${JSON.stringify(halftoneFragmentShader)}; -${isImageMode ? `const imagePassthroughFragmentShader = ${JSON.stringify(imagePassthroughFragmentShader)};` : ''} - -${HALFTONE_FOOTPRINT_RUNTIME_SOURCE} - -${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE} - -${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)} - -${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE} - -function createRenderTarget(width, height) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function createInteractionState() { - return { - autoElapsed: initialPose.autoElapsed, - activePointerId: null, - dragging: false, - hoverStrength: 0, - mouseX: 0.5, - mouseY: 0.5, - pointerInside: false, - pointerVelocityX: 0, - pointerVelocityY: 0, - pointerX: 0, - pointerY: 0, - rotateElapsed: initialPose.rotateElapsed, - rotationX: initialPose.rotationX, - rotationVelocityX: 0, - rotationY: initialPose.rotationY, - rotationVelocityY: 0, - rotationZ: initialPose.rotationZ, - rotationVelocityZ: 0, - smoothedMouseX: 0.5, - smoothedMouseY: 0.5, - targetRotationX: initialPose.targetRotationX, - targetRotationY: initialPose.targetRotationY, - velocityX: 0, - velocityY: 0, - }; -} - -${ - isImageMode - ? '' - : ` -function setPrimaryLightPosition(light, angleDegrees, height) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set(Math.cos(lightAngle) * 5, height, Math.sin(lightAngle) * 5); -} - -function applySpringStep(current, target, velocity, strength, damping) { - const nextVelocity = (velocity + (target - current) * strength) * damping; - const nextValue = current + nextVelocity; - - return { - value: nextValue, - velocity: nextVelocity, - }; -} - -function resetInteractionState(interactionState) { - interactionState.dragging = false; - interactionState.mouseX = 0.5; - interactionState.mouseY = 0.5; - interactionState.targetRotationX = 0; - interactionState.targetRotationY = 0; - interactionState.velocityX = 0; - interactionState.velocityY = 0; - interactionState.rotationVelocityX = 0; - interactionState.rotationVelocityY = 0; - interactionState.rotationVelocityZ = 0; - interactionState.autoElapsed = 0; -} - -async function createGeometry(modelUrl) { - if (shape.kind === 'imported') { - if (!modelUrl) { - throw new Error('No model URL was provided for ' + shape.label + '.'); - } - - return loadImportedGeometryFromUrl(modelUrl, shape.label); - } - - return createBuiltinGeometry(shape.key); -} -` -} -`; -} - -function createMountScript() { - return ` -async function mountHalftoneCanvas(options) { - const { - container, - modelUrl, - onError, - } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - let geometry; - - try { - geometry = await createGeometry(modelUrl); - } catch (error) { - onError?.(error); - return () => {}; - } - - // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime - const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const materialAssets = await createHalftoneMaterialAssets(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const baseCameraDistance = previewDistance; - const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100); - camera.position.z = baseCameraDistance; - - const primaryLight = new THREE.DirectionalLight(0xffffff, settings.lighting.intensity); - setPrimaryLightPosition( - primaryLight, - settings.lighting.angleDegrees, - settings.lighting.height, - ); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - settings.lighting.fillIntensity, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - settings.lighting.ambientIntensity, - ); - scene3d.add(ambientLight); - - const material = createHalftoneMaterial(); - applyHalftoneMaterialSettings(material, settings.material, materialAssets); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: 0.2 }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial)); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - }; - - const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { - if (!mesh.geometry.boundingBox) { - mesh.geometry.computeBoundingBox(); - } - - if (!mesh.geometry.boundingBox) { - return 1; - } - - mesh.updateMatrixWorld(); - camera.updateMatrixWorld(); - - return getMeshFootprintScale({ - camera, - localBounds: mesh.geometry.boundingBox, - lookAtTarget, - meshMatrixWorld: mesh.matrixWorld, - viewportHeight, - viewportWidth, - }); - }; - - const interaction = createInteractionState(); - const autoRotateEnabled = settings.animation.autoRotateEnabled; - const followHoverEnabled = settings.animation.followHoverEnabled; - const followDragEnabled = settings.animation.followDragEnabled; - const rotateEnabled = settings.animation.rotateEnabled; - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event); - if (!followDragEnabled) { - return; - } - - interaction.dragging = true; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'grabbing'; - }; - - const handlePointerMove = (event) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event) => { - updatePointerPosition(event); - - if (!interaction.dragging || !followDragEnabled) { - return; - } - - const deltaX = (event.clientX - interaction.pointerX) * settings.animation.dragSens; - const deltaY = (event.clientY - interaction.pointerY) * settings.animation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - - if (!settings.animation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handlePointerCancel = () => { - interaction.dragging = false; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; - handlePointerLeave(); - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const delta = 1 / 60; - const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - - let baseRotationX = initialPose.rotationX; - let baseRotationY = initialPose.rotationY; - let baseRotationZ = initialPose.rotationZ; - let meshOffsetY = 0; - let meshScale = 1; - let lightAngle = settings.lighting.angleDegrees; - let lightHeight = settings.lighting.height; - - if (autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; - baseRotationX += Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; - } - - if (settings.animation.floatEnabled) { - const floatPhase = elapsedTime * settings.animation.floatSpeed; - const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; - - meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (settings.animation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * settings.animation.breatheSpeed) * - settings.animation.breatheAmount; - } - - if (rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = settings.animation.rotatePingPong - ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * Math.PI - : interaction.rotateElapsed * settings.animation.rotateSpeed; - - if (settings.animation.rotatePreset === 'axis') { - const axisDirection = - settings.animation.rotateAxis.startsWith('-') ? -1 : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - settings.animation.rotateAxis === 'x' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-x' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'y' || - settings.animation.rotateAxis === 'xy' || - settings.animation.rotateAxis === '-y' || - settings.animation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - settings.animation.rotateAxis === 'z' || - settings.animation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (settings.animation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (settings.animation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (settings.animation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (settings.animation.lightSweepEnabled) { - const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; - lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * - settings.animation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (followHoverEnabled) { - const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180; - - if ( - settings.animation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = settings.animation.hoverEase; - } - - if (followDragEnabled) { - if (!interaction.dragging && settings.animation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - settings.animation.dragFriction; - interaction.velocityY *= 1 - settings.animation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = settings.animation.dragFriction; - } - - if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (settings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - settings.animation.springStrength, - settings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - settings.animation.springStrength, - settings.animation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (settings.animation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (settings.animation.cameraParallaxEnabled) { - const cameraRange = settings.animation.cameraParallaxAmount; - const cameraEase = settings.animation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = - Math.sin(orbitPitch) * baseCameraDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; - } - - const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); - - camera.lookAt(lookAtTarget); - setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( - getVirtualWidth(), - getVirtualHeight(), - lookAtTarget, - ); - - if (!settings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - disposeHalftoneMaterialAssets(materialAssets); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} -`; -} - -function createImageMountScript() { - return ` -async function mountHalftoneCanvas(options) { - const { - container, - imageUrl, - onError, - } = options; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), - 1, - ); - - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error('Failed to load image')); - img.src = imageUrl; - }); - - // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime - const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = 'default'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const imageTexture = new THREE.Texture(image); - imageTexture.colorSpace = THREE.SRGBColorSpace; - imageTexture.needsUpdate = true; - - const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const imageMaterial = new THREE.ShaderMaterial({ - uniforms: { - tImage: { value: imageTexture }, - imageSize: { value: new THREE.Vector2(image.width, image.height) }, - viewportSize: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - zoom: { value: getImagePreviewZoom(previewDistance) }, - contrast: { value: settings.halftone.imageContrast }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: imagePassthroughFragmentShader, - }); - - const imageScene = new THREE.Scene(); - imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - logicalResolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - tile: { value: settings.halftone.scale }, - s_3: { value: settings.halftone.power }, - s_4: { value: settings.halftone.width }, - applyToDarkAreas: { - value: settings.halftone.toneTarget === 'dark' ? 1 : 0, - }, - dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, - hoverDashColor: { - value: new THREE.Color(settings.halftone.hoverDashColor), - }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: settings.animation.waveSpeed }, - footprintScale: { value: 1.0 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverHalftoneActive: { value: 0 }, - hoverHalftonePowerShift: { value: 0 }, - hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius }, - hoverHalftoneWidthShift: { value: 0 }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: settings.animation.hoverLightRadius }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 1 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add(new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial)); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial)); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const updateViewportUniforms = ( - logicalWidth, - logicalHeight, - effectWidth, - effectHeight, - ) => { - blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); - halftoneMaterial.uniforms.effectResolution.value.set( - effectWidth, - effectHeight, - ); - halftoneMaterial.uniforms.logicalResolution.value.set( - logicalWidth, - logicalHeight, - ); - imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); - }; - - const getHalftoneScale = () => - getImageFootprintScale({ - imageHeight: image.height, - imageWidth: image.width, - previewDistance, - viewportHeight: getVirtualHeight(), - viewportWidth: getVirtualWidth(), - }); - - const interaction = createInteractionState(); - const imagePointerFollow = 0.38; - const imagePointerVelocityDamping = 0.82; - const imageHoverFadeIn = 18; - const imageHoverFadeOut = 7; - - const syncSize = () => { - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - updateViewportUniforms( - virtualWidth, - virtualHeight, - virtualWidth, - virtualHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = (event, options = {}) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - const nextMouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, 0, 1, - ); - const nextMouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, 0, 1, - ); - - const deltaX = nextMouseX - interaction.mouseX; - const deltaY = nextMouseY - interaction.mouseY; - - interaction.mouseX = nextMouseX; - interaction.mouseY = nextMouseY; - interaction.pointerInside = - interaction.dragging || - (event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom); - - if (options.resetVelocity) { - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - interaction.smoothedMouseX = nextMouseX; - interaction.smoothedMouseY = nextMouseY; - } else { - interaction.pointerVelocityX = deltaX; - interaction.pointerVelocityY = deltaY; - } - - return { deltaX, deltaY }; - }; - - const releasePointerCapture = (pointerId) => { - if (pointerId === null) { - return; - } - - if (!canvas.hasPointerCapture(pointerId)) { - return; - } - - try { - canvas.releasePointerCapture(pointerId); - } catch (error) { - void error; - } - }; - - const handlePointerDown = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerMove = (event) => { - const resetVelocity = !interaction.pointerInside && !interaction.dragging; - updatePointerPosition( - event, - resetVelocity ? { resetVelocity: true } : undefined, - ); - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handlePointerUp = (event) => { - updatePointerPosition(event, { resetVelocity: true }); - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - const rect = canvas.getBoundingClientRect(); - interaction.pointerInside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - }; - - const handlePointerCancel = () => { - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - }; - - const handleWindowBlur = () => { - handlePointerCancel(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointerup', handlePointerUp); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Timer(); - clock.connect(document); - let animationFrameId = 0; - - const renderFrame = (timestamp) => { - animationFrameId = window.requestAnimationFrame(renderFrame); - clock.update(timestamp); - - const deltaSeconds = clock.getDelta(); - const elapsedTime = clock.getElapsed(); - halftoneMaterial.uniforms.time.value = elapsedTime; - const hoverEasing = - 1 - - Math.exp( - -deltaSeconds * - (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut), - ); - interaction.hoverStrength += - ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * - hoverEasing; - - interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow; - interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow; - interaction.pointerVelocityX *= imagePointerVelocityDamping; - interaction.pointerVelocityY *= imagePointerVelocityDamping; - - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.smoothedMouseX, - 1 - interaction.smoothedMouseY, - ); - halftoneMaterial.uniforms.interactionVelocity.value.set( - interaction.pointerVelocityX * getVirtualWidth(), - -interaction.pointerVelocityY * getVirtualHeight(), - ); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - halftoneMaterial.uniforms.hoverHalftoneActive.value = - settings.animation.hoverHalftoneEnabled ? interaction.hoverStrength : 0; - halftoneMaterial.uniforms.hoverHalftonePowerShift.value = - settings.animation.hoverHalftoneEnabled - ? settings.animation.hoverHalftonePowerShift - : 0; - halftoneMaterial.uniforms.hoverHalftoneRadius.value = - settings.animation.hoverHalftoneRadius; - halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = - settings.animation.hoverHalftoneEnabled - ? settings.animation.hoverHalftoneWidthShift - : 0; - halftoneMaterial.uniforms.hoverLightStrength.value = - settings.animation.hoverLightEnabled - ? settings.animation.hoverLightIntensity * interaction.hoverStrength - : 0; - halftoneMaterial.uniforms.hoverLightRadius.value = - settings.animation.hoverLightRadius; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); - halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); - - renderer.setRenderTarget(sceneTarget); - renderer.render(imageScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - window.cancelAnimationFrame(animationFrameId); - clock.dispose(); - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerup', handlePointerUp); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); - imageTexture.dispose(); - fullScreenGeometry.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; -} -`; -} - -export function getExportedModelFile( - shape: HalftoneGeometrySpec | undefined, - importedFile: File | undefined, -) { - if (!shape || shape.kind !== 'imported' || !importedFile) { - return null; - } - - return importedFile; -} - -export function generateReactComponent( - settings: HalftoneStudioSettings, - selectedShape: HalftoneGeometrySpec | undefined, - componentName = 'HalftoneDashes', - options: ReactExportOptions = {}, -) { - const isImageMode = settings.sourceMode === 'image'; - const exportSettings = normalizeReactExportSettings(options.exportSettings); - const shape = createShapeDescriptor( - selectedShape, - settings, - options.importedFile, - options.modelFilenameOverride, - ); - const pose = normalizeExportPose(options.initialPose); - const normalizedComponentName = normalizeExportComponentName(componentName); - const background = options.background ?? 'transparent'; - const assetUrl = - exportSettings.includePublicAssetUrl && options.assetUrl - ? options.assetUrl - : null; - const defaultModelFilename = - options.modelFilenameOverride ?? shape.filename ?? 'model.glb'; - const defaultImageFilename = options.imageFilename ?? 'image.png'; - const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`; - const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`; - const importBlock = getReactImportBlock(exportSettings, isImageMode, shape); - const headerComment = exportSettings.includeRegistryComment - ? getTwentyReactHeaderComment( - normalizedComponentName, - toIllustrationRegistryKey(normalizedComponentName), - isImageMode || shape.kind === 'imported' - ? (assetUrl ?? undefined) - : undefined, - ) - : ''; - const mountScript = isImageMode - ? createImageMountScript() - : createMountScript(); - const serializedRuntime = serializeRuntimeSource( - settings, - shape, - pose, - options.previewDistance, - ); - const generatedBanner = exportSettings.includeTsNoCheck - ? `/** - * @generated halftone-studio - * This file is autogenerated by the in-app halftone studio - * (src/app/halftone). Do NOT hand-edit — regenerate the export - * from the studio if the visual or runtime behaviour needs to change. - * - * Type checking is disabled at the file level because the serialized - * Three.js runtime below contains thousands of lines of legacy - * implicitly-typed code that the generator does not annotate. See - * src/app/halftone/_lib/exporters.ts for the generator source. - */ -// @ts-nocheck` - : null; - const directiveLines = [ - generatedBanner, - exportSettings.includeUseClientDirective ? "'use client';" : null, - ] - .filter((line): line is string => line !== null) - .join('\n'); - const mountStyleBlock = exportSettings.includeStyledMount - ? `const StyledVisualMount = styled.div\` - background: ${background}; - display: block; - height: 100%; - min-width: 0; - width: 100%; -\`; -` - : ''; - const assetPropName = isImageMode - ? 'imageUrl' - : shape.kind === 'imported' - ? 'modelUrl' - : null; - const assetPropDefaultValue = - assetPropName === 'imageUrl' - ? defaultImageUrl - : assetPropName === 'modelUrl' - ? defaultModelUrl - : null; - const propsTypeBlock = assetPropName - ? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};` - : `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`; - const propsSignature = assetPropName - ? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props` - : `{\n style,\n}: ${normalizedComponentName}Props`; - const mountOptionsBlock = assetPropName - ? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });` - : `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`; - const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]'; - const returnBlock = exportSettings.includeStyledMount - ? `return <StyledVisualMount aria-hidden ref={mountReference} style={style} />;` - : `return ( - <div - ref={mountReference} - style={{ - background: ${JSON.stringify(background)}, - height: '100%', - width: '100%', - ...style, - }} - /> - );`; - const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport - ? `export function ${normalizedComponentName}(${propsSignature}) { - const mountReference = useRef<HTMLDivElement>(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - ${mountOptionsBlock} - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, ${effectDependencies}); - - ${returnBlock} -} - -export default ${normalizedComponentName};` - : `export default function ${normalizedComponentName}(${propsSignature}) { - const mountReference = useRef<HTMLDivElement>(null); - - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - ${mountOptionsBlock} - - return () => { - void Promise.resolve(unmount).then((dispose) => dispose?.()); - }; - }, ${effectDependencies}); - - ${returnBlock} -}`; - - return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock} - -${headerComment}${serializedRuntime} - -${mountScript} - -${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock} - -${componentFunctionBlock} -`; -} - -export async function generateStandaloneHtml( - settings: HalftoneStudioSettings, - selectedShape: HalftoneGeometrySpec | undefined, - componentName = 'HalftoneDashes', - options: ReactExportOptions = {}, -) { - const isImageMode = settings.sourceMode === 'image'; - const shape = createShapeDescriptor( - selectedShape, - settings, - options.importedFile, - options.modelFilenameOverride, - ); - const pose = normalizeExportPose(options.initialPose); - const normalizedComponentName = normalizeExportComponentName(componentName); - const defaultImageUrl = options.imageFilename ?? 'image.png'; - const embeddedImportedModelUrl = - !isImageMode && shape.kind === 'imported' && options.importedFile - ? await fileToDataUrl(options.importedFile, shape.loader) - : null; - const defaultModelUrl = - embeddedImportedModelUrl ?? - options.modelFilenameOverride ?? - shape.filename ?? - 'model.glb'; - const background = options.background ?? 'transparent'; - - const threeImports = getStandaloneThreeImports(isImageMode, shape); - - const mountScript = isImageMode - ? createImageMountScript() - : createMountScript(); - - const mountCall = isImageMode - ? `mountHalftoneCanvas({ - container, - imageUrl: ${JSON.stringify(`./${defaultImageUrl}`)}, - onError: (error) => { - console.error(error); - }, - });` - : `mountHalftoneCanvas({ - container, - modelUrl: ${JSON.stringify(`./${defaultModelUrl}`)}, - onError: (error) => { - console.error(error); - }, - });`; - - const captionText = isImageMode - ? `Place <code>${defaultImageUrl}</code> next to this HTML file.` - : `Standalone export of the current halftone scene. - ${ - shape.kind === 'imported' - ? embeddedImportedModelUrl - ? 'The uploaded model is embedded directly in this HTML file.' - : `Place <code>${defaultModelUrl}</code> next to this HTML file to keep the current uploaded shape.` - : '' - }`; - - return `<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>${normalizedComponentName} - - - - -
    -
    -
    - ${captionText} -
    -
    - - - - -`; -} diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts deleted file mode 100644 index ea741c4c7e..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - DEFAULT_GEOMETRY_SPECS, - DEFAULT_HALFTONE_SETTINGS, - normalizeHalftoneStudioSettings, - type HalftoneStudioSettings, -} from '@/lib/halftone/state'; - -export type ShareableHalftoneState = { - settings: HalftoneStudioSettings; - previewDistance: number; - exportName: string; -}; - -function toUrlSafeBase64(input: string) { - return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function fromUrlSafeBase64(input: string) { - let normalized = input.replace(/-/g, '+').replace(/_/g, '/'); - - while (normalized.length % 4 !== 0) { - normalized += '='; - } - - return normalized; -} - -function encodeUtf8ToBase64(value: string) { - const bytes = new TextEncoder().encode(value); - let binary = ''; - - bytes.forEach((byte) => { - binary += String.fromCharCode(byte); - }); - - return btoa(binary); -} - -function decodeBase64ToUtf8(value: string) { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - - return new TextDecoder().decode(bytes); -} - -export function encodeShareState(state: ShareableHalftoneState): string { - const payload = { - settings: state.settings, - previewDistance: state.previewDistance, - exportName: state.exportName, - }; - - return toUrlSafeBase64(encodeUtf8ToBase64(JSON.stringify(payload))); -} - -function sanitizeShapeKey(shapeKey: string): string { - const isBuiltinShape = DEFAULT_GEOMETRY_SPECS.some( - (spec) => spec.key === shapeKey, - ); - - return isBuiltinShape ? shapeKey : DEFAULT_HALFTONE_SETTINGS.shapeKey; -} - -export function decodeShareState( - hash: string | null | undefined, -): ShareableHalftoneState | null { - if (!hash) { - return null; - } - - const trimmed = hash.replace(/^#/, '').trim(); - - if (!trimmed) { - return null; - } - - try { - const json = decodeBase64ToUtf8(fromUrlSafeBase64(trimmed)); - const parsed = JSON.parse(json) as Partial | null; - - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const settings = normalizeHalftoneStudioSettings(parsed.settings); - const previewDistance = - typeof parsed.previewDistance === 'number' && - Number.isFinite(parsed.previewDistance) - ? parsed.previewDistance - : null; - const exportName = - typeof parsed.exportName === 'string' ? parsed.exportName : ''; - - if (previewDistance === null) { - return null; - } - - return { - settings: { - ...settings, - shapeKey: sanitizeShapeKey(settings.shapeKey), - }, - previewDistance, - exportName, - }; - } catch { - return null; - } -} - -export function buildShareUrl(state: ShareableHalftoneState): string { - const hash = encodeShareState(state); - const { origin, pathname } = window.location; - - return `${origin}${pathname}#${hash}`; -} diff --git a/packages/twenty-website-new/src/app/[locale]/layout.tsx b/packages/twenty-website-new/src/app/[locale]/layout.tsx index 54d9313bec..e5d34fcad7 100644 --- a/packages/twenty-website-new/src/app/[locale]/layout.tsx +++ b/packages/twenty-website-new/src/app/[locale]/layout.tsx @@ -4,7 +4,7 @@ import { getSiteUrl, JsonLd, } from '@/lib/seo'; -import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path'; +import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/utils/draco-decoder-path'; import { theme } from '@/theme'; import { cssVariables } from '@/theme/css-variables'; import { css } from '@linaria/core'; @@ -15,19 +15,17 @@ import { type ReactNode } from 'react'; import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate'; import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange'; -import { ContactCalModalRoot } from '@/lib/contact-cal'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { I18nProvider, - PUBLIC_APP_LOCALE_LIST, localeToUrlSegment, + PUBLIC_APP_LOCALE_LIST, resolveLocaleParam, } from '@/lib/i18n'; -import { getLocaleMessages } from '@/lib/i18n/messages-by-locale'; -import { setServerI18n } from '@/lib/i18n/set-server-i18n'; -import { PartnerApplicationModalRoot } from '@/lib/partner-application'; -import { Footer } from '@/sections/Footer/components'; -import { FOOTER_DATA } from '@/sections/Footer/data'; +import { getLocaleMessages } from '@/lib/i18n/utils/messages-by-locale'; +import { setServerI18n } from '@/lib/i18n/utils/set-server-i18n'; +import { ContactCalModalRoot } from '@/sections/ContactCal'; +import { Footer, FOOTER_DATA } from '@/sections/Footer'; +import { PartnerApplicationModalRoot } from '@/sections/PartnerApplication'; const hostGrotesk = Host_Grotesk({ subsets: ['latin'], @@ -114,6 +112,11 @@ export const metadata: Metadata = { site: '@twentycrm', creator: '@twentycrm', }, + alternates: { + types: { + 'application/rss+xml': '/articles/feed.xml', + }, + }, }; type LocaleLayoutParams = { locale: string }; @@ -134,8 +137,7 @@ const LocaleLayout = async ({ }) => { const { locale: rawLocale } = await params; const locale = resolveLocaleParam(rawLocale); - const i18n = setServerI18n(locale); - const renderText = createMessageDescriptorRenderer(i18n); + setServerI18n(locale); const messages = getLocaleMessages(locale); return ( @@ -162,14 +164,10 @@ const LocaleLayout = async ({ - + diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx index c3d7c525db..20047f6a3c 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx +++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx @@ -4,8 +4,8 @@ import { BaseButton, buttonBaseStyles, } from '@/design-system/components/Button/BaseButton'; -import { useRenderMessage } from '@/lib/i18n/use-render-message'; -import { usePartnerApplicationModal } from '@/lib/partner-application'; +import { useLingui } from '@lingui/react'; +import { usePartnerApplicationModal } from '@/sections/PartnerApplication'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { styled } from '@linaria/react'; @@ -25,7 +25,7 @@ export function BecomePartnerButton({ label = msg`Become a partner`, variant = 'contained', }: BecomePartnerButtonProps) { - const renderText = useRenderMessage(); + const { i18n } = useLingui(); const { openPartnerApplicationModal } = usePartnerApplicationModal(); return ( @@ -37,7 +37,7 @@ export function BecomePartnerButton({ openPartnerApplicationModal(); }} > - + ); } diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx index 94b395327a..93906abdd9 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx +++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { msg } from '@lingui/core/macro'; import { BecomePartnerButton } from './BecomePartnerButton'; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx index b103637dfe..29f45d6913 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx +++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { msg } from '@lingui/core/macro'; import { BecomePartnerButton } from './BecomePartnerButton'; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts deleted file mode 100644 index fff36e4389..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const HERO_COPY = { - body: msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx index d2e1c695a1..13ea4fab88 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx @@ -1,41 +1,36 @@ import { msg } from '@lingui/core/macro'; -import { FAQ_DATA } from '@/sections/Faq/data'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { Faq, FAQ_QUESTIONS } from '@/sections/Faq'; +import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers'; -import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/partners/three-cards-illustration.data'; -import { HERO_COPY } from '@/app/[locale]/partners/hero.data'; -import { SIGNOFF_COPY } from '@/app/[locale]/partners/signoff.data'; -import { TESTIMONIALS_DATA } from '@/app/[locale]/partners/testimonials.data'; +import { PARTNER_ILLUSTRATION_CARDS } from '@/app/[locale]/partners/three-cards-illustration.data'; +import { PARTNER_TESTIMONIALS } from '@/app/[locale]/partners/testimonials.data'; import { PartnerHeroCtas, PartnerSignoffCtas, } from '@/app/[locale]/partners/components/PartnerApplication'; import { - Body, Eyebrow, Heading, HeadingPart, LinkButton, } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components'; -import { Faq } from '@/sections/Faq/components'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { Signoff } from '@/sections/Signoff/components'; -import { Testimonials } from '@/sections/Testimonials/components'; -import { ThreeCards } from '@/sections/ThreeCards/components'; -import { TrustedBy } from '@/sections/TrustedBy/components'; -import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout'; +import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Signoff } from '@/sections/Signoff'; +import { Testimonials } from '@/sections/Testimonials'; +import { + ThreeCards, + type ThreeCardsScrollLayoutOptions, +} from '@/sections/ThreeCards'; import { theme } from '@/theme'; import { buildRouteMetadata } from '@/lib/seo'; import { styled } from '@linaria/react'; @@ -68,7 +63,6 @@ export default async function PartnerPage({ params }: PartnerPageProps) { getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); return ( @@ -85,21 +79,19 @@ export default async function PartnerPage({ params }: PartnerPageProps) { - + - - {renderText(msg`Become`)} - + {i18n._(msg`Become`)}
    - {renderText(msg`our partner`)} + {i18n._(msg`our partner`)}
    - + + {i18n._( + msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`, + )} + @@ -109,88 +101,68 @@ export default async function PartnerPage({ params }: PartnerPageProps) { - - - - + separator={i18n._(msg`trusted by`)} + logos={TRUSTED_BY_LOGOS} + clientCount={i18n._(msg`+10k others`)} + /> - + - + + + {i18n._(msg`Which partner program is right for you?`)} + + - {renderText(msg`Find the program that fits your business`)} + {i18n._(msg`Find the program that fits your business`)} {' '} - {renderText(msg`and unlock new opportunities with Twenty`)} + {i18n._(msg`and unlock new opportunities with Twenty`)} - {THREE_CARDS_ILLUSTRATION_DATA.body && ( - - )} - + - {renderText(msg`Ready to grow`)} + {i18n._(msg`Ready to grow`)}
    - {renderText(msg`with Twenty?`)} + {i18n._(msg`with Twenty?`)}
    - + + {i18n._( + msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`, + )} + @@ -198,25 +170,25 @@ export default async function PartnerPage({ params }: PartnerPageProps) { - + + + {i18n._(msg`Any Questions?`)} + + - {renderText(msg`Stop fighting custom.`)} + {i18n._(msg`Stop fighting custom.`)}
    - {renderText(msg`Start building, with Twenty`)} + {i18n._(msg`Start building, with Twenty`)}
    - +
    ); diff --git a/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts deleted file mode 100644 index c35eebf9ca..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const SIGNOFF_COPY = { - body: msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts index a0d7bd4a46..1e2f55d995 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts @@ -1,55 +1,38 @@ import { msg } from '@lingui/core/macro'; -import type { TestimonialsDataType } from '@/sections/Testimonials/types'; +import type { TestimonialCardType } from '@/sections/Testimonials'; -export const TESTIMONIALS_DATA: TestimonialsDataType = { - eyebrow: { - heading: { - text: msg`Join our growing partner ecosystem`, - fontFamily: 'sans', +export const PARTNER_TESTIMONIALS: TestimonialCardType[] = [ + { + heading: msg`Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.`, + author: { + name: msg`Benjamin Reynolds`, + designation: msg`Principal and Founder, Alternative Partners`, + avatar: { + src: '/images/partner/testimonials/benjamin-reynolds.webp', + alt: 'Portrait of Benjamin Reynolds', + }, }, }, - testimonials: [ - { - heading: { - text: msg`Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Benjamin Reynolds` }, - designation: { text: msg`Principal and Founder, Alternative Partners` }, - avatar: { - src: '/images/partner/testimonials/benjamin-reynolds.webp', - alt: 'Portrait of Benjamin Reynolds', - }, + { + heading: msg`The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.`, + author: { + name: msg`Bertrams`, + designation: msg`Founder, Wintactix`, + avatar: { + src: '/images/partner/testimonials/bertrams.webp', + alt: 'Portrait of Bertrams', }, }, - { - heading: { - text: msg`The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Bertrams` }, - designation: { text: msg`Founder, Wintactix` }, - avatar: { - src: '/images/partner/testimonials/bertrams.jpeg', - alt: 'Portrait of Bertrams', - }, + }, + { + heading: msg`Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty's client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.`, + author: { + name: msg`Mike Babiy`, + designation: msg`Founder, Nine Dots Ventures`, + avatar: { + src: '/images/partner/testimonials/mike-babiy.webp', + alt: 'Photo featuring Mike Babiy', }, }, - { - heading: { - text: msg`Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty’s client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.`, - fontFamily: 'sans', - }, - author: { - name: { text: msg`Mike Babiy` }, - designation: { text: msg`Founder, Nine Dots Ventures` }, - avatar: { - src: '/images/partner/testimonials/mike-babiy.png', - alt: 'Photo featuring Mike Babiy', - }, - }, - }, - ], -}; + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts index 5ce6cf3b9a..20e840ec07 100644 --- a/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts @@ -1,70 +1,56 @@ import { msg } from '@lingui/core/macro'; -import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types'; +import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards'; -export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = { - eyebrow: { - heading: { - text: msg`Which partner program is right for you?`, - fontFamily: 'sans', +export const PARTNER_ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [ + { + heading: msg`Technology Partners`, + body: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`, + benefits: [ + { text: msg`Co-marketing opportunities`, icon: 'users' }, + { text: msg`Listing on Twenty integrations page`, icon: 'search' }, + { text: msg`Soon: earn revenue`, icon: 'tag' }, + ], + action: { + kind: 'partnerApplication', + label: msg`Become a Technology Partner`, + programId: 'technology', }, + attribution: undefined, + illustration: 'programming', }, - illustrationCards: [ - { - heading: { text: msg`Technology Partners`, fontFamily: 'sans' }, - body: { - text: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`, + { + heading: msg`Content & Community Partners`, + body: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`, + benefits: [ + { text: msg`Revenue share for referred customers`, icon: 'tag' }, + { + text: msg`Exclusive content collaboration opportunities`, + icon: 'edit', }, - benefits: [ - { text: msg`Co-marketing opportunities`, icon: 'users' }, - { text: msg`Listing on Twenty integrations page`, icon: 'search' }, - { text: msg`Soon: earn revenue`, icon: 'tag' }, - ], - action: { - kind: 'partnerApplication', - label: msg`Become a Technology Partner`, - programId: 'technology', - }, - attribution: undefined, - illustration: 'programming', + { text: msg`Marketing assets & brand resources`, icon: 'book' }, + ], + action: { + kind: 'partnerApplication', + label: msg`Become a Content Partner`, + programId: 'content', }, - { - heading: { text: msg`Content & Community Partners`, fontFamily: 'sans' }, - body: { - text: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`, - }, - benefits: [ - { text: msg`Revenue share for referred customers`, icon: 'tag' }, - { - text: msg`Exclusive content collaboration opportunities`, - icon: 'edit', - }, - { text: msg`Marketing assets & brand resources`, icon: 'book' }, - ], - action: { - kind: 'partnerApplication', - label: msg`Become a Content Partner`, - programId: 'content', - }, - attribution: undefined, - illustration: 'connect', + attribution: undefined, + illustration: 'connect', + }, + { + heading: msg`Solutions Partners`, + body: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`, + benefits: [ + { text: msg`Resale discounts & revenue share`, icon: 'tag' }, + { text: msg`Marketplace listing`, icon: 'search' }, + { text: msg`Dedicated partner support`, icon: 'users' }, + ], + action: { + kind: 'partnerApplication', + label: msg`Become a Solution Partner`, + programId: 'solutions', }, - { - heading: { text: msg`Solutions Partners`, fontFamily: 'sans' }, - body: { - text: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`, - }, - benefits: [ - { text: msg`Resale discounts & revenue share`, icon: 'tag' }, - { text: msg`Marketplace listing`, icon: 'search' }, - { text: msg`Dedicated partner support`, icon: 'users' }, - ], - action: { - kind: 'partnerApplication', - label: msg`Become a Solution Partner`, - programId: 'solutions', - }, - attribution: undefined, - illustration: 'grow', - }, - ], -}; + attribution: undefined, + illustration: 'grow', + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts deleted file mode 100644 index b516b399f4..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const ENGAGEMENT_BAND_COPY = { - body: msg`Find the right partner to implement, customize, and tailor Twenty to your team.`, - heading: msg`Need help with customization?`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts deleted file mode 100644 index be0371d4c8..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const HERO_COPY = { - body: msg`Start your free trial today\nwithout credit card.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx index 604aa68569..7a000410fa 100644 --- a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx @@ -1,29 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { FAQ_DATA } from '@/sections/Faq/data'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import { Faq, FAQ_QUESTIONS } from '@/sections/Faq'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication'; -import { ENGAGEMENT_BAND_COPY } from '@/app/[locale]/pricing/engagement-band.data'; -import { HERO_COPY } from '@/app/[locale]/pricing/hero.data'; import { PLAN_TABLE_DATA } from '@/app/[locale]/pricing/plan-table.data'; import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data'; import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { EngagementBand } from '@/sections/EngagementBand/components'; -import { Faq } from '@/sections/Faq/components'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { Plans } from '@/sections/Plans/components'; -import { PricingStateProvider } from '@/sections/Plans/context/PricingStateContext'; -import { PlanTable } from '@/sections/PlanTable/components'; -import { Salesforce } from '@/sections/Salesforce/components'; +import { EngagementBand } from '@/sections/EngagementBand'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Plans, PricingStateProvider } from '@/sections/Plans'; +import { PlanTable } from '@/sections/PlanTable'; +import { Salesforce } from '@/sections/Salesforce'; import { theme } from '@/theme'; import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo'; import { styled } from '@linaria/react'; @@ -51,12 +45,11 @@ export default async function PricingPage({ params }: PricingPageProps) { getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); return ( <> - + i18n._(d))} /> - + - - {renderText(msg`Simple`)} - + {i18n._(msg`Simple`)}
    - - {renderText(msg`Pricing`)} - + {i18n._(msg`Pricing`)}
    - + + {i18n._(msg`Start your free trial today\nwithout credit card.`)} +
    - + - + - - + + + {i18n._(msg`Need help with customization?`)} + + + + {i18n._( + msg`Find the right partner to implement, customize, and tailor Twenty to your team.`, + )} + - + - {renderText(msg`Trust the n°1 CRM,`)} + {i18n._(msg`Trust the n°1 CRM,`)} {' '} - {renderText(msg`or not !`)} + {i18n._(msg`or not !`)} - + + + {i18n._(msg`Any Questions?`)} + + - {renderText(msg`Stop fighting custom.`)} + {i18n._(msg`Stop fighting custom.`)}
    - {renderText(msg`Start building, with Twenty`)} + {i18n._(msg`Start building, with Twenty`)}
    - +
    ); diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts index f944671fa6..d1d0b8a8f8 100644 --- a/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts @@ -1,5 +1,5 @@ import { msg } from '@lingui/core/macro'; -import type { PlanTableDataType } from '@/sections/PlanTable/types'; +import type { PlanTableDataType } from '@/sections/PlanTable'; export const PLAN_TABLE_DATA: PlanTableDataType = { featureColumnLabel: msg`Name`, diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts index 0191b7c68f..962d346a91 100644 --- a/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts @@ -1,12 +1,10 @@ import { msg } from '@lingui/core/macro'; -import type { SalesforceDataType } from '@/sections/Salesforce/types'; +import type { SalesforceDataType } from '@/sections/Salesforce'; const SALESFORCE_POPUP_TITLE = msg`Good choice!`; export const SALESFORCE_DATA: SalesforceDataType = { - body: { - text: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`, - }, + body: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`, pricing: { addons: [ { diff --git a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx index 95ef27f408..ee7adefa36 100644 --- a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx @@ -1,7 +1,7 @@ -import { MENU_DATA } from '@/sections/Menu/data'; +import { MENU_DATA } from '@/sections/Menu'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { LegalDocument } from '@/sections/LegalDocument/components'; +import { LegalDocument } from '@/sections/LegalDocument'; import { buildRouteMetadata } from '@/lib/seo'; import { PrivacyPolicyDocument } from './_components'; diff --git a/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts b/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts index b75f507737..84e19c6995 100644 --- a/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts @@ -1,84 +1,81 @@ import { msg } from '@lingui/core/macro'; -import type { FeatureDataType } from '@/sections/Feature/types'; +import type { ImageType } from '@/design-system/components/Image'; +import type { FeatureTileType } from '@/sections/Feature'; -export const FEATURE_DATA: FeatureDataType = { - eyebrow: { - heading: { - text: msg`Core Features`, - fontFamily: 'sans', - }, - }, - mask: { src: '/images/product/feature/mask.webp', alt: '' }, - tiles: [ - { - icon: 'check', - image: { src: '/images/product/feature/dashboards.webp', alt: '' }, - heading: { text: msg`Reports & Dashboards`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Build custom dashboards` }, - { text: msg`Aggregate, bar, line, pie, and gauge widgets` }, - { text: msg`Filtered metrics from live CRM data` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/tasks.webp', alt: '' }, - heading: { text: msg`Tasks & Activities`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Create tasks from records` }, - { text: msg`Assign owners and due dates` }, - { text: msg`Rich notes attached to records` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/emails.webp', alt: '' }, - heading: { text: msg`Email & Calendar`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Connect Google or Microsoft accounts` }, - { text: msg`Emails and events linked to CRM records` }, - { text: msg`Full communication history in one place` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/contacts.webp', alt: '' }, - heading: { text: msg`Contacts & Companies`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Custom fields and relationships` }, - { text: msg`Unified timeline (emails, events, tasks, notes, files)` }, - { text: msg`Email/calendar activity on each record` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/pipeline.webp', alt: '' }, - heading: { text: msg`Pipeline Management`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Custom deal stages for your process` }, - { text: msg`Drag-and-drop deals between stages` }, - { text: msg`Track amount and close date` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/files.webp', alt: '' }, - heading: { text: msg`Files`, fontFamily: 'sans' }, - bullets: [ - { text: msg`Multi-file upload on records` }, - { text: msg`Rename, download, and delete attachments` }, - { text: msg`In-app preview for supported file types (when enabled)` }, - ], - }, - { - icon: 'check', - image: { src: '/images/product/feature/data.webp', alt: '' }, - heading: { text: msg`Data import`, fontFamily: 'sans' }, - bullets: [ - { text: msg`CSV import flow` }, - { text: msg`Column-to-field mapping (including relations)` }, - { text: msg`CSV export anytime` }, - ], - }, - ], +export const FEATURE_MASK: ImageType = { + src: '/images/product/feature/mask.webp', + alt: '', }; + +export const FEATURE_TILES: FeatureTileType[] = [ + { + icon: 'check', + image: { src: '/images/product/feature/dashboards.webp', alt: '' }, + heading: msg`Reports & Dashboards`, + bullets: [ + msg`Build custom dashboards`, + msg`Aggregate, bar, line, pie, and gauge widgets`, + msg`Filtered metrics from live CRM data`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/tasks.webp', alt: '' }, + heading: msg`Tasks & Activities`, + bullets: [ + msg`Create tasks from records`, + msg`Assign owners and due dates`, + msg`Rich notes attached to records`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/emails.webp', alt: '' }, + heading: msg`Email & Calendar`, + bullets: [ + msg`Connect Google or Microsoft accounts`, + msg`Emails and events linked to CRM records`, + msg`Full communication history in one place`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/contacts.webp', alt: '' }, + heading: msg`Contacts & Companies`, + bullets: [ + msg`Custom fields and relationships`, + msg`Unified timeline (emails, events, tasks, notes, files)`, + msg`Email/calendar activity on each record`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/pipeline.webp', alt: '' }, + heading: msg`Pipeline Management`, + bullets: [ + msg`Custom deal stages for your process`, + msg`Drag-and-drop deals between stages`, + msg`Track amount and close date`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/files.webp', alt: '' }, + heading: msg`Files`, + bullets: [ + msg`Multi-file upload on records`, + msg`Rename, download, and delete attachments`, + msg`In-app preview for supported file types (when enabled)`, + ], + }, + { + icon: 'check', + image: { src: '/images/product/feature/data.webp', alt: '' }, + heading: msg`Data import`, + bullets: [ + msg`CSV import flow`, + msg`Column-to-field mapping (including relations)`, + msg`CSV export anytime`, + ], + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts deleted file mode 100644 index 1c2eeedcd1..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const HERO_COPY = { - body: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/product/page.tsx b/packages/twenty-website-new/src/app/[locale]/product/page.tsx index 357dbb2994..e98269834f 100644 --- a/packages/twenty-website-new/src/app/[locale]/product/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/product/page.tsx @@ -1,14 +1,15 @@ import { msg } from '@lingui/core/macro'; -import { FAQ_DATA } from '@/sections/Faq/data'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data'; -import { TalkToUsButton } from '@/lib/contact-cal'; +import type { AppLocale } from 'twenty-shared/translations'; +import { Faq, FAQ_QUESTIONS } from '@/sections/Faq'; +import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { DEMO_DATA } from '@/app/[locale]/product/demo.data'; -import { FEATURE_DATA } from '@/app/[locale]/product/feature.data'; -import { HERO_COPY } from '@/app/[locale]/product/hero.data'; -import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data'; -import { TABS_DATA } from '@/app/[locale]/product/tabs.data'; -import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data'; +import { + FEATURE_MASK, + FEATURE_TILES, +} from '@/app/[locale]/product/feature.data'; +import { TABS } from '@/app/[locale]/product/tabs.data'; +import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data'; import { Body, Eyebrow, @@ -17,24 +18,28 @@ import { LinkButton, } from '@/design-system/components'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { Faq } from '@/sections/Faq/components'; -import { Feature } from '@/sections/Feature/components'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { Demo } from '@/sections/Demo/components'; -import { ProductStepper } from '@/sections/ProductStepper/components'; -import { Tabs } from '@/sections/Tabs/components'; -import { ThreeCards } from '@/sections/ThreeCards/components'; -import { TrustedBy } from '@/sections/TrustedBy/components'; +import { Feature } from '@/sections/Feature'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Demo } from '@/sections/Demo'; +import { + ProductStepper, + type ProductStepperStepType, +} from '@/sections/ProductStepper'; +import { Tabs } from '@/sections/Tabs'; +import { ThreeCards } from '@/sections/ThreeCards'; import { theme } from '@/theme'; -import { buildRouteMetadata } from '@/lib/seo'; +import { + buildBreadcrumbListJsonLd, + buildRouteMetadata, + JsonLd, +} from '@/lib/seo'; export const generateMetadata = buildRouteMetadata('product'); @@ -47,16 +52,55 @@ export default async function ProductPage({ params }: ProductPageProps) { getRouteI18n(params), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); + const PRODUCT_STEPS: ProductStepperStepType[] = [ + { + icon: 'users', + heading: ( + {i18n._(msg`Data model`)} + ), + body: msg`Add objects and fields`, + image: { + src: '/images/product/stepper/step-one.webp', + alt: 'Twenty data model: add objects and fields', + }, + }, + { + icon: 'check', + heading: ( + {i18n._(msg`Automation`)} + ), + body: msg`Create a workflow`, + image: { + src: '/images/product/stepper/step-two.webp', + alt: 'Twenty automation: create a workflow', + }, + }, + { + icon: 'eye', + heading: ( + {i18n._(msg`Layout`)} + ), + body: msg`Tailor record pages, menus, and views`, + image: { + src: '/images/product/stepper/step-three.webp', + alt: 'Twenty layout: record pages, menus, and views', + }, + }, + ]; + return ( <> - {/* - * Above-the-fold hero scene. Preload kicks off the GLB fetch in - * parallel with the JS chunk download, so the model is already in - * the browser cache by the time Three.js asks for it. - */} + - + - {renderText(msg`A CRM for teams`)} + {i18n._(msg`A CRM for teams`)}
    - - {renderText(msg`that`)} - {' '} - - {renderText(msg`moves fast`)} - + {i18n._(msg`that`)}{' '} + {i18n._(msg`moves fast`)}
    - + + {i18n._( + msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`, + )} +
    - - - - - + - + + + {i18n._(msg`AI & Automation`)} + + - {renderText(msg`AI that actually`)} + {i18n._(msg`AI that actually`)}
    - {renderText(msg`helps you`)} + {i18n._(msg`helps you`)} {' '} - {renderText(msg`work faster`)} + {i18n._(msg`work faster`)}
    - - + + {i18n._(msg`The AI understands your CRM and takes action.`)} + +
    - + - + + + {i18n._(msg`Core Features`)} + + - {renderText(msg`Everything you need,`)} + {i18n._(msg`Everything you need,`)} {' '} - {renderText(msg`out of the box`)} + {i18n._(msg`out of the box`)} - + - + - + + + {i18n._(msg`Stop settling for trade-offs.`)} + + - {renderText(msg`A modern CRM with`)} + {i18n._(msg`A modern CRM with`)} {' '} - {renderText(msg`an intuitive interface`)} + {i18n._(msg`an intuitive interface`)} - {THREE_CARDS_ILLUSTRATION_DATA.body && ( - - )} - + - {renderText(msg`Go the extra mile`)} + {i18n._(msg`Go the extra mile`)} {' '} - - {renderText(msg`with no-code`)} - + {i18n._(msg`with no-code`)} - + + + {i18n._(msg`Try it live`)} + + - {renderText(msg`A demo worth a`)} + {i18n._(msg`A demo worth a`)}
    - {renderText(msg`thousand words`)} + {i18n._(msg`thousand words`)}
    @@ -236,25 +252,25 @@ export default async function ProductPage({ params }: ProductPageProps) { - + + + {i18n._(msg`Any Questions?`)} + + - {renderText(msg`Stop fighting custom.`)} + {i18n._(msg`Stop fighting custom.`)}
    - {renderText(msg`Start building, with Twenty`)} + {i18n._(msg`Start building, with Twenty`)}
    - +
    ); diff --git a/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts deleted file mode 100644 index 390f828f57..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const SIGNOFF_COPY = { - body: msg`Join the teams that chose to own their CRM. Start building with Twenty today.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts b/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts deleted file mode 100644 index cf282a3c2d..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { ProductStepperDataType } from '@/sections/ProductStepper/types'; - -export const STEPPER_DATA: ProductStepperDataType = { - eyebrow: { - heading: { - text: msg`Customization`, - fontFamily: 'sans', - }, - }, - body: { - text: msg`Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.`, - }, - steps: [ - { - icon: 'users', - heading: { - text: msg`Data model`, - fontFamily: 'sans', - }, - body: { - text: msg`Add objects and fields`, - }, - image: { - src: '/images/product/stepper/step-one.webp', - alt: 'Twenty data model: add objects and fields', - }, - }, - { - icon: 'check', - heading: { - text: msg`Automation`, - fontFamily: 'sans', - }, - body: { - text: msg`Create a workflow`, - }, - image: { - src: '/images/product/stepper/step-two.webp', - alt: 'Twenty automation: create a workflow', - }, - }, - { - icon: 'eye', - heading: { - text: msg`Layout`, - fontFamily: 'sans', - }, - body: { - text: msg`Tailor record pages, menus, and views`, - }, - image: { - src: '/images/product/stepper/step-three.webp', - alt: 'Twenty layout: record pages, menus, and views', - }, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts b/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts index 3f56751a5a..f46a5caf4d 100644 --- a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts @@ -1,49 +1,38 @@ import { msg } from '@lingui/core/macro'; -import type { TabType } from '@/sections/Tabs/types'; +import type { TabType } from '@/sections/Tabs'; -export const TABS_DATA: { - eyebrow: { heading: { text: ReturnType; fontFamily: 'sans' } }; - tabs: TabType[]; -} = { - eyebrow: { - heading: { - text: msg`AI & Automation`, - fontFamily: 'sans', +export const TABS: TabType[] = [ + { + body: msg`Show me all deals closing this month`, + icon: 'search', + image: { + src: '/images/product/tabs/deals.webp', + alt: 'Deals view', }, }, - tabs: [ - { - body: msg`Show me all deals closing this month`, - icon: 'search', - image: { - src: '/images/product/tabs/deals.webp', - alt: 'Deals view', - }, + { + body: msg`Create follow-up tasks for my top 10 accounts`, + icon: 'eye', + image: { + src: '/images/product/tabs/tasks.webp', + alt: 'Tasks view', }, - { - body: msg`Create follow-up tasks for my top 10 accounts`, - icon: 'eye', - image: { - src: '/images/product/tabs/tasks.webp', - alt: 'Tasks view', - }, + }, + { + body: msg`Summarize this customer's history`, + icon: 'edit', + image: { + src: '/images/product/tabs/history.webp', + alt: 'History view', }, - { - body: msg`Summarize this customer's history`, - icon: 'edit', - image: { - src: '/images/product/tabs/history.webp', - alt: 'History view', - }, + }, + { + body: msg`Create a workflow that send an email sequence`, + icon: 'check', + image: { + src: '/images/product/tabs/workflow.webp', + alt: 'Workflow view', }, - { - body: msg`Create a workflow that send an email sequence`, - icon: 'check', - image: { - src: '/images/product/tabs/workflow.webp', - alt: 'Workflow view', - }, - }, - ], -}; + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts b/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts index d27dbf2114..78a9ccd7ba 100644 --- a/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts +++ b/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts @@ -1,37 +1,26 @@ import { msg } from '@lingui/core/macro'; -import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types'; +import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards'; -export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = { - eyebrow: { - heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' }, +export const ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [ + { + heading: msg`Built for speed`, + body: msg`Fly through your workspace with shortcuts and short load times.`, + benefits: undefined, + attribution: undefined, + illustration: 'speed', }, - illustrationCards: [ - { - heading: { text: msg`Built for speed`, fontFamily: 'sans' }, - body: { - text: msg`Fly through your workspace with shortcuts and short load times.`, - }, - benefits: undefined, - attribution: undefined, - illustration: 'speed', - }, - { - heading: { text: msg`Real-time data`, fontFamily: 'sans' }, - body: { - text: msg`See updates as they happen. Work with your team and agents seamlessly.`, - }, - benefits: undefined, - attribution: undefined, - illustration: 'eye', - }, - { - heading: { text: msg`Stay in Flow`, fontFamily: 'sans' }, - body: { - text: msg`AI chat, settings, and records in a side panels for fast, single-screen access.`, - }, - benefits: undefined, - attribution: undefined, - illustration: 'singleScreen', - }, - ], -}; + { + heading: msg`Real-time data`, + body: msg`See updates as they happen. Work with your team and agents seamlessly.`, + benefits: undefined, + attribution: undefined, + illustration: 'eye', + }, + { + heading: msg`Stay in Flow`, + body: msg`AI chat, settings, and records in a side panels for fast, single-screen access.`, + benefits: undefined, + attribution: undefined, + illustration: 'singleScreen', + }, +]; diff --git a/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts deleted file mode 100644 index 2adca69373..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const RELEASE_NOTES_HERO_COPY = { - body: msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx index 84381bd6d2..5c34947d2b 100644 --- a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx @@ -1,22 +1,19 @@ import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { RELEASE_NOTES_HERO_COPY } from '@/app/[locale]/releases/hero.data'; import { HeadingPart, LinkButton } from '@/design-system/components'; import { GitHubIcon } from '@/icons'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; import { getRouteI18n, type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; +} from '@/lib/i18n/utils/get-route-i18n'; import { Pages } from '@/lib/pages'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { fetchLatestGithubReleaseTag } from '@/lib/releases/fetch-latest-release-tag'; import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases'; import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes'; -import { Hero } from '@/sections/Hero/components'; -import { Menu } from '@/sections/Menu/components'; -import { ReleaseNotes } from '@/sections/ReleaseNotes/components'; +import { Hero } from '@/sections/Hero'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { ReleaseNotes } from '@/sections/ReleaseNotes'; import { theme } from '@/theme'; import { buildReleaseListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo'; import { Fragment } from 'react'; @@ -34,7 +31,6 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) { fetchLatestGithubReleaseTag(), fetchCommunityStats(), ]); - const renderText = createMessageDescriptorRenderer(i18n); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); const visibleNotes = process.env.NODE_ENV === 'development' @@ -67,27 +63,22 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) { - + - - {renderText(msg`Latest`)} - + {i18n._(msg`Latest`)}
    - - {renderText(msg`Releases`)} - + {i18n._(msg`Releases`)}
    - + + {i18n._( + msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`, + )} + } variant="outlined" /> diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts deleted file mode 100644 index b0fd2a50c0..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData'; - -export const EDITORIAL_FOUR: EditorialDataType = { - eyebrow: { - heading: { - fontFamily: 'sans', - text: msg`What this means`, - }, - }, - body: [ - { - text: msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`, - }, - { - text: msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts deleted file mode 100644 index b4f2d4b5b2..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData'; - -export const EDITORIAL_ONE: EditorialDataType = { - eyebrow: { - heading: { - fontFamily: 'sans', - text: msg`The shift`, - }, - }, - body: [ - { - text: msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`, - }, - { - text: msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts deleted file mode 100644 index 7d287dd520..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData'; - -export const EDITORIAL_THREE: EditorialDataType = { - eyebrow: { - heading: { fontFamily: 'sans', text: msg`The opportunity` }, - }, - body: [ - { - text: msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it\'s live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`, - }, - { - text: msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts deleted file mode 100644 index 4ca1f72b78..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const HERO_COPY = { - body: msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts deleted file mode 100644 index 4f83779490..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { MarqueeDataType } from '@/sections/Marquee/types'; - -export const MARQUEE_DATA: MarqueeDataType = { - heading: [ - { fontFamily: 'serif', text: msg`Same CRM` }, - { fontFamily: 'sans', text: msg`Same output` }, - { fontFamily: 'serif', text: msg`Same results` }, - ], -}; diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx deleted file mode 100644 index a725ab11c4..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import { MENU_DATA } from '@/sections/Menu/data'; -import { EDITORIAL_FOUR } from '@/app/[locale]/resources/why-twenty/editorial-four.data'; -import { EDITORIAL_ONE } from '@/app/[locale]/resources/why-twenty/editorial-one.data'; -import { EDITORIAL_THREE } from '@/app/[locale]/resources/why-twenty/editorial-three.data'; -import { HERO_COPY } from '@/app/[locale]/resources/why-twenty/hero.data'; -import { MARQUEE_DATA } from '@/app/[locale]/resources/why-twenty/marquee.data'; -import { SIGNOFF_COPY } from '@/app/[locale]/resources/why-twenty/signoff.data'; -import { HeadingPart, LinkButton } from '@/design-system/components'; -import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; -import { - getRouteI18n, - type LocaleRouteParams, -} from '@/lib/i18n/get-route-i18n'; -import { Pages } from '@/lib/pages'; -import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { Editorial } from '@/sections/Editorial/components'; -import { Hero } from '@/sections/Hero/components'; -import { Marquee } from '@/sections/Marquee/components'; -import { Menu } from '@/sections/Menu/components'; -import { Signoff } from '@/sections/Signoff/components'; -import { theme } from '@/theme'; -import { buildRouteMetadata } from '@/lib/seo'; -import { css } from '@linaria/core'; - -const editorialOneIntroClass = css` - margin-bottom: ${theme.spacing(4)}; - --editorial-heading-max-width: 760px; - --editorial-intro-max-width: 760px; - - @media (min-width: ${theme.breakpoints.md}px) { - margin-bottom: ${theme.spacing(8)}; - } -`; - -const editorialRightIntroClass = css` - margin-bottom: ${theme.spacing(4)}; - --editorial-heading-max-width: 760px; - --editorial-intro-max-width: 760px; - - @media (min-width: ${theme.breakpoints.md}px) { - align-items: flex-end; - margin-bottom: ${theme.spacing(8)}; - margin-left: auto; - margin-right: 0; - text-align: right; - width: auto; - } -`; - -const crosshairLineColor = theme.colors.secondary.border[10]; - -const sectionCrosshairLeft = { - crossX: '120px', - crossY: '0px', - lineColor: crosshairLineColor, -}; - -const sectionCrosshairRight = { - crossX: 'calc(100% - 120px)', - crossY: '0px', - lineColor: crosshairLineColor, -}; - -export const generateMetadata = buildRouteMetadata('whyTwenty'); - -type WhyTwentyPageProps = { - params: Promise; -}; - -export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) { - const [i18n, stats] = await Promise.all([ - getRouteI18n(params), - fetchCommunityStats(), - ]); - const renderText = createMessageDescriptorRenderer(i18n); - const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); - - return ( - <> - {/* - * Above-the-fold hero scene. Preload kicks off the GLB fetch in - * parallel with the JS chunk download, so the model is already in - * the browser cache by the time Three.js asks for it. - */} - - - - - - - - - - - - {renderText(msg`The future of CRM is built,`)} - {' '} - - {renderText(msg`not bought.`)} - - - - - - - - - - - - {renderText(msg`CRM was a ledger.`)} - {' '} - - {renderText(msg`AI turned it into an operating system.`)} - - - - - - - {/* - - - - */} - - - - - - - {renderText(msg`Differentiation now`)} - {' '} - - {renderText(msg`lives in the code you own.`)} - - - - - - - - - - - - {renderText(msg`Build it in an afternoon.`)} - {' '} - - {renderText(msg`AI made the gap that small.`)} - - - - - - - - - - - - - - {renderText(msg`Build a CRM your competitors`)} - {' '} - - {renderText(msg`can't buy.`)} - - - - - - - - - ); -} diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts deleted file mode 100644 index 7f8efcb4c9..0000000000 --- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { msg } from '@lingui/core/macro'; - -export const SIGNOFF_COPY = { - body: msg`Open-source, AI-ready, and yours to shape.`, -}; diff --git a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx index c8dd6938a9..84bec87683 100644 --- a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx @@ -1,7 +1,7 @@ -import { MENU_DATA } from '@/sections/Menu/data'; +import { MENU_DATA } from '@/sections/Menu'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; -import { LegalDocument } from '@/sections/LegalDocument/components'; +import { LegalDocument } from '@/sections/LegalDocument'; import { buildRouteMetadata } from '@/lib/seo'; import { TermsDocument } from './_components'; diff --git a/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx new file mode 100644 index 0000000000..5f7b1d4eac --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx @@ -0,0 +1,214 @@ +import { msg } from '@lingui/core/macro'; +import { HeadingPart, LinkButton } from '@/design-system/components'; +import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; +import { + getRouteI18n, + type LocaleRouteParams, +} from '@/lib/i18n/utils/get-route-i18n'; +import { Pages } from '@/lib/pages'; +import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; +import { Editorial } from '@/sections/Editorial'; +import { Hero } from '@/sections/Hero'; +import { Marquee } from '@/sections/Marquee'; +import { Menu, MENU_DATA } from '@/sections/Menu'; +import { Signoff } from '@/sections/Signoff'; +import { theme } from '@/theme'; +import { buildRouteMetadata } from '@/lib/seo'; +import { css } from '@linaria/core'; + +const editorialOneIntroClass = css` + margin-bottom: ${theme.spacing(4)}; + --editorial-heading-max-width: 760px; + --editorial-intro-max-width: 760px; + + @media (min-width: ${theme.breakpoints.md}px) { + margin-bottom: ${theme.spacing(8)}; + } +`; + +const editorialRightIntroClass = css` + margin-bottom: ${theme.spacing(4)}; + --editorial-heading-max-width: 760px; + --editorial-intro-max-width: 760px; + + @media (min-width: ${theme.breakpoints.md}px) { + align-items: flex-end; + margin-bottom: ${theme.spacing(8)}; + margin-left: auto; + margin-right: 0; + text-align: right; + width: auto; + } +`; + +const crosshairLineColor = theme.colors.secondary.border[10]; + +const sectionCrosshairLeft = { + crossX: '120px', + crossY: '0px', + lineColor: crosshairLineColor, +}; + +const sectionCrosshairRight = { + crossX: 'calc(100% - 120px)', + crossY: '0px', + lineColor: crosshairLineColor, +}; + +export const generateMetadata = buildRouteMetadata('whyTwenty'); + +type WhyTwentyPageProps = { + params: Promise; +}; + +export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) { + const [i18n, stats] = await Promise.all([ + getRouteI18n(params), + fetchCommunityStats(), + ]); + const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); + + return ( + <> + {/* + * Above-the-fold hero scene. Preload kicks off the GLB fetch in + * parallel with the JS chunk download, so the model is already in + * the browser cache by the time Three.js asks for it. + */} + + + + + + + + + + + + {i18n._(msg`The future of CRM is built,`)} + {' '} + + {i18n._(msg`not bought.`)} + + + + {i18n._( + msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`, + )} + + + + + + + + {i18n._(msg`The shift`)} + + + + {i18n._(msg`CRM was a ledger.`)} + {' '} + + {i18n._(msg`AI turned it into an operating system.`)} + + + + + {i18n._( + msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`, + )} + {i18n._( + msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`, + )} + + + + + + + {i18n._(msg`What this means`)} + + + + {i18n._(msg`Differentiation now`)} + {' '} + + {i18n._(msg`lives in the code you own.`)} + + + + + {i18n._( + msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`, + )} + {i18n._( + msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`, + )} + + + + + + + {i18n._(msg`The opportunity`)} + + + + {i18n._(msg`Build it in an afternoon.`)} + {' '} + + {i18n._(msg`AI made the gap that small.`)} + + + + + {i18n._( + msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it's live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`, + )} + {i18n._( + msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`, + )} + + + + + + + + + {i18n._(msg`Build a CRM your competitors`)} + {' '} + {i18n._(msg`can't buy.`)} + + + {i18n._(msg`Open-source, AI-ready, and yours to shape.`)} + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx index 4753f5f836..84fe95cd90 100644 --- a/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx +++ b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx @@ -1,13 +1,13 @@ 'use client'; import { usePathname } from 'next/navigation'; -import { useEffect, useRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; export function ScrollToTopOnRouteChange() { const pathname = usePathname(); const isInitialRenderRef = useRef(true); - useEffect(() => { + useLayoutEffect(() => { if (isInitialRenderRef.current) { isInitialRenderRef.current = false; return; diff --git a/packages/twenty-website-new/src/app/api/partner-application/route.ts b/packages/twenty-website-new/src/app/api/partner-application/route.ts index 21f801147d..64da364709 100644 --- a/packages/twenty-website-new/src/app/api/partner-application/route.ts +++ b/packages/twenty-website-new/src/app/api/partner-application/route.ts @@ -4,7 +4,7 @@ import { getClientIpKey, readJsonBody, } from '@/lib/api'; -import { splitFullName } from '@/lib/partner-application'; +import { splitFullName } from '@/sections/PartnerApplication'; import { NextResponse } from 'next/server'; import { z } from 'zod'; diff --git a/packages/twenty-website-new/src/app/articles/feed.xml/route.ts b/packages/twenty-website-new/src/app/articles/feed.xml/route.ts new file mode 100644 index 0000000000..7569401a36 --- /dev/null +++ b/packages/twenty-website-new/src/app/articles/feed.xml/route.ts @@ -0,0 +1,48 @@ +import { getPublishedArticles } from '@/lib/articles'; +import { getSiteUrl } from '@/lib/seo/site-url'; + +export function GET() { + const siteUrl = getSiteUrl(); + const articles = getPublishedArticles(); + + const items = articles + .map( + (article) => ` + ${escapeXml(article.title)} + ${siteUrl}/articles/${article.slug} + ${siteUrl}/articles/${article.slug} + ${escapeXml(article.description)} + ${new Date(article.date).toUTCString()} + ${escapeXml(article.author)} + `, + ) + .join('\n'); + + const feed = ` + + + Twenty Articles + ${siteUrl}/articles + Articles and insights from the Twenty team + en + +${items} + +`; + + return new Response(feed, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', + }, + }); +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts index 77ccac9ca1..a21f0dc095 100644 --- a/packages/twenty-website-new/src/app/sitemap.ts +++ b/packages/twenty-website-new/src/app/sitemap.ts @@ -9,6 +9,7 @@ import { } from '@/lib/website-routing'; const SITE_URL = getSiteUrl(); +const BUILD_DATE = new Date().toISOString(); const buildLocalizedUrl = (locale: AppLocale, path: string): string => { const prefix = @@ -40,6 +41,7 @@ const localize = ( ): MetadataRoute.Sitemap => locales.map((locale) => ({ url: buildLocalizedUrl(locale, path), + lastModified: BUILD_DATE, changeFrequency, priority, alternates: { languages: buildLanguageAlternates(path, locales) }, diff --git a/packages/twenty-website-new/src/content/site/asset-paths.ts b/packages/twenty-website-new/src/content/site/asset-paths.ts index ab03b3eb0f..eec689a417 100644 --- a/packages/twenty-website-new/src/content/site/asset-paths.ts +++ b/packages/twenty-website-new/src/content/site/asset-paths.ts @@ -1,81 +1,81 @@ export const SHARED_COMPANY_LOGO_URLS = { - a16z: '/images/shared/companies/logos/a16z.png', - accel: '/images/shared/companies/logos/accel.png', - airbnb: '/images/shared/companies/logos/airbnb.png', - airtable: '/images/shared/companies/logos/airtable.png', - anthropic: '/images/shared/companies/logos/anthropic.png', - apple: '/images/shared/companies/logos/apple.png', - apple1977: '/images/shared/companies/logos/apple-1977.png', - calendar: '/images/shared/companies/logos/calendar.png', - claude: '/images/shared/companies/logos/claude.png', - cursor: '/images/shared/companies/logos/cursor.png', - docusign: '/images/shared/companies/logos/docusign.png', - figma: '/images/shared/companies/logos/figma.png', - foundersFund: '/images/shared/companies/logos/founders-fund.png', - github: '/images/shared/companies/logos/github.png', - gmail: '/images/shared/companies/logos/gmail.png', - google: '/images/shared/companies/logos/google.png', - hubspot: '/images/shared/companies/logos/hubspot.png', - kleinerPerkins: '/images/shared/companies/logos/kleiner-perkins.png', - lemlist: '/images/shared/companies/logos/lemlist.png', + a16z: '/images/shared/companies/logos/a16z.webp', + accel: '/images/shared/companies/logos/accel.webp', + airbnb: '/images/shared/companies/logos/airbnb.webp', + airtable: '/images/shared/companies/logos/airtable.webp', + anthropic: '/images/shared/companies/logos/anthropic.webp', + apple: '/images/shared/companies/logos/apple.webp', + apple1977: '/images/shared/companies/logos/apple-1977.webp', + calendar: '/images/shared/companies/logos/calendar.webp', + claude: '/images/shared/companies/logos/claude.webp', + cursor: '/images/shared/companies/logos/cursor.webp', + docusign: '/images/shared/companies/logos/docusign.webp', + figma: '/images/shared/companies/logos/figma.webp', + foundersFund: '/images/shared/companies/logos/founders-fund.webp', + github: '/images/shared/companies/logos/github.webp', + gmail: '/images/shared/companies/logos/gmail.webp', + google: '/images/shared/companies/logos/google.webp', + hubspot: '/images/shared/companies/logos/hubspot.webp', + kleinerPerkins: '/images/shared/companies/logos/kleiner-perkins.webp', + lemlist: '/images/shared/companies/logos/lemlist.webp', linear: '/images/shared/companies/logos/linear.svg', - linkedin: '/images/shared/companies/logos/linkedin.png', - mailchimp: '/images/shared/companies/logos/mailchimp.png', - meet: '/images/shared/companies/logos/meet.png', - metabase: '/images/shared/companies/logos/metabase.png', - microsoft: '/images/shared/companies/logos/microsoft.png', - notion: '/images/shared/companies/logos/notion.png', - okta: '/images/shared/companies/logos/okta.png', - openai: '/images/shared/companies/logos/openai.png', - outlook: '/images/shared/companies/logos/outlook.png', - outreach: '/images/shared/companies/logos/outreach.png', - postgresql: '/images/shared/companies/logos/postgresql.png', - qonto: '/images/shared/companies/logos/qonto.png', - salesforce: '/images/shared/companies/logos/salesforce.png', - segment: '/images/shared/companies/logos/segment.png', - sequoia: '/images/shared/companies/logos/sequoia.png', - slack: '/images/shared/companies/logos/slack.png', - stripe: '/images/shared/companies/logos/stripe.png', - tally: '/images/shared/companies/logos/tally.png', - twenty: '/images/shared/companies/logos/twenty.png', - whatsapp: '/images/shared/companies/logos/whatsapp.png', - zapier: '/images/shared/companies/logos/zapier.png', + linkedin: '/images/shared/companies/logos/linkedin.webp', + mailchimp: '/images/shared/companies/logos/mailchimp.webp', + meet: '/images/shared/companies/logos/meet.webp', + metabase: '/images/shared/companies/logos/metabase.webp', + microsoft: '/images/shared/companies/logos/microsoft.webp', + notion: '/images/shared/companies/logos/notion.webp', + okta: '/images/shared/companies/logos/okta.webp', + openai: '/images/shared/companies/logos/openai.webp', + outlook: '/images/shared/companies/logos/outlook.webp', + outreach: '/images/shared/companies/logos/outreach.webp', + postgresql: '/images/shared/companies/logos/postgresql.webp', + qonto: '/images/shared/companies/logos/qonto.webp', + salesforce: '/images/shared/companies/logos/salesforce.webp', + segment: '/images/shared/companies/logos/segment.webp', + sequoia: '/images/shared/companies/logos/sequoia.webp', + slack: '/images/shared/companies/logos/slack.webp', + stripe: '/images/shared/companies/logos/stripe.webp', + tally: '/images/shared/companies/logos/tally.webp', + twenty: '/images/shared/companies/logos/twenty.webp', + whatsapp: '/images/shared/companies/logos/whatsapp.webp', + zapier: '/images/shared/companies/logos/zapier.webp', } as const; export const SHARED_PEOPLE_AVATAR_URLS = { - alexandreProt: '/images/shared/people/avatars/alexandre-prot.jpg', - anonymousAnna: '/images/shared/people/avatars/anonymous-anna.jpg', - anonymousFabrice: '/images/shared/people/avatars/anonymous-fabrice.jpg', - anonymousFelix: '/images/shared/people/avatars/anonymous-felix.jpg', - anonymousIndira: '/images/shared/people/avatars/anonymous-indira.jpg', - anonymousLaura: '/images/shared/people/avatars/anonymous-laura.jpg', - anonymousMike: '/images/shared/people/avatars/anonymous-mike.jpg', - anonymousThomas: '/images/shared/people/avatars/anonymous-thomas.jpg', - benChestnut: '/images/shared/people/avatars/ben-chestnut.jpg', - brianChesky: '/images/shared/people/avatars/brian-chesky.jpg', - chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.jpg', - craigFederighi: '/images/shared/people/avatars/craig-federighi.jpg', - darioAmodei: '/images/shared/people/avatars/dario-amodei.jpg', - dylanField: '/images/shared/people/avatars/dylan-field.jpg', - eddyCue: '/images/shared/people/avatars/eddy-cue.jpg', - ivanZhao: '/images/shared/people/avatars/ivan-zhao.jpg', - jeffWilliams: '/images/shared/people/avatars/jeff-williams.jpg', - joeGebbia: '/images/shared/people/avatars/joe-gebbia.jpg', - katherineAdams: '/images/shared/people/avatars/katherine-adams.jpg', - patrickCollison: '/images/shared/people/avatars/patrick-collison.jpg', - pingLi: '/images/shared/people/avatars/ping-li.jpg', - peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.jpg', - peterThiel: '/images/shared/people/avatars/peter-thiel.jpg', - philSchiller: '/images/shared/people/avatars/phil-schiller.jpg', - rayDamm: '/images/shared/people/avatars/ray-damm.jpg', - reidHoffman: '/images/shared/people/avatars/reid-hoffman.jpg', - roelofBotha: '/images/shared/people/avatars/roelof-botha.jpg', - ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.jpg', - steveAnavi: '/images/shared/people/avatars/steve-anavi.jpg', - stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.jpg', - sundarPichai: '/images/shared/people/avatars/sundar-pichai.jpg', - thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.jpg', - timCook: '/images/shared/people/avatars/tim-cook.jpg', + alexandreProt: '/images/shared/people/avatars/alexandre-prot.webp', + anonymousAnna: '/images/shared/people/avatars/anonymous-anna.webp', + anonymousFabrice: '/images/shared/people/avatars/anonymous-fabrice.webp', + anonymousFelix: '/images/shared/people/avatars/anonymous-felix.webp', + anonymousIndira: '/images/shared/people/avatars/anonymous-indira.webp', + anonymousLaura: '/images/shared/people/avatars/anonymous-laura.webp', + anonymousMike: '/images/shared/people/avatars/anonymous-mike.webp', + anonymousThomas: '/images/shared/people/avatars/anonymous-thomas.webp', + benChestnut: '/images/shared/people/avatars/ben-chestnut.webp', + brianChesky: '/images/shared/people/avatars/brian-chesky.webp', + chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.webp', + craigFederighi: '/images/shared/people/avatars/craig-federighi.webp', + darioAmodei: '/images/shared/people/avatars/dario-amodei.webp', + dylanField: '/images/shared/people/avatars/dylan-field.webp', + eddyCue: '/images/shared/people/avatars/eddy-cue.webp', + ivanZhao: '/images/shared/people/avatars/ivan-zhao.webp', + jeffWilliams: '/images/shared/people/avatars/jeff-williams.webp', + joeGebbia: '/images/shared/people/avatars/joe-gebbia.webp', + katherineAdams: '/images/shared/people/avatars/katherine-adams.webp', + patrickCollison: '/images/shared/people/avatars/patrick-collison.webp', + pingLi: '/images/shared/people/avatars/ping-li.webp', + peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.webp', + peterThiel: '/images/shared/people/avatars/peter-thiel.webp', + philSchiller: '/images/shared/people/avatars/phil-schiller.webp', + rayDamm: '/images/shared/people/avatars/ray-damm.webp', + reidHoffman: '/images/shared/people/avatars/reid-hoffman.webp', + roelofBotha: '/images/shared/people/avatars/roelof-botha.webp', + ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.webp', + steveAnavi: '/images/shared/people/avatars/steve-anavi.webp', + stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.webp', + sundarPichai: '/images/shared/people/avatars/sundar-pichai.webp', + thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.webp', + timCook: '/images/shared/people/avatars/tim-cook.webp', } as const; export const SHARED_COMPANY_LOGO_URLS_BY_DOMAIN = { diff --git a/packages/twenty-website-new/src/design-system/components/Body.tsx b/packages/twenty-website-new/src/design-system/components/Body.tsx index 110fae5208..b184fb7fd9 100644 --- a/packages/twenty-website-new/src/design-system/components/Body.tsx +++ b/packages/twenty-website-new/src/design-system/components/Body.tsx @@ -2,10 +2,6 @@ import { theme } from '@/theme'; import { css } from '@linaria/core'; import type { ReactNode } from 'react'; -export type BodyType = { - text: TText; -}; - const bodyClassName = css` color: inherit; margin: 0; @@ -42,7 +38,7 @@ const bodyClassName = css` } &[data-size='sm'] { - color: var(--body-sm-color, ${theme.colors.primary.text[60]}); + color: var(--color-text-muted, ${theme.colors.primary.text[60]}); font-size: ${theme.font.size(4)}; line-height: 1.55; } @@ -70,7 +66,7 @@ const bodyClassName = css` } &[data-variant='body-paragraph'] { - color: var(--body-paragraph-color, ${theme.colors.primary.text[80]}); + color: var(--color-text-muted, ${theme.colors.primary.text[80]}); line-height: 1.55; } `; @@ -81,33 +77,26 @@ export type BodyWeight = 'light' | 'regular' | 'medium'; export type BodySize = 'md' | 'sm' | 'xs'; export type BodyVariant = 'default' | 'body-paragraph'; -type BodyTextRenderer = [TText] extends [ReactNode] - ? { renderText?: (text: TText) => ReactNode } - : { renderText: (text: TText) => ReactNode }; - -export type BodyProps = { +export type BodyProps = { as?: BodyAs; - body: BodyType; + children: ReactNode; family?: BodyFamily; weight?: BodyWeight; size?: BodySize; variant?: BodyVariant; className?: string; -} & BodyTextRenderer; +}; -export function Body({ +export function Body({ as: Tag = 'p', - body, + children, family = 'sans', - renderText, weight = 'regular', size = 'md', variant = 'default', className, -}: BodyProps) { +}: BodyProps) { const rootClassName = [bodyClassName, className].filter(Boolean).join(' '); - const content = - renderText === undefined ? (body.text as ReactNode) : renderText(body.text); return ( ({ data-size={size} data-variant={variant} > - {content} + {children} ); } diff --git a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx index b63e33d8fe..2f59c99d51 100644 --- a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx +++ b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx @@ -1,4 +1,4 @@ -import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType'; +import type { LinkButtonType } from '@/design-system/components/Button/types/link-button-type'; import { styled } from '@linaria/react'; import { BaseButton, diff --git a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx deleted file mode 100644 index 829094fa90..0000000000 --- a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { styled } from '@linaria/react'; -import { - BaseButton, - type BaseButtonProps, - buttonBaseStyles, -} from './BaseButton'; -import type { SubmitButtonType } from './types/SubmitButtonType'; - -const StyledSubmitButton = styled.button` - ${buttonBaseStyles} -`; - -export type SubmitButtonProps = Omit & - SubmitButtonType & { onClick?: () => void }; - -export function SubmitButton({ - color, - label, - leadingIcon, - onClick, - size = 'regular', - variant, -}: SubmitButtonProps) { - return ( - - - - ); -} diff --git a/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts b/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts deleted file mode 100644 index 610819aeaf..0000000000 --- a/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ReactNode } from 'react'; - -export type SubmitButtonType = { label: ReactNode }; diff --git a/packages/twenty-website-new/src/design-system/components/Button/types/LinkButtonType.ts b/packages/twenty-website-new/src/design-system/components/Button/types/link-button-type.ts similarity index 100% rename from packages/twenty-website-new/src/design-system/components/Button/types/LinkButtonType.ts rename to packages/twenty-website-new/src/design-system/components/Button/types/link-button-type.ts diff --git a/packages/twenty-website-new/src/design-system/components/Container.tsx b/packages/twenty-website-new/src/design-system/components/Container.tsx index 2de65bbc6a..c94b3f8023 100644 --- a/packages/twenty-website-new/src/design-system/components/Container.tsx +++ b/packages/twenty-website-new/src/design-system/components/Container.tsx @@ -11,12 +11,18 @@ const ContainerRoot = styled.div` export type ContainerProps = { children: ReactNode; className?: string; + 'data-scheme'?: string; style?: CSSProperties; }; -export function Container({ children, className, style }: ContainerProps) { +export function Container({ + children, + className, + 'data-scheme': dataScheme, + style, +}: ContainerProps) { return ( - + {children} ); diff --git a/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx index a667237841..31a079379a 100644 --- a/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx +++ b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx @@ -1,12 +1,10 @@ -import { Heading, type HeadingType } from '@/design-system/components/Heading'; +import { Heading } from '@/design-system/components/Heading'; import { RectangleFillIcon } from '@/icons'; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; -export type EyebrowType = { heading: HeadingType }; - const EyebrowRow = styled.div` align-items: center; display: flex; @@ -38,31 +36,22 @@ const eyebrowLabelClassName = css` } `; -type EyebrowTextRenderer = [TText] extends [ReactNode] - ? { renderText?: (text: TText) => ReactNode } - : { renderText: (text: TText) => ReactNode }; - -type EyebrowProps = { - heading: HeadingType; - colorScheme: 'primary' | 'secondary'; +export type EyebrowProps = { + children: ReactNode; + colorScheme?: 'primary' | 'secondary'; markerHeight?: number; markerWidth?: number; -} & EyebrowTextRenderer; +}; -export function Eyebrow({ - heading, - colorScheme, +export function Eyebrow({ + children, + colorScheme = 'primary', markerHeight, markerWidth, - renderText, -}: EyebrowProps) { +}: EyebrowProps) { const colorClassName = colorScheme === 'primary' ? eyebrowColorPrimary : eyebrowColorSecondary; const headingClassName = [eyebrowLabelClassName, colorClassName].join(' '); - const headingSegment = { - fontFamily: heading.fontFamily, - text: heading.text, - }; return ( @@ -74,24 +63,9 @@ export function Eyebrow({ width={markerWidth} /> - {renderText === undefined ? ( - - ) : ( - - as="h3" - className={headingClassName} - renderText={renderText} - segments={headingSegment} - size="xs" - weight="medium" - /> - )} + + {children} + ); } diff --git a/packages/twenty-website-new/src/design-system/components/Heading.tsx b/packages/twenty-website-new/src/design-system/components/Heading.tsx index 8bf91aa9de..1eb0d84eb4 100644 --- a/packages/twenty-website-new/src/design-system/components/Heading.tsx +++ b/packages/twenty-website-new/src/design-system/components/Heading.tsx @@ -1,15 +1,7 @@ import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; -import { Fragment, type ReactNode } from 'react'; - -export type HeadingType = { - fontFamily: 'sans' | 'serif' | 'mono'; - text: TText; - fontWeight?: 'light' | 'regular' | 'medium'; - newLine?: boolean; - lineBreakBefore?: boolean; -}; +import type { ReactNode } from 'react'; const headingRootClassName = css` margin: 0; @@ -108,6 +100,11 @@ const StyledSpan = styled.span` } `; +export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +export type HeadingFamily = 'sans' | 'serif' | 'mono'; +export type HeadingWeight = 'light' | 'regular' | 'medium'; +export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; + type HeadingPartProps = { children: ReactNode; fontFamily: HeadingFamily; @@ -126,73 +123,28 @@ export function HeadingPart({ ); } -export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; -export type HeadingFamily = 'sans' | 'serif' | 'mono'; -export type HeadingWeight = 'light' | 'regular' | 'medium'; -export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; - -type HeadingTextRenderer = [TText] extends [ReactNode] - ? { renderText?: (text: TText) => ReactNode } - : { renderText: (text: TText) => ReactNode }; - -export type HeadingProps = { +export type HeadingProps = { as?: HeadingAs; - children?: ReactNode; - inlineSegmentSeparator?: ReactNode; - segments?: HeadingType | HeadingType[]; - weight?: HeadingWeight; - size?: HeadingSize; + children: ReactNode; className?: string; -} & HeadingTextRenderer; + size?: HeadingSize; + weight?: HeadingWeight; +}; -export function Heading({ +export function Heading({ as: Tag = 'h2', children, - inlineSegmentSeparator = ' ', - renderText, - segments, - weight = 'regular', - size = 'md', className, -}: HeadingProps) { + size = 'md', + weight = 'regular', +}: HeadingProps) { const rootClassName = [headingRootClassName, className] .filter(Boolean) .join(' '); - const renderSegmentText = (text: TText) => - renderText === undefined ? (text as ReactNode) : renderText(text); return ( - {children !== undefined ? ( - children - ) : Array.isArray(segments) ? ( - segments.map((segment, index) => { - const lineBreakBefore = - segment.newLine === true || segment.lineBreakBefore === true; - const joinWithPreviousInlineSegment = - index > 0 && lineBreakBefore === false; - - return ( - - {lineBreakBefore ?
    : null} - {joinWithPreviousInlineSegment ? inlineSegmentSeparator : null} - - {renderSegmentText(segment.text)} - -
    - ); - }) - ) : segments !== undefined ? ( - - {renderSegmentText(segments.text)} - - ) : null} + {children}
    ); } diff --git a/packages/twenty-website-new/src/design-system/components/Image.tsx b/packages/twenty-website-new/src/design-system/components/Image.tsx index 4478b281c5..3784b8cbf2 100644 --- a/packages/twenty-website-new/src/design-system/components/Image.tsx +++ b/packages/twenty-website-new/src/design-system/components/Image.tsx @@ -1,5 +1,10 @@ -import NextImage from 'next/image'; +import { css } from '@linaria/core'; import { styled } from '@linaria/react'; +import NextImage from 'next/image'; + +const coverImageClassName = css` + object-fit: cover; +`; export type ImageType = { alt: string; @@ -20,10 +25,10 @@ export function Image({ alt, className, src }: ImageComponentProps) { ); diff --git a/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx b/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx index 9f44093a5a..ef0ea62430 100644 --- a/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx +++ b/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx @@ -117,7 +117,10 @@ export function StepperProgressRail({ data-active={String(isActive)} style={{ backgroundColor: inactiveColor }} > - + {String(index + 1).padStart(2, '0')} diff --git a/packages/twenty-website-new/src/design-system/components/index.ts b/packages/twenty-website-new/src/design-system/components/index.ts index 09cf1255c4..2ac9e37c4a 100644 --- a/packages/twenty-website-new/src/design-system/components/index.ts +++ b/packages/twenty-website-new/src/design-system/components/index.ts @@ -1,10 +1,8 @@ export { Body } from './Body'; export { LinkButton } from './Button/LinkButton'; -export { SubmitButton } from './Button/SubmitButton'; export { Container } from './Container'; export { Eyebrow } from './Eyebrow'; export { Form } from './Form'; -export type { FormFieldProps, FormTextareaProps } from './Form'; export { GuideCrosshair } from './GuideCrosshair'; export { Heading, HeadingPart } from './Heading'; export { IconButton } from './IconButton'; @@ -20,5 +18,4 @@ export type { } from './Layout'; export { MarkdownProse } from './MarkdownProse'; export { Modal } from './Modal'; -export type { ModalRootProps } from './Modal'; export { StepperProgressRail } from './StepperProgressRail'; diff --git a/packages/twenty-website-new/src/icons/clients/act-education.tsx b/packages/twenty-website-new/src/icons/clients/ActEducation.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/act-education.tsx rename to packages/twenty-website-new/src/icons/clients/ActEducation.tsx diff --git a/packages/twenty-website-new/src/icons/clients/alternative-partners.tsx b/packages/twenty-website-new/src/icons/clients/AlternativePartners.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/alternative-partners.tsx rename to packages/twenty-website-new/src/icons/clients/AlternativePartners.tsx diff --git a/packages/twenty-website-new/src/icons/clients/elevate-consulting.tsx b/packages/twenty-website-new/src/icons/clients/ElevateConsulting.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/elevate-consulting.tsx rename to packages/twenty-website-new/src/icons/clients/ElevateConsulting.tsx diff --git a/packages/twenty-website-new/src/icons/clients/netzero.tsx b/packages/twenty-website-new/src/icons/clients/Netzero.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/netzero.tsx rename to packages/twenty-website-new/src/icons/clients/Netzero.tsx diff --git a/packages/twenty-website-new/src/icons/clients/nine-dots.tsx b/packages/twenty-website-new/src/icons/clients/NineDots.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/nine-dots.tsx rename to packages/twenty-website-new/src/icons/clients/NineDots.tsx diff --git a/packages/twenty-website-new/src/icons/clients/w3villa.tsx b/packages/twenty-website-new/src/icons/clients/W3Villa.tsx similarity index 100% rename from packages/twenty-website-new/src/icons/clients/w3villa.tsx rename to packages/twenty-website-new/src/icons/clients/W3Villa.tsx diff --git a/packages/twenty-website-new/src/icons/clients/index.ts b/packages/twenty-website-new/src/icons/clients/index.ts index f311b32108..bf4434cdba 100644 --- a/packages/twenty-website-new/src/icons/clients/index.ts +++ b/packages/twenty-website-new/src/icons/clients/index.ts @@ -1,18 +1,18 @@ import type { ComponentType } from 'react'; -import { ActEducationIcon } from './act-education'; +import { ActEducationIcon } from './ActEducation'; import type { ClientIconProps } from './client-icon-props'; -import { AlternativePartnersIcon } from './alternative-partners'; -import { ElevateConsultingIcon } from './elevate-consulting'; -import { NetZeroIcon } from './netzero'; -import { NineDotsIcon } from './nine-dots'; -import { W3villaIcon } from './w3villa'; +import { AlternativePartnersIcon } from './AlternativePartners'; +import { ElevateConsultingIcon } from './ElevateConsulting'; +import { NetZeroIcon } from './Netzero'; +import { NineDotsIcon } from './NineDots'; +import { W3villaIcon } from './W3Villa'; -export { ActEducationIcon } from './act-education'; -export { AlternativePartnersIcon } from './alternative-partners'; -export { ElevateConsultingIcon } from './elevate-consulting'; -export { NetZeroIcon } from './netzero'; -export { NineDotsIcon } from './nine-dots'; -export { W3villaIcon } from './w3villa'; +export { ActEducationIcon } from './ActEducation'; +export { AlternativePartnersIcon } from './AlternativePartners'; +export { ElevateConsultingIcon } from './ElevateConsulting'; +export { NetZeroIcon } from './Netzero'; +export { NineDotsIcon } from './NineDots'; +export { W3villaIcon } from './W3Villa'; export type { ClientIconProps } from './client-icon-props'; export const CLIENT_ICONS: Record> = { diff --git a/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts index d0db2df347..a758046b3f 100644 --- a/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts +++ b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts @@ -1,10 +1,10 @@ -export type AnimationFrameLoopController = { +type AnimationFrameLoopController = { isRunning: () => boolean; start: () => void; stop: () => void; }; -export type CreateAnimationFrameLoopOptions = { +type CreateAnimationFrameLoopOptions = { cancelAnimationFrame?: (handle: number) => void; onFrame: (timestamp: number) => boolean | void; requestAnimationFrame?: (callback: FrameRequestCallback) => number; diff --git a/packages/twenty-website-new/src/lib/animation/index.ts b/packages/twenty-website-new/src/lib/animation/index.ts index 8ee2cdaf0e..f96d2fdc28 100644 --- a/packages/twenty-website-new/src/lib/animation/index.ts +++ b/packages/twenty-website-new/src/lib/animation/index.ts @@ -1,17 +1,2 @@ -export { - createAnimationFrameLoop, - type AnimationFrameLoopController, - type CreateAnimationFrameLoopOptions, -} from './animation-frame-loop'; -export { - DEFAULT_ANIMATED_NUMBER_DURATION_MS, - easeOutCubic, - getAnimatedNumberValue, - type AnimatedNumberEasing, - type AnimatedNumberRounder, - type GetAnimatedNumberValueParameters, -} from './animated-number'; -export { - useAnimatedNumber, - type UseAnimatedNumberOptions, -} from './use-animated-number'; +export { createAnimationFrameLoop } from './animation-frame-loop'; +export { useAnimatedNumber } from './use-animated-number'; diff --git a/packages/twenty-website-new/src/lib/animation/use-animated-number.ts b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts index b88fde7d68..ea2b582bcf 100644 --- a/packages/twenty-website-new/src/lib/animation/use-animated-number.ts +++ b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts @@ -13,7 +13,7 @@ import { } from './animated-number'; import { createAnimationFrameLoop } from './animation-frame-loop'; -export type UseAnimatedNumberOptions = { +type UseAnimatedNumberOptions = { disabled?: boolean; durationMs?: number; easing?: AnimatedNumberEasing; diff --git a/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts b/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts index bc7bd68965..af82a9444a 100644 --- a/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts +++ b/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts @@ -1,4 +1,4 @@ -export type FetchWithTimeoutResult = +type FetchWithTimeoutResult = | { ok: true; response: Response } | { ok: false; error: 'timeout' | 'network' }; diff --git a/packages/twenty-website-new/src/lib/api/index.ts b/packages/twenty-website-new/src/lib/api/index.ts index c4ae4b8e27..0d2acb3d2b 100644 --- a/packages/twenty-website-new/src/lib/api/index.ts +++ b/packages/twenty-website-new/src/lib/api/index.ts @@ -1,8 +1,3 @@ export { fetchWithTimeout } from './fetch-with-timeout'; -export type { FetchWithTimeoutResult } from './fetch-with-timeout'; - export { readJsonBody } from './read-json-body'; -export type { ReadJsonBodyResult } from './read-json-body'; - export { createRateLimiter, getClientIpKey } from './rate-limit'; -export type { RateLimitOptions, RateLimitResult } from './rate-limit'; diff --git a/packages/twenty-website-new/src/lib/api/read-json-body.ts b/packages/twenty-website-new/src/lib/api/read-json-body.ts index 43f726e26d..898d279714 100644 --- a/packages/twenty-website-new/src/lib/api/read-json-body.ts +++ b/packages/twenty-website-new/src/lib/api/read-json-body.ts @@ -1,4 +1,4 @@ -export type ReadJsonBodyResult = +type ReadJsonBodyResult = | { ok: true; value: T } | { ok: false; error: 'invalid-json' | 'too-large' | 'wrong-content-type' }; diff --git a/packages/twenty-website-new/src/lib/articles/format-article-date.ts b/packages/twenty-website-new/src/lib/articles/format-article-date.ts index 98be14be41..5455daaa57 100644 --- a/packages/twenty-website-new/src/lib/articles/format-article-date.ts +++ b/packages/twenty-website-new/src/lib/articles/format-article-date.ts @@ -1,13 +1,15 @@ -export function formatArticleDate(dateString: string): string { - const date = new Date(dateString); +const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', +}); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid article date: ${dateString}`); +export function formatArticleDate(date: Date | string): string { + const resolved = typeof date === 'string' ? new Date(date) : date; + + if (Number.isNaN(resolved.getTime())) { + throw new Error(`Invalid article date: ${String(date)}`); } - return new Intl.DateTimeFormat('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }).format(date); + return DATE_FORMATTER.format(resolved); } diff --git a/packages/twenty-website-new/src/lib/articles/index.ts b/packages/twenty-website-new/src/lib/articles/index.ts index 28d4055988..4a34cb7438 100644 --- a/packages/twenty-website-new/src/lib/articles/index.ts +++ b/packages/twenty-website-new/src/lib/articles/index.ts @@ -2,7 +2,5 @@ export { formatArticleDate } from './format-article-date'; export { getPublishedArticle, getPublishedArticles, - loadLocalArticles, - loadLocalArticlesFromDirectory, } from './load-local-articles'; export type { Article } from './types'; diff --git a/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts b/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts index 83eac66d85..d5c0f4cf4d 100644 --- a/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts +++ b/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts @@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro'; import type { CaseStudyCatalogEntry } from '@/lib/customers/types'; import { theme } from '@/theme'; -const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg'; +const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.webp'; export const CASE_STUDY_HALFTONE_PALETTE: readonly { dashColor: string; @@ -53,12 +53,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ }, hero: { readingTime: '9 min', - title: [ - { text: msg`A real estate agency on WhatsApp`, fontFamily: 'serif' }, - { text: msg`built a CRM around it`, fontFamily: 'sans' }, - ], + title: msg`A real estate agency on WhatsApp built a CRM around it`, author: 'Mike Babiy & Azmat Parveen', - authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png', + authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp', clientIcon: 'nine-dots', heroImageSrc: PLACEHOLDER_HERO, }, @@ -79,10 +76,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ ], hero: { readingTime: '7 min', - title: [ - { text: msg`From Salesforce to`, fontFamily: 'serif' }, - { text: msg`self-hosted Twenty`, fontFamily: 'sans' }, - ], + title: msg`From Salesforce to self-hosted Twenty`, author: 'Benjamin Reynolds', authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp', clientIcon: 'alternative-partners', @@ -105,12 +99,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ ], hero: { readingTime: '8 min', - title: [ - { text: msg`A CRM that`, fontFamily: 'serif' }, - { text: msg`grows with you`, fontFamily: 'sans' }, - ], + title: msg`A CRM that grows with you`, author: 'Olivier Reinaud', - authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg', + authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp', clientIcon: 'netzero', heroImageSrc: PLACEHOLDER_HERO, }, @@ -128,12 +119,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }], hero: { readingTime: '7 min', - title: [ - { text: msg`A CRM they`, fontFamily: 'serif' }, - { text: msg`actually own`, fontFamily: 'sans' }, - ], + title: msg`A CRM they actually own`, author: 'Joseph Chiang', - authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.jpg', + authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp', clientIcon: 'act-education', heroImageSrc: PLACEHOLDER_HERO, }, @@ -151,10 +139,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }], hero: { readingTime: '8 min', - title: [ - { text: msg`When your CRM`, fontFamily: 'serif' }, - { text: msg`is the product`, fontFamily: 'sans' }, - ], + title: msg`When your CRM is the product`, author: 'Amrendra Pratap Singh', authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp', clientIcon: 'w3villa', @@ -183,10 +168,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [ }, hero: { readingTime: '8 min', - title: [ - { text: msg`Twenty as the API backbone`, fontFamily: 'serif' }, - { text: msg`of a go-to-market stack`, fontFamily: 'sans' }, - ], + title: msg`Twenty as the API backbone of a go-to-market stack`, author: 'Justin Beadle', clientIcon: 'elevate-consulting', heroImageSrc: PLACEHOLDER_HERO, diff --git a/packages/twenty-website-new/src/lib/customers/types.ts b/packages/twenty-website-new/src/lib/customers/types.ts index 5b71853a64..52ff42b490 100644 --- a/packages/twenty-website-new/src/lib/customers/types.ts +++ b/packages/twenty-website-new/src/lib/customers/types.ts @@ -1,10 +1,10 @@ -import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment'; import type { MessageDescriptor } from '@lingui/core'; +import type { ReactNode } from 'react'; export type CaseStudyTextBlock = { type: 'text'; eyebrow?: MessageDescriptor; - heading: MessageHeadingSegment[]; + heading: ReactNode; paragraphs: MessageDescriptor[]; callout?: string; }; @@ -32,7 +32,7 @@ export type CaseStudyData = { meta: { title: MessageDescriptor; description: MessageDescriptor }; hero: { readingTime: string; - title: MessageHeadingSegment[]; + title: ReactNode; author: string; authorAvatarSrc?: string; clientIcon: string; @@ -57,6 +57,13 @@ export type CaseStudyCatalogEntry = { kpis: CaseStudyKpi[]; authorRole: MessageDescriptor; quote?: CaseStudyQuote; - hero: CaseStudyData['hero']; + hero: { + readingTime: string; + title: MessageDescriptor; + author: string; + authorAvatarSrc?: string; + clientIcon: string; + heroImageSrc: string; + }; catalogCard: CaseStudyData['catalogCard']; }; diff --git a/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts b/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts index ae105941f8..6e07aa7dfa 100644 --- a/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts +++ b/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts @@ -1,12 +1,12 @@ import * as crypto from 'crypto'; -export type EnterpriseKeyPayload = { +type EnterpriseKeyPayload = { sub: string; licensee: string; iat: number; }; -export type EnterpriseValidityPayload = { +type EnterpriseValidityPayload = { sub: string; status: 'valid'; iat: number; @@ -32,7 +32,7 @@ const getValidityTokenDurationDays = (): number => { return parsed; }; -export type SignValidityTokenOptions = { +type SignValidityTokenOptions = { subscriptionCancelAt: number | null; }; @@ -61,18 +61,11 @@ const getPrivateKey = (): string => { }; const base64UrlEncode = (data: string): string => { - return Buffer.from(data) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + return Buffer.from(data).toString('base64url'); }; const base64UrlDecode = (data: string): string => { - const padded = data + '='.repeat((4 - (data.length % 4)) % 4); - const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); - - return Buffer.from(base64, 'base64').toString('utf-8'); + return Buffer.from(data, 'base64url').toString('utf-8'); }; const signJwt = ( @@ -89,10 +82,7 @@ const signJwt = ( key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING, }) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .toString('base64url'); return `${signingInput}.${signature}`; }; @@ -111,11 +101,7 @@ export const verifyJwt = >( const [encodedHeader, encodedPayload, signature] = parts; const signingInput = `${encodedHeader}.${encodedPayload}`; - const signatureBuffer = Buffer.from( - signature.replace(/-/g, '+').replace(/_/g, '/') + - '='.repeat((4 - (signature.length % 4)) % 4), - 'base64', - ); + const signatureBuffer = Buffer.from(signature, 'base64url'); const isValid = crypto.verify( 'sha256', diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts similarity index 97% rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts rename to packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts index 0003e1e3db..102d9c3c15 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts @@ -3,14 +3,14 @@ import { generateStandaloneHtml, parseExportedPreset, type ReactExportSettings, -} from '@/app/[locale]/halftone/_lib/exporters'; -import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames'; -import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint'; +} from '@/lib/halftone/utils/exporters'; +import { resolveExportArtifactNames } from '@/lib/halftone/utils/export-names'; +import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/utils/footprint'; import { DEFAULT_HALFTONE_SETTINGS, normalizeHalftoneStudioSettings, type HalftoneGeometrySpec, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; const IMPORTED_GLB_SHAPE: HalftoneGeometrySpec = { key: 'userUpload_connect', diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts index 3cb1d59761..f36a66676d 100644 --- a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts @@ -1,10 +1,10 @@ import { - HALFTONE_FOOTPRINT_RUNTIME_SOURCE, getContainedImageRect, getImageFootprintScale, getMeshFootprintScale, REFERENCE_PREVIEW_DISTANCE, -} from '@/lib/halftone/footprint'; +} from '@/lib/halftone/utils/footprint'; +import { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from '@/lib/halftone/generated/footprint-runtime-source'; import * as THREE from 'three'; describe('halftone footprint helpers', () => { diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts index 6d499a6732..b8c61af68f 100644 --- a/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts @@ -1,9 +1,9 @@ -import { DEFAULT_HALFTONE_SETTINGS } from '../state'; +import { DEFAULT_HALFTONE_SETTINGS } from '../utils/state'; import { applySpringStep, createHalftoneInteractionState, resetHalftoneInteractionState, -} from '../interaction-state'; +} from '../utils/interaction-state'; describe('halftone interaction state', () => { it('hydrates pose fields while defaulting transient pointer state', () => { diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts index 82c3ca8412..f571cf1b5b 100644 --- a/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts +++ b/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_SOLID_LIGHTING_SETTINGS, normalizeHalftoneStudioSettings, type HalftoneStudioSettings, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; describe('halftone studio state defaults', () => { it('keeps the default scene aligned with the solid material preset', () => { diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx similarity index 89% rename from packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx index 894e6cf0a9..cd5f2e292e 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx @@ -6,7 +6,7 @@ import { getMeshFootprintScale, type HalftoneImageFit, VIRTUAL_RENDER_HEIGHT, -} from '@/lib/halftone/footprint'; +} from '@/lib/halftone/utils/footprint'; import { observeElementSize } from '@/lib/dom/observe-element-size'; import { applyHalftoneMaterialSettings, @@ -16,18 +16,18 @@ import { type HalftoneMaterialAssets, type HalftoneTransmissionMaterial, renderHalftoneMaterialScene, -} from '@/lib/halftone/materials'; +} from '@/lib/halftone/utils/materials'; import { applySpringStep, createHalftoneInteractionState, resetHalftoneInteractionState, type HalftoneInteractionState, -} from '@/lib/halftone/interaction-state'; +} from '@/lib/halftone/utils/interaction-state'; import type { HalftoneExportPose, HalftoneStudioSettings, -} from '@/lib/halftone/state'; -import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks'; +} from '@/lib/halftone/utils/state'; +import { runCleanupTasks } from '@/lib/react/run-cleanup-tasks'; import { useLatestRef } from '@/lib/react'; import { createSiteWebGlRenderer, @@ -37,258 +37,20 @@ import { } from '@/lib/visual-runtime'; import { styled } from '@linaria/react'; import { type MutableRefObject, useEffect, useRef } from 'react'; +import { + blurFragmentShader, + halftoneFragmentShader, + imagePassthroughFragmentShader, + passThroughVertexShader, +} from '../utils/exporter-shaders'; +import { + IMAGE_HOVER_FADE_IN, + IMAGE_HOVER_FADE_OUT, + IMAGE_POINTER_FOLLOW, + IMAGE_POINTER_VELOCITY_DAMPING, +} from '../constants/halftone-image-constants'; import * as THREE from 'three'; -const passThroughVertexShader = ` - varying vec2 vUv; - - void main() { - vUv = uv; - gl_Position = vec4(position, 1.0); - } -`; - -const blurFragmentShader = ` - precision highp float; - - uniform sampler2D tInput; - uniform vec2 dir; - uniform vec2 res; - - varying vec2 vUv; - - void main() { - vec4 sum = vec4(0.0); - vec2 px = dir / res; - - float w[5]; - w[0] = 0.227027; - w[1] = 0.1945946; - w[2] = 0.1216216; - w[3] = 0.054054; - w[4] = 0.016216; - - sum += texture2D(tInput, vUv) * w[0]; - - for (int i = 1; i < 5; i++) { - float fi = float(i) * 3.0; - sum += texture2D(tInput, vUv + px * fi) * w[i]; - sum += texture2D(tInput, vUv - px * fi) * w[i]; - } - - gl_FragColor = sum; - } -`; - -const imagePassthroughFragmentShader = ` - precision highp float; - - uniform sampler2D tImage; - uniform vec2 imageSize; - uniform vec2 viewportSize; - uniform float zoom; - uniform float contrast; - uniform float imageFit; - - varying vec2 vUv; - - void main() { - float imageAspect = imageSize.x / imageSize.y; - float viewAspect = viewportSize.x / viewportSize.y; - - vec2 uv = vUv; - - if (imageAspect > viewAspect) { - float scale = viewAspect / imageAspect; - if (imageFit > 0.5) { - uv.x = (uv.x - 0.5) * scale + 0.5; - } else { - uv.y = (uv.y - 0.5) / scale + 0.5; - } - } else { - float scale = imageAspect / viewAspect; - if (imageFit > 0.5) { - uv.y = (uv.y - 0.5) * scale + 0.5; - } else { - uv.x = (uv.x - 0.5) / scale + 0.5; - } - } - - uv = (uv - 0.5) / zoom + 0.5; - - float inBounds = step(0.0, uv.x) * step(uv.x, 1.0) - * step(0.0, uv.y) * step(uv.y, 1.0); - - vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0)); - vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0); - - gl_FragColor = vec4(contrastColor, inBounds); - } -`; - -const halftoneFragmentShader = ` - precision highp float; - - uniform sampler2D tScene; - uniform sampler2D tGlow; - uniform vec2 effectResolution; - uniform vec2 logicalResolution; - uniform float tile; - uniform float s_3; - uniform float s_4; - uniform float applyToDarkAreas; - uniform vec3 dashColor; - uniform vec3 hoverDashColor; - uniform float time; - uniform float waveAmount; - uniform float waveSpeed; - uniform float footprintScale; - uniform vec2 interactionUv; - uniform vec2 interactionVelocity; - uniform vec2 dragOffset; - uniform float hoverHalftoneActive; - uniform float hoverHalftonePowerShift; - uniform float hoverHalftoneRadius; - uniform float hoverHalftoneWidthShift; - uniform float hoverLightStrength; - uniform float hoverLightRadius; - uniform float hoverFlowStrength; - uniform float hoverFlowRadius; - uniform float dragFlowStrength; - uniform float cropToBounds; - - varying vec2 vUv; - - float distSegment(in vec2 p, in vec2 a, in vec2 b) { - vec2 pa = p - a; - vec2 ba = b - a; - float denom = max(dot(ba, ba), 0.000001); - float h = clamp(dot(pa, ba) / denom, 0.0, 1.0); - return length(pa - ba * h); - } - - float lineSimpleEt(in vec2 p, in float r, in float thickness) { - vec2 a = vec2(0.5) + vec2(-r, 0.0); - vec2 b = vec2(0.5) + vec2(r, 0.0); - float distToSegment = distSegment(p, a, b); - float halfThickness = thickness * r; - return distToSegment - halfThickness; - } - - void main() { - if (cropToBounds > 0.5) { - vec4 boundsCheck = texture2D(tScene, vUv); - if (boundsCheck.a < 0.01) { - gl_FragColor = vec4(0.0); - return; - } - } - - vec2 fragCoord = - (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution; - float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0); - vec2 pointerPx = interactionUv * logicalResolution; - vec2 fragDelta = fragCoord - pointerPx; - float fragDist = length(fragDelta); - vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); - float velocityMagnitude = length(interactionVelocity); - vec2 motionDir = velocityMagnitude > 0.001 - ? interactionVelocity / velocityMagnitude - : vec2(0.0, 0.0); - float motionBias = velocityMagnitude > 0.001 - ? dot(-radialDir, motionDir) * 0.5 + 0.5 - : 0.5; - - float hoverLightMask = 0.0; - if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * logicalResolution.y; - hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); - } - - float hoverHalftoneMask = 0.0; - if (hoverHalftoneActive > 0.0) { - float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y; - hoverHalftoneMask = - smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) * - clamp(hoverHalftoneActive, 0.0, 1.0); - } - - float hoverFlowMask = 0.0; - if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * logicalResolution.y; - hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); - } - - vec2 hoverDisplacement = - radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 + - motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15; - vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45; - vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement; - - float bandRow = floor(effectCoord.y / halftoneSize); - float waveOffset = - waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize; - effectCoord.x += waveOffset; - - vec2 cellIndex = floor(effectCoord / halftoneSize); - vec2 sampleUv = clamp( - (cellIndex + 0.5) * halftoneSize / logicalResolution, - vec2(0.0), - vec2(1.0) - ); - vec2 cellUv = fract(effectCoord / halftoneSize); - - vec4 sceneSample = texture2D(tScene, sampleUv); - float mask = smoothstep(0.02, 0.08, sceneSample.a); - float localPower = clamp( - s_3 + hoverHalftonePowerShift * hoverHalftoneMask, - -1.5, - 1.5 - ); - float localWidth = clamp( - s_4 + hoverHalftoneWidthShift * hoverHalftoneMask, - 0.05, - 1.4 - ); - float lightLift = - hoverLightStrength * - hoverLightMask * - mix(0.78, 1.18, motionBias) * - 0.22; - float toneValue = - (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0); - if (applyToDarkAreas > 0.5) { - toneValue = 1.0 - toneValue; - } - // Preserve the pre-toneTarget light-mode response by keeping the power - // bias inside the averaged tone calculation. - float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0); - float bandRadius = clamp( - toneValue + powerBias + lightLift, - 0.0, - 1.0 - ) * 1.86 * 0.5; - - float alpha = 0.0; - if (bandRadius > 0.0001) { - float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth); - float edge = 0.02; - alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; - } - - vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask); - vec3 color = activeDashColor * alpha; - gl_FragColor = vec4(color, alpha); - - #include - #include - } -`; - -const IMAGE_POINTER_FOLLOW = 0.38; -const IMAGE_POINTER_VELOCITY_DAMPING = 0.82; -const IMAGE_HOVER_FADE_IN = 18; -const IMAGE_HOVER_FADE_OUT = 7; const MAX_PREVIEW_PIXEL_RATIO = 2; const CanvasMount = styled.div<{ $background: string; $transparent: boolean }>` diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx similarity index 95% rename from packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx index 67eba9dd8e..d9abbfcea2 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx @@ -15,9 +15,12 @@ import { HalftoneCanvas, type HalftoneImageInteractionSettings, type HalftoneSnapshotFn, -} from './halftone-canvas'; -import type { HalftoneExportPose, HalftoneStudioSettings } from './state'; -import type { HalftoneImageFit } from './footprint'; +} from './HalftoneCanvas'; +import type { + HalftoneExportPose, + HalftoneStudioSettings, +} from '../utils/state'; +import type { HalftoneImageFit } from '../utils/footprint'; type HalftoneImageCanvasProps = { crossOrigin?: HTMLImageElement['crossOrigin']; diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx similarity index 97% rename from packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx index 3935ccb566..4fdafab460 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx @@ -13,17 +13,17 @@ import { createFallbackGeometry, loadImportedGeometryFromUrl, type ImportedGeometryNormalizationOptions, -} from './geometry-registry'; +} from '../utils/geometry-registry'; import { HalftoneCanvas, type HalftoneRenderStrategy, type HalftoneSnapshotFn, -} from './halftone-canvas'; +} from './HalftoneCanvas'; import type { HalftoneExportPose, HalftoneModelLoader, HalftoneStudioSettings, -} from './state'; +} from '../utils/state'; type HalftoneModelCanvasProps = { geometryOptions?: ImportedGeometryNormalizationOptions; diff --git a/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts b/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts new file mode 100644 index 0000000000..02b87d5d87 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts @@ -0,0 +1,4 @@ +export const IMAGE_POINTER_FOLLOW = 0.38; +export const IMAGE_POINTER_VELOCITY_DAMPING = 0.82; +export const IMAGE_HOVER_FADE_IN = 18; +export const IMAGE_HOVER_FADE_OUT = 7; diff --git a/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts b/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts new file mode 100644 index 0000000000..905282b5d1 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts @@ -0,0 +1,248 @@ +// Untyped JS mirror of the footprint helpers in ../footprint.ts. +// Injected into exported standalone HTML. Keep in sync — validated by +// __tests__/footprint.test.ts which evals this string and compares results. +import { + MIN_FOOTPRINT_SCALE, + REFERENCE_PREVIEW_DISTANCE, +} from '../utils/footprint'; + +export const HALFTONE_FOOTPRINT_RUNTIME_SOURCE = ` +const REFERENCE_PREVIEW_DISTANCE = ${REFERENCE_PREVIEW_DISTANCE}; +const MIN_FOOTPRINT_SCALE = ${MIN_FOOTPRINT_SCALE}; + +function clampRectToViewport(rect, viewportWidth, viewportHeight) { + const minX = Math.max(rect.x, 0); + const minY = Math.max(rect.y, 0); + const maxX = Math.min(rect.x + rect.width, viewportWidth); + const maxY = Math.min(rect.y + rect.height, viewportHeight); + + if (maxX <= minX || maxY <= minY) { + return null; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +function getRectArea(rect) { + if (!rect) { + return 0; + } + + return Math.max(rect.width, 0) * Math.max(rect.height, 0); +} + +function createBox3Corners(bounds) { + const { min, max } = bounds; + + return [ + new THREE.Vector3(min.x, min.y, min.z), + new THREE.Vector3(min.x, min.y, max.z), + new THREE.Vector3(min.x, max.y, min.z), + new THREE.Vector3(min.x, max.y, max.z), + new THREE.Vector3(max.x, min.y, min.z), + new THREE.Vector3(max.x, min.y, max.z), + new THREE.Vector3(max.x, max.y, min.z), + new THREE.Vector3(max.x, max.y, max.z), + ]; +} + +function getImagePreviewZoom(previewDistance) { + return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); +} + +function getContainedImageRect({ + imageFit = 'contain', + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom, +}) { + if ( + imageWidth <= 0 || + imageHeight <= 0 || + viewportWidth <= 0 || + viewportHeight <= 0 + ) { + return null; + } + + const imageAspect = imageWidth / imageHeight; + const viewAspect = viewportWidth / viewportHeight; + + let fittedWidth = viewportWidth; + let fittedHeight = viewportHeight; + + if (imageAspect > viewAspect) { + if (imageFit === 'cover') { + fittedWidth = viewportHeight * imageAspect; + } else { + fittedHeight = viewportWidth / imageAspect; + } + } else { + if (imageFit === 'cover') { + fittedHeight = viewportWidth / imageAspect; + } else { + fittedWidth = viewportHeight * imageAspect; + } + } + + const scaledWidth = fittedWidth * zoom; + const scaledHeight = fittedHeight * zoom; + + return clampRectToViewport( + { + x: (viewportWidth - scaledWidth) * 0.5, + y: (viewportHeight - scaledHeight) * 0.5, + width: scaledWidth, + height: scaledHeight, + }, + viewportWidth, + viewportHeight, + ); +} + +function getFootprintScaleFromRects(currentRect, referenceRect) { + const currentArea = getRectArea(currentRect); + const referenceArea = getRectArea(referenceRect); + + if (currentArea <= 0 || referenceArea <= 0) { + return 1; + } + + return Math.max( + Math.sqrt(currentArea / referenceArea), + MIN_FOOTPRINT_SCALE, + ); +} + +function getImageFootprintScale({ + imageFit = 'contain', + imageHeight, + imageWidth, + previewDistance, + viewportHeight, + viewportWidth, +}) { + const currentRect = getContainedImageRect({ + imageFit, + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: getImagePreviewZoom(previewDistance), + }); + const referenceRect = getContainedImageRect({ + imageFit, + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: 1, + }); + + return getFootprintScaleFromRects(currentRect, referenceRect); +} + +function projectBox3ToViewport({ + camera, + localBounds, + meshMatrixWorld, + viewportHeight, + viewportWidth, +}) { + if ( + localBounds.isEmpty() || + viewportWidth <= 0 || + viewportHeight <= 0 + ) { + return null; + } + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let hasProjectedCorner = false; + + for (const corner of createBox3Corners(localBounds)) { + corner.applyMatrix4(meshMatrixWorld).project(camera); + + if ( + !Number.isFinite(corner.x) || + !Number.isFinite(corner.y) || + !Number.isFinite(corner.z) + ) { + continue; + } + + hasProjectedCorner = true; + + const x = (corner.x * 0.5 + 0.5) * viewportWidth; + const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + if (!hasProjectedCorner) { + return null; + } + + return clampRectToViewport( + { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }, + viewportWidth, + viewportHeight, + ); +} + +function getMeshFootprintScale({ + camera, + localBounds, + lookAtTarget, + meshMatrixWorld, + viewportHeight, + viewportWidth, +}) { + const currentRect = projectBox3ToViewport({ + camera, + localBounds, + meshMatrixWorld, + viewportHeight, + viewportWidth, + }); + const referenceCamera = camera.clone(); + const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); + const referenceOffset = + currentOffset.lengthSq() > 0 + ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) + : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); + + referenceCamera.position.copy(lookAtTarget).add(referenceOffset); + referenceCamera.lookAt(lookAtTarget); + referenceCamera.updateProjectionMatrix(); + referenceCamera.updateMatrixWorld(true); + + const referenceRect = projectBox3ToViewport({ + camera: referenceCamera, + localBounds, + meshMatrixWorld, + viewportHeight, + viewportWidth, + }); + + return getFootprintScaleFromRects(currentRect, referenceRect); +} +`; diff --git a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts deleted file mode 100644 index 49dddac6e6..0000000000 --- a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts +++ /dev/null @@ -1,835 +0,0 @@ -import * as THREE from 'three'; -import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; - -type HalftoneModelLoader = 'fbx' | 'glb'; - -interface HalftoneGeometrySpec { - key: string; - label: string; - kind: 'builtin' | 'imported'; - loader?: HalftoneModelLoader; - filename?: string; - description?: string; - extensions?: readonly string[]; - userProvided?: boolean; -} - -type GeometryCacheEntry = THREE.BufferGeometry | Promise; - -export type ImportedGeometryNormalizationOptions = { - postRotateZ?: number; - scaleTarget?: number; - useLegacyNormalization?: boolean; -}; - -const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75; -const importedGeometryCache = new Map(); - -function getImportedGeometryCacheKey({ - geometryOptions, - loader, - modelUrl, -}: { - geometryOptions?: ImportedGeometryNormalizationOptions; - loader: HalftoneModelLoader; - modelUrl: string; -}) { - return JSON.stringify({ - geometryOptions: geometryOptions ?? null, - loader, - modelUrl, - }); -} - -function mergeGeometries(geometries: THREE.BufferGeometry[]) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - -function normalizeImportedGeometry( - geometry: THREE.BufferGeometry, - options: ImportedGeometryNormalizationOptions = {}, -) { - const { - postRotateZ = 0, - scaleTarget, - useLegacyNormalization = false, - } = options; - - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - if (!useLegacyNormalization) { - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = useLegacyNormalization - ? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) / - Math.max(size.x, size.y, size.z, 0.001) - : (scaleTarget ?? 1.6) / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - if (postRotateZ !== 0) { - geometry.rotateZ(postRotateZ); - } - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - - return loadingManager; -} - -function extractMergedGeometry( - root: THREE.Object3D, - emptyMessage: string, - geometryOptions?: ImportedGeometryNormalizationOptions, -) { - root.updateMatrixWorld(true); - - const geometries: THREE.BufferGeometry[] = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry( - mergeGeometries(geometries), - geometryOptions, - ); -} - -function parseFbxGeometry( - buffer: ArrayBuffer, - resourcePath: string, - label: string, - geometryOptions?: ImportedGeometryNormalizationOptions, -) { - const originalWarn = console.warn; - - console.warn = (...args: unknown[]) => { - if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { - return; - } - - originalWarn(...args); - }; - - try { - const root = new FBXLoader(createLoadingManager()).parse( - buffer, - resourcePath, - ); - - return extractMergedGeometry( - root, - `${label} did not contain any mesh geometry.`, - geometryOptions, - ); - } finally { - console.warn = originalWarn; - } -} - -function parseGlbGeometry( - buffer: ArrayBuffer, - resourcePath: string, - label: string, - geometryOptions?: ImportedGeometryNormalizationOptions, -) { - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const gltfLoader = new GLTFLoader(createLoadingManager()); - gltfLoader.setDRACOLoader(dracoLoader); - - return new Promise((resolve, reject) => { - gltfLoader.parse( - buffer, - resourcePath, - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - `${label} did not contain any mesh geometry.`, - geometryOptions, - ), - ); - } catch (error) { - reject(error); - } - }, - reject, - ); - }).finally(() => { - dracoLoader.dispose(); - }); -} - -async function loadImportedGeometry( - loader: HalftoneModelLoader, - file: File, - label: string, -) { - const buffer = await file.arrayBuffer(); - - if (loader === 'fbx') { - return parseFbxGeometry(buffer, '', label); - } - - return parseGlbGeometry(buffer, '', label); -} - -export async function loadImportedGeometryFromUrl( - loader: HalftoneModelLoader, - modelUrl: string, - label: string, - geometryOptions?: ImportedGeometryNormalizationOptions, -) { - const cacheKey = getImportedGeometryCacheKey({ - geometryOptions, - loader, - modelUrl, - }); - const cachedGeometry = importedGeometryCache.get(cacheKey); - - if (cachedGeometry) { - return (await cachedGeometry).clone(); - } - - const geometryPromise = (async () => { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error(`Unable to load ${label} from ${modelUrl}.`); - } - - const buffer = await response.arrayBuffer(); - - if (loader === 'fbx') { - return parseFbxGeometry(buffer, '', label, geometryOptions); - } - - return parseGlbGeometry(buffer, '', label, geometryOptions); - })(); - importedGeometryCache.set(cacheKey, geometryPromise); - - try { - const geometry = await geometryPromise; - - importedGeometryCache.set(cacheKey, geometry); - - return geometry.clone(); - } catch (error) { - importedGeometryCache.delete(cacheKey); - throw error; - } -} - -function makePolarShape( - radiusFunction: (angle: number) => number, - segments = 320, -) { - const shape = new THREE.Shape(); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * Math.PI * 2; - const radius = radiusFunction(angle); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (segmentIndex === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - - return shape; -} - -function makeReliefGeometry( - shape: THREE.Shape, - options: { - depth?: number; - bevelSize?: number; - bevelThickness?: number; - bevelSegments?: number; - waves?: number; - waveDepth?: number; - } = {}, -) { - const { - bevelSegments = 8, - bevelSize = 0.08, - bevelThickness = 0.1, - depth = 0.58, - waveDepth = 0.016, - waves = 8, - } = options; - - const geometry = new THREE.ExtrudeGeometry(shape, { - depth, - steps: 2, - bevelEnabled: true, - bevelThickness, - bevelSize, - bevelSegments, - curveSegments: 96, - }); - - geometry.center(); - - const position = geometry.attributes.position; - let maxRadius = 0; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - maxRadius = Math.max( - maxRadius, - Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), - ); - } - - const fullDepth = depth + bevelThickness * 2; - - for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { - const x = position.getX(vertexIndex); - const y = position.getY(vertexIndex); - const z = position.getZ(vertexIndex); - const radius = Math.hypot(x, y) / maxRadius; - const angle = Math.atan2(y, x); - const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); - const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); - const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); - const wave = - Math.cos(angle * waves) * - Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * - waveDepth; - const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); - - position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); - } - - position.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function makeArrowTarget() { - const targetParts: THREE.BufferGeometry[] = []; - const arrowParts: THREE.BufferGeometry[] = []; - const baseRadius = 1.35; - const baseDepth = 0.32; - const bevel = 0.12; - const points: THREE.Vector2[] = []; - const segments = 16; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - -baseDepth / 2 + bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = (segmentIndex / segments) * (Math.PI / 2); - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - baseDepth / 2 - bevel + Math.sin(angle) * bevel, - ), - ); - } - - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - targetParts.push(disc); - - for (const radius of [0.45, 0.85, 1.22]) { - const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); - ring.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), - ); - targetParts.push(ring); - } - - const bump = new THREE.SphereGeometry( - 0.32, - 32, - 24, - 0, - Math.PI * 2, - 0, - Math.PI / 2, - ); - bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); - bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); - targetParts.push(bump); - - const shaftLength = 1.5; - const shaftRadius = 0.05; - const shaft = new THREE.CylinderGeometry( - shaftRadius, - shaftRadius, - shaftLength, - 10, - 1, - ); - shaft.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), - ); - arrowParts.push(shaft); - - const head = new THREE.ConeGeometry(0.12, 0.35, 10); - head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); - arrowParts.push(head); - - for (let finIndex = 0; finIndex < 3; finIndex += 1) { - const finShape = new THREE.Shape(); - finShape.moveTo(0, 0); - finShape.lineTo(0.22, 0.25); - finShape.lineTo(0, 0.5); - finShape.lineTo(0, 0); - - const finGeometry = new THREE.ExtrudeGeometry(finShape, { - depth: 0.012, - bevelEnabled: false, - }); - - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), - ); - finGeometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), - ); - arrowParts.push(finGeometry); - } - - const nock = new THREE.SphereGeometry(0.065, 8, 8); - nock.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), - ); - arrowParts.push(nock); - - const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); - const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); - const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); - - for (const geometry of arrowParts) { - geometry.applyMatrix4(aim); - geometry.applyMatrix4(tilt); - geometry.applyMatrix4(shift); - } - - const merged = mergeGeometries([...targetParts, ...arrowParts]); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -function makeDollarCoin() { - const parts: THREE.BufferGeometry[] = []; - const baseRadius = 1.3; - const baseDepth = 0.45; - const bevel = 0.18; - const points: THREE.Vector2[] = []; - const segments = 20; - - points.push(new THREE.Vector2(0, -baseDepth / 2)); - points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); - - for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { - const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; - points.push( - new THREE.Vector2( - baseRadius - bevel + Math.cos(angle) * bevel, - Math.sin(angle) * (baseDepth / 2), - ), - ); - } - - points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); - points.push(new THREE.Vector2(0, baseDepth / 2)); - - const disc = new THREE.LatheGeometry(points, 64); - disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - parts.push(disc); - - const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - frontRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), - ); - parts.push(frontRim); - - const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); - backRim.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), - ); - parts.push(backRim); - - const createDollarSign = () => { - const geometries: THREE.BufferGeometry[] = []; - const tubeRadius = 0.1; - const curveRadius = 0.28; - const verticalOffset = 0.22; - - const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); - geometries.push(bar); - - const topArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), - ); - geometries.push(topArc); - - const bottomArc = new THREE.TorusGeometry( - curveRadius, - tubeRadius, - 16, - 32, - Math.PI, - ); - bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); - bottomArc.applyMatrix4( - new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), - ); - geometries.push(bottomArc); - - const topSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - topSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - 0.16, - verticalOffset + curveRadius, - 0, - ), - ); - geometries.push(topSerif); - - const bottomSerif = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - 0.22, - 12, - ); - bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); - bottomSerif.applyMatrix4( - new THREE.Matrix4().makeTranslation( - -0.16, - -verticalOffset - curveRadius, - 0, - ), - ); - geometries.push(bottomSerif); - - const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); - const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); - const diagonal = new THREE.CylinderGeometry( - tubeRadius, - tubeRadius, - diagonalLength + 0.12, - 12, - ); - diagonal.applyMatrix4( - new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), - ); - geometries.push(diagonal); - - return geometries; - }; - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), - ); - parts.push(geometry); - } - - for (const geometry of createDollarSign()) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); - geometry.applyMatrix4( - new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), - ); - parts.push(geometry); - } - - const merged = mergeGeometries(parts); - merged.computeVertexNormals(); - merged.computeBoundingSphere(); - - return merged; -} - -const BUILTIN_GEOMETRIES: Record THREE.BufferGeometry> = { - torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32), - sphere: () => new THREE.SphereGeometry(1.4, 64, 64), - torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100), - icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4), - box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6), - cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10), - cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10), - octahedron: () => new THREE.OctahedronGeometry(1.5, 2), - dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1), - tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1), - sunCoin: () => - makeReliefGeometry( - makePolarShape( - (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), - ), - { depth: 0.62, waves: 12, waveDepth: 0.018 }, - ), - lotusCoin: () => - makeReliefGeometry( - makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), - { depth: 0.64, waves: 8, waveDepth: 0.014 }, - ), - arrowTarget: () => makeArrowTarget(), - dollarCoin: () => makeDollarCoin(), -}; - -async function createGeometry( - spec: HalftoneGeometrySpec, - file: File | undefined, -) { - if (spec.kind === 'imported') { - if (!file || !spec.loader) { - throw new Error(`No file is available for ${spec.label}.`); - } - - return loadImportedGeometry( - spec.loader as HalftoneModelLoader, - file, - spec.label, - ); - } - - const factory = BUILTIN_GEOMETRIES[spec.key]; - - if (!factory) { - throw new Error(`Unsupported geometry: ${spec.key}.`); - } - - return factory(); -} - -export function createFallbackGeometry() { - return BUILTIN_GEOMETRIES.torusKnot(); -} - -export async function getGeometryForSpec( - spec: HalftoneGeometrySpec, - file: File | undefined, - cache: Map, -) { - const cached = cache.get(spec.key); - - if (cached) { - return cached instanceof Promise ? cached : Promise.resolve(cached); - } - - const created = createGeometry(spec, file); - - if (created instanceof Promise) { - const pending = created - .then((geometry) => { - cache.set(spec.key, geometry); - return geometry; - }) - .catch((error) => { - cache.delete(spec.key); - throw error; - }); - - cache.set(spec.key, pending); - - return pending; - } - - cache.set(spec.key, created); - - return Promise.resolve(created); -} - -export function disposeGeometryCache(cache: Map) { - for (const value of cache.values()) { - if (!(value instanceof Promise)) { - value.dispose(); - } - } - - cache.clear(); -} diff --git a/packages/twenty-website-new/src/lib/halftone/index.ts b/packages/twenty-website-new/src/lib/halftone/index.ts index f245679e54..cc2e735655 100644 --- a/packages/twenty-website-new/src/lib/halftone/index.ts +++ b/packages/twenty-website-new/src/lib/halftone/index.ts @@ -3,15 +3,15 @@ export { type HalftoneImageInteractionSettings, type HalftoneRenderStrategy, type HalftoneSnapshotFn, -} from './halftone-canvas'; -export { HalftoneImageCanvas } from './halftone-image-canvas'; -export { HalftoneModelCanvas } from './halftone-model-canvas'; +} from './components/HalftoneCanvas'; +export { HalftoneImageCanvas } from './components/HalftoneImageCanvas'; +export { HalftoneModelCanvas } from './components/HalftoneModelCanvas'; export { createFallbackGeometry, loadImportedGeometryFromUrl, type ImportedGeometryNormalizationOptions, -} from './geometry-registry'; +} from './utils/geometry-registry'; export { DEFAULT_GLASS_ANIMATION_SETTINGS, @@ -26,7 +26,7 @@ export { DEFAULT_SOLID_LIGHTING_SETTINGS, DEFAULT_SOLID_MATERIAL_SETTINGS, normalizeHalftoneStudioSettings, -} from './state'; +} from './utils/state'; export type { HalftoneAnimationSettings, @@ -40,7 +40,7 @@ export type { HalftoneStudioSettings, HalftoneStudioSettingsOverrides, HalftoneToneTarget, -} from './state'; +} from './utils/state'; export { getContainedImageRect, @@ -48,7 +48,8 @@ export { getImagePreviewZoom, getMeshFootprintScale, type HalftoneImageFit, - HALFTONE_FOOTPRINT_RUNTIME_SOURCE, REFERENCE_PREVIEW_DISTANCE, VIRTUAL_RENDER_HEIGHT, -} from './footprint'; +} from './utils/footprint'; + +export { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from './generated/footprint-runtime-source'; diff --git a/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts b/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts new file mode 100644 index 0000000000..b3a68cc75a --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts @@ -0,0 +1,52 @@ +import type { + HalftoneExportPose, + HalftoneGeometrySpec, + HalftoneStudioSettings, +} from '@/lib/halftone/utils/state'; + +export type ReactExportSettings = { + includeNamedAndDefaultExport: boolean; + includePublicAssetUrl: boolean; + includeRegistryComment: boolean; + includeTsNoCheck: boolean; + includeUseClientDirective: boolean; + includeStyledMount: boolean; +}; + +export type ReactExportOptions = { + assetUrl?: string; + background?: string; + exportSettings?: Partial; + imageFilename?: string; + importedFile?: File; + initialPose?: HalftoneExportPose; + modelFilenameOverride?: string; + previewDistance?: number; +}; + +export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = { + includeNamedAndDefaultExport: true, + includePublicAssetUrl: true, + includeRegistryComment: true, + includeTsNoCheck: true, + includeUseClientDirective: true, + includeStyledMount: true, +}; + +export type ExportedShapeDescriptor = { + filename: string | null; + key: string; + kind: HalftoneGeometrySpec['kind']; + label: string; + loader: HalftoneGeometrySpec['loader'] | null; +}; + +export type ParsedExportedPreset = { + componentName: string | null; + imageAssetReference: string | null; + initialPose: HalftoneExportPose; + modelAssetReference: string | null; + previewDistance: number; + settings: HalftoneStudioSettings; + shape: ExportedShapeDescriptor; +}; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts b/packages/twenty-website-new/src/lib/halftone/utils/export-names.ts similarity index 96% rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts rename to packages/twenty-website-new/src/lib/halftone/utils/export-names.ts index c81dec8f6a..4c42a71d9e 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/export-names.ts @@ -25,7 +25,7 @@ export function normalizeExportComponentName( return toPascalCase(value?.trim() || fallback); } -export function toKebabCase(value: string) { +function toKebabCase(value: string) { const normalized = value .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts new file mode 100644 index 0000000000..6dccec81eb --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts @@ -0,0 +1,177 @@ +// Composable string-returning functions for the exported mount scripts. +// Used by createMountScript() and createImageMountScript() to avoid +// maintaining parallel blocks of identical runtime JavaScript. + +export function emitSizingHelpers() { + return ` + const getWidth = () => Math.max(container.clientWidth, 1); + const getHeight = () => Math.max(container.clientHeight, 1); + const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualWidth = () => + Math.max( + Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))), + 1, + );`; +} + +export function emitRendererSetup(cursor: string) { + return ` + // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime + const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + + const canvas = renderer.domElement; + canvas.style.cursor = ${JSON.stringify(cursor)}; + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.touchAction = 'none'; + canvas.style.width = '100%'; + container.appendChild(canvas);`; +} + +export function emitBlurMaterials() { + return ` + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + });`; +} + +type HalftoneUniformOverrides = { + cropToBounds: number; + hoverHalftoneRadius: string; + hoverLightRadius: string; + waveSpeed: string; +}; + +export function emitHalftoneMaterial(overrides: HalftoneUniformOverrides) { + return ` + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + effectResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + tile: { value: settings.halftone.scale }, + s_3: { value: settings.halftone.power }, + s_4: { value: settings.halftone.width }, + applyToDarkAreas: { + value: settings.halftone.toneTarget === 'dark' ? 1 : 0, + }, + dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, + hoverDashColor: { + value: new THREE.Color(settings.halftone.hoverDashColor), + }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: ${overrides.waveSpeed} }, + footprintScale: { value: 1.0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverHalftoneActive: { value: 0 }, + hoverHalftonePowerShift: { value: 0 }, + hoverHalftoneRadius: { value: ${overrides.hoverHalftoneRadius} }, + hoverHalftoneWidthShift: { value: 0 }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: ${overrides.hoverLightRadius} }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + cropToBounds: { value: ${overrides.cropToBounds} }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + });`; +} + +export function emitPostScenes() { + return ` + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial)); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));`; +} + +export function emitBlurPasses() { + return ` + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera);`; +} + +export function emitTimerSetup() { + return ` + const clock = new THREE.Timer(); + clock.connect(document); + let animationFrameId = 0;`; +} + +export function emitCleanup(extraDispose: string, extraListeners: string) { + return ` + return () => { + // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export + window.cancelAnimationFrame(animationFrameId); + clock.dispose(); + resizeObserver.disconnect(); +${extraListeners} + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); +${extraDispose} + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + renderer.dispose(); + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + };`; +} diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts new file mode 100644 index 0000000000..95e831d73e --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts @@ -0,0 +1,195 @@ +import { normalizeExportComponentName } from '@/lib/halftone/utils/export-names'; +import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/utils/footprint'; +import { + LEGACY_HALFTONE_SETTING_KEYS, + isRoundedBandHalftoneSettings, + type HalftoneExportPose, + type HalftoneGeometrySpec, + type HalftoneStudioSettings, +} from '@/lib/halftone/utils/state'; + +import type { + ExportedShapeDescriptor, + ParsedExportedPreset, +} from '../types/exporter-types'; + +function createDefaultExportPose(): HalftoneExportPose { + return { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 0, + }; +} + +export function normalizeExportPose( + pose: HalftoneExportPose | undefined, +): HalftoneExportPose { + const fallback = createDefaultExportPose(); + + if (!pose) { + return fallback; + } + + return { + autoElapsed: Number.isFinite(pose.autoElapsed) ? pose.autoElapsed : 0, + rotateElapsed: Number.isFinite(pose.rotateElapsed) ? pose.rotateElapsed : 0, + rotationX: Number.isFinite(pose.rotationX) ? pose.rotationX : 0, + rotationY: Number.isFinite(pose.rotationY) ? pose.rotationY : 0, + rotationZ: Number.isFinite(pose.rotationZ) ? pose.rotationZ : 0, + targetRotationX: Number.isFinite(pose.targetRotationX) + ? pose.targetRotationX + : 0, + targetRotationY: Number.isFinite(pose.targetRotationY) + ? pose.targetRotationY + : 0, + timeElapsed: Number.isFinite(pose.timeElapsed) ? pose.timeElapsed : 0, + }; +} + +export function normalizePreviewDistance(previewDistance: number | undefined) { + return Number.isFinite(previewDistance) + ? Math.max(previewDistance ?? REFERENCE_PREVIEW_DISTANCE, 0.001) + : REFERENCE_PREVIEW_DISTANCE; +} + +function extractSerializedJson( + content: string, + name: string, + nextName: string, +) { + const pattern = new RegExp( + String.raw`const\s+${name}\s*=\s*([\s\S]*?)\s*;\s*\r?\nconst\s+${nextName}\s*=`, + ); + const match = content.match(pattern); + + if (!match) { + throw new Error(`Could not find ${name} in the exported preset.`); + } + + try { + return JSON.parse(match[1]) as T; + } catch (error) { + throw new Error( + error instanceof Error + ? `Could not parse ${name}: ${error.message}` + : `Could not parse ${name}.`, + ); + } +} + +function extractFirstMatch(content: string, patterns: RegExp[]) { + for (const pattern of patterns) { + const match = content.match(pattern); + + if (match && match.length > 1) { + return match[match.length - 1] ?? null; + } + } + + return null; +} + +function assertRoundedBandPreset( + settings: HalftoneStudioSettings, +): asserts settings is HalftoneStudioSettings { + if (isRoundedBandHalftoneSettings(settings.halftone)) { + return; + } + + const halftoneValue = + settings.halftone && typeof settings.halftone === 'object' + ? (settings.halftone as Record) + : null; + const detectedLegacyKeys = LEGACY_HALFTONE_SETTING_KEYS.filter((key) => + halftoneValue ? key in halftoneValue : false, + ); + const suffix = + detectedLegacyKeys.length > 0 + ? ` Detected legacy keys: ${detectedLegacyKeys.join(', ')}.` + : ''; + + throw new Error( + `This preset uses the legacy halftone effect and can no longer be imported.${suffix}`, + ); +} + +export function parseExportedPreset(content: string): ParsedExportedPreset { + const settings = extractSerializedJson( + content, + 'settings', + 'shape', + ); + assertRoundedBandPreset(settings); + const shape = extractSerializedJson( + content, + 'shape', + 'initialPose', + ); + const initialPose = normalizeExportPose( + (() => { + try { + return extractSerializedJson( + content, + 'initialPose', + 'previewDistance', + ); + } catch { + return extractSerializedJson( + content, + 'initialPose', + 'VIRTUAL_RENDER_HEIGHT', + ); + } + })(), + ); + const previewDistanceMatch = content.match( + /const\s+previewDistance\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;/, + ); + const previewDistance = normalizePreviewDistance( + previewDistanceMatch ? Number(previewDistanceMatch[1]) : undefined, + ); + const componentName = + extractFirstMatch(content, [ + /export\s+default\s+function\s+([A-Za-z0-9_]+)/, + /export\s+function\s+([A-Za-z0-9_]+)/, + /export\s+default\s+([A-Za-z0-9_]+)\s*;/, + /([^<]+)<\/title>/i, + ]) ?? null; + const modelAssetReference = extractFirstMatch(content, [ + /modelUrl\s*=\s*(['"`])([\s\S]*?)\1/, + /modelUrl\s*:\s*(['"`])([\s\S]*?)\1/, + ]); + const imageAssetReference = extractFirstMatch(content, [ + /imageUrl\s*=\s*(['"`])([\s\S]*?)\1/, + /imageUrl\s*:\s*(['"`])([\s\S]*?)\1/, + ]); + + return { + componentName, + imageAssetReference, + initialPose, + modelAssetReference, + previewDistance, + settings, + shape, + }; +} + +export function deriveExportComponentName( + shape: HalftoneGeometrySpec | undefined, + importedFile: File | undefined, +) { + const source = + importedFile?.name ?? + shape?.filename ?? + shape?.label ?? + shape?.key ?? + 'HalftoneDashes'; + + return normalizeExportComponentName(source); +} diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts new file mode 100644 index 0000000000..79cf551cf1 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts @@ -0,0 +1,1585 @@ +import { GLASS_ENVIRONMENT_DATA_URL } from '@/lib/halftone/utils/glass-environment-data'; +import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/utils/draco-decoder-path'; +import type { HalftoneGeometrySpec } from '@/lib/halftone/utils/state'; + +import type { ExportedShapeDescriptor } from '../types/exporter-types'; + +export const passThroughVertexShader = ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +export const blurFragmentShader = ` + precision highp float; + + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; + + void main() { + vec4 sum = vec4(0.0); + vec2 px = dir / res; + + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; + + sum += texture2D(tInput, vUv) * w[0]; + + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; + } + + gl_FragColor = sum; + } +`; + +export const imagePassthroughFragmentShader = ` + precision highp float; + + uniform sampler2D tImage; + uniform vec2 imageSize; + uniform vec2 viewportSize; + uniform float zoom; + uniform float contrast; + uniform float imageFit; + + varying vec2 vUv; + + void main() { + float imageAspect = imageSize.x / imageSize.y; + float viewAspect = viewportSize.x / viewportSize.y; + + vec2 uv = vUv; + + if (imageAspect > viewAspect) { + float scale = viewAspect / imageAspect; + if (imageFit > 0.5) { + uv.x = (uv.x - 0.5) * scale + 0.5; + } else { + uv.y = (uv.y - 0.5) / scale + 0.5; + } + } else { + float scale = imageAspect / viewAspect; + if (imageFit > 0.5) { + uv.y = (uv.y - 0.5) * scale + 0.5; + } else { + uv.x = (uv.x - 0.5) / scale + 0.5; + } + } + + uv = (uv - 0.5) / zoom + 0.5; + + float inBounds = step(0.0, uv.x) * step(uv.x, 1.0) + * step(0.0, uv.y) * step(uv.y, 1.0); + + vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0)); + vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0); + + gl_FragColor = vec4(contrastColor, inBounds); + } +`; + +export const halftoneFragmentShader = ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 effectResolution; + uniform vec2 logicalResolution; + uniform float tile; + uniform float s_3; + uniform float s_4; + uniform float applyToDarkAreas; + uniform vec3 dashColor; + uniform vec3 hoverDashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float footprintScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverHalftoneActive; + uniform float hoverHalftonePowerShift; + uniform float hoverHalftoneRadius; + uniform float hoverHalftoneWidthShift; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float cropToBounds; + + varying vec2 vUv; + + float distSegment(in vec2 p, in vec2 a, in vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float denom = max(dot(ba, ba), 0.000001); + float h = clamp(dot(pa, ba) / denom, 0.0, 1.0); + return length(pa - ba * h); + } + + float lineSimpleEt(in vec2 p, in float r, in float thickness) { + vec2 a = vec2(0.5) + vec2(-r, 0.0); + vec2 b = vec2(0.5) + vec2(r, 0.0); + float distToSegment = distSegment(p, a, b); + float halfThickness = thickness * r; + return distToSegment - halfThickness; + } + + void main() { + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + vec2 fragCoord = + (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution; + float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0); + vec2 pointerPx = interactionUv * logicalResolution; + vec2 fragDelta = fragCoord - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * logicalResolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverHalftoneMask = 0.0; + if (hoverHalftoneActive > 0.0) { + float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y; + hoverHalftoneMask = + smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) * + clamp(hoverHalftoneActive, 0.0, 1.0); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * logicalResolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15; + vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45; + vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement; + + float bandRow = floor(effectCoord.y / halftoneSize); + float waveOffset = + waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize; + effectCoord.x += waveOffset; + + vec2 cellIndex = floor(effectCoord / halftoneSize); + vec2 sampleUv = clamp( + (cellIndex + 0.5) * halftoneSize / logicalResolution, + vec2(0.0), + vec2(1.0) + ); + vec2 cellUv = fract(effectCoord / halftoneSize); + + vec4 sceneSample = texture2D(tScene, sampleUv); + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float localPower = clamp( + s_3 + hoverHalftonePowerShift * hoverHalftoneMask, + -1.5, + 1.5 + ); + float localWidth = clamp( + s_4 + hoverHalftoneWidthShift * hoverHalftoneMask, + 0.05, + 1.4 + ); + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22; + float toneValue = + (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0); + if (applyToDarkAreas > 0.5) { + toneValue = 1.0 - toneValue; + } + // Preserve the pre-toneTarget light-mode response by keeping the power + // bias inside the averaged tone calculation. + float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0); + float bandRadius = clamp( + toneValue + powerBias + lightLift, + 0.0, + 1.0 + ) * 1.86 * 0.5; + + float alpha = 0.0; + if (bandRadius > 0.0001) { + float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth); + float edge = 0.02; + alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; + } + + vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask); + vec3 color = activeDashColor * alpha; + gl_FragColor = vec4(color, alpha); + + #include <tonemapping_fragment> + #include <colorspace_fragment> + } +`; + +const HALFTONE_TRANSMISSION_SHADER_PREFIX = String.raw` +uniform float chromaticAberration; +uniform float anisotropicBlur; +uniform float time; +uniform float distortion; +uniform float distortionScale; +uniform float temporalDistortion; +uniform sampler2D buffer; + +vec3 random3(vec3 c) { + float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0))); + vec3 r; + r.z = fract(512.0 * j); + j *= 0.125; + r.x = fract(512.0 * j); + j *= 0.125; + r.y = fract(512.0 * j); + return r - 0.5; +} + +uint hash(uint x) { + x += (x << 10u); + x ^= (x >> 6u); + x += (x << 3u); + x ^= (x >> 11u); + x += (x << 15u); + return x; +} + +uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); } +uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); } +uint hash(uvec4 v) { + return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w)); +} + +float floatConstruct(uint m) { + const uint ieeeMantissa = 0x007FFFFFu; + const uint ieeeOne = 0x3F800000u; + m &= ieeeMantissa; + m |= ieeeOne; + float f = uintBitsToFloat(m); + return f - 1.0; +} + +float randomBase(float x) { + return floatConstruct(hash(floatBitsToUint(x))); +} +float randomBase(vec2 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} +float randomBase(vec3 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} +float randomBase(vec4 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} + +float rand(float seed) { + return randomBase(vec3(gl_FragCoord.xy, seed)); +} + +const float F3 = 0.3333333; +const float G3 = 0.1666667; + +float snoise(vec3 p) { + vec3 s = floor(p + dot(p, vec3(F3))); + vec3 x = p - s + dot(s, vec3(G3)); + vec3 e = step(vec3(0.0), x - x.yzx); + vec3 i1 = e * (1.0 - e.zxy); + vec3 i2 = 1.0 - e.zxy * (1.0 - e); + vec3 x1 = x - i1 + G3; + vec3 x2 = x - i2 + 2.0 * G3; + vec3 x3 = x - 1.0 + 3.0 * G3; + vec4 w; + vec4 d; + w.x = dot(x, x); + w.y = dot(x1, x1); + w.z = dot(x2, x2); + w.w = dot(x3, x3); + w = max(0.6 - w, 0.0); + d.x = dot(random3(s), x); + d.y = dot(random3(s + i1), x1); + d.z = dot(random3(s + i2), x2); + d.w = dot(random3(s + 1.0), x3); + w *= w; + w *= w; + d *= w; + return dot(d, vec4(52.0)); +} + +float snoiseFractal(vec3 m) { + return 0.5333333 * snoise(m) + + 0.2666667 * snoise(2.0 * m) + + 0.1333333 * snoise(4.0 * m) + + 0.0666667 * snoise(8.0 * m); +} +`; + +const HALFTONE_TRANSMISSION_PARS_FRAGMENT = String.raw` +#ifdef USE_TRANSMISSION + uniform float _transmission; + uniform float thickness; + uniform float attenuationDistance; + uniform vec3 attenuationColor; + uniform sampler2D refractionEnvMap; + uniform float useEnvMapRefraction; + #ifdef USE_TRANSMISSIONMAP + uniform sampler2D transmissionMap; + #endif + #ifdef USE_THICKNESSMAP + uniform sampler2D thicknessMap; + #endif + uniform vec2 transmissionSamplerSize; + uniform sampler2D transmissionSamplerMap; + uniform mat4 modelMatrix; + uniform mat4 projectionMatrix; + varying vec3 vWorldPosition; + + vec3 getVolumeTransmissionRay( + const in vec3 n, + const in vec3 v, + const in float thicknessValue, + const in float ior, + const in mat4 modelMatrix + ) { + vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior); + vec3 modelScale; + modelScale.x = length(vec3(modelMatrix[0].xyz)); + modelScale.y = length(vec3(modelMatrix[1].xyz)); + modelScale.z = length(vec3(modelMatrix[2].xyz)); + return normalize(refractionVector) * thicknessValue * modelScale; + } + + float applyIorToRoughness( + const in float roughnessValue, + const in float ior + ) { + return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0); + } + + vec2 directionToEquirectUv(const in vec3 direction) { + vec3 dir = normalize(direction); + vec2 uv = vec2( + atan(dir.z, dir.x) * 0.15915494309189535 + 0.5, + asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5 + ); + + return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0)); + } + + vec4 getTransmissionSample( + const in vec2 fragCoord, + const in vec3 transmissionDirection, + const in float roughnessValue, + const in float ior + ) { + if (useEnvMapRefraction > 0.5) { + return texture2D( + refractionEnvMap, + directionToEquirectUv(transmissionDirection) + ); + } + + float framebufferLod = + log2(transmissionSamplerSize.x) * + applyIorToRoughness(roughnessValue, ior); + return texture2D(buffer, fragCoord.xy); + } + + vec3 applyVolumeAttenuation( + const in vec3 radiance, + const in float transmissionDistance, + const in vec3 attenuationColorValue, + const in float attenuationDistanceValue + ) { + if (isinf(attenuationDistanceValue)) { + return radiance; + } + + vec3 attenuationCoefficient = + -log(attenuationColorValue) / attenuationDistanceValue; + vec3 transmittance = + exp(-attenuationCoefficient * transmissionDistance); + + return transmittance * radiance; + } + + vec4 getIBLVolumeRefraction( + const in vec3 n, + const in vec3 v, + const in float roughnessValue, + const in vec3 diffuseColor, + const in vec3 specularColor, + const in float specularF90, + const in vec3 position, + const in mat4 modelMatrix, + const in mat4 viewMatrix, + const in mat4 projMatrix, + const in float ior, + const in float thicknessValue, + const in vec3 attenuationColorValue, + const in float attenuationDistanceValue + ) { + vec3 transmissionRay = getVolumeTransmissionRay( + n, + v, + thicknessValue, + ior, + modelMatrix + ); + vec3 refractedRayExit = position + transmissionRay; + vec4 ndcPos = + projMatrix * viewMatrix * vec4(refractedRayExit, 1.0); + vec2 refractionCoords = ndcPos.xy / ndcPos.w; + refractionCoords += 1.0; + refractionCoords /= 2.0; + vec3 transmissionDirection = normalize(transmissionRay); + vec4 transmittedLight = getTransmissionSample( + refractionCoords, + transmissionDirection, + roughnessValue, + ior + ); + vec3 attenuatedColor = applyVolumeAttenuation( + transmittedLight.rgb, + length(transmissionRay), + attenuationColorValue, + attenuationDistanceValue + ); + vec3 F = EnvironmentBRDF( + n, + v, + specularColor, + specularF90, + roughnessValue + ); + return vec4( + (1.0 - F) * attenuatedColor * diffuseColor, + transmittedLight.a + ); + } +#endif +`; + +const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = String.raw` +material.transmission = _transmission; +material.transmissionAlpha = 1.0; +material.thickness = thickness; +material.attenuationDistance = attenuationDistance; +material.attenuationColor = attenuationColor; +#ifdef USE_TRANSMISSIONMAP + material.transmission *= texture2D(transmissionMap, vUv).r; +#endif +#ifdef USE_THICKNESSMAP + material.thickness *= texture2D(thicknessMap, vUv).g; +#endif + +vec3 pos = vWorldPosition; +float runningSeed = 0.0; +vec3 v = normalize(cameraPosition - pos); +vec3 n = inverseTransformDirection(normal, viewMatrix); +vec3 transmission = vec3(0.0); +float transmissionR; +float transmissionG; +float transmissionB; +float randomCoords = rand(runningSeed++); +float thicknessSmear = + thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur); +vec3 distortionNormal = vec3(0.0); +vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion; + +if (distortion > 0.0) { + distortionNormal = distortion * vec3( + snoiseFractal(vec3(pos * distortionScale + temporalOffset)), + snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)), + snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset)) + ); +} + +for (float i = 0.0; i < __SAMPLES__.0; i++) { + vec3 sampleNorm = normalize( + n + + roughnessFactor * roughnessFactor * 2.0 * + normalize( + vec3( + rand(runningSeed++) - 0.5, + rand(runningSeed++) - 0.5, + rand(runningSeed++) - 0.5 + ) + ) * + pow(rand(runningSeed++), 0.33) + + distortionNormal + ); + + transmissionR = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior, + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).r; + + transmissionG = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).g; + + transmissionB = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).b; + + transmission.r += transmissionR; + transmission.g += transmissionG; + transmission.b += transmissionB; +} + +transmission /= __SAMPLES__.0; +totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission); +`; + +export const GEOMETRY_RUNTIME_SOURCE = String.raw` +function makePolarShape(radiusFunction, segments = 320) { + const shape = new THREE.Shape(); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * Math.PI * 2; + const radius = radiusFunction(angle); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (segmentIndex === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + return shape; +} + +function makeReliefGeometry(shape, options = {}) { + const { + bevelSegments = 8, + bevelSize = 0.08, + bevelThickness = 0.1, + depth = 0.58, + waveDepth = 0.016, + waves = 8, + } = options; + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 2, + bevelEnabled: true, + bevelThickness, + bevelSize, + bevelSegments, + curveSegments: 96, + }); + + geometry.center(); + + const position = geometry.attributes.position; + let maxRadius = 0; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + maxRadius = Math.max( + maxRadius, + Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), + ); + } + + const fullDepth = depth + bevelThickness * 2; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + const x = position.getX(vertexIndex); + const y = position.getY(vertexIndex); + const z = position.getZ(vertexIndex); + const radius = Math.hypot(x, y) / maxRadius; + const angle = Math.atan2(y, x); + const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); + const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); + const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); + const wave = + Math.cos(angle * waves) * + Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * + waveDepth; + const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); + + position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); + } + + position.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function mergeGeometries(geometries) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function makeArrowTarget() { + const targetParts = []; + const arrowParts = []; + const baseRadius = 1.35; + const baseDepth = 0.32; + const bevel = 0.12; + const points = []; + const segments = 16; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + -baseDepth / 2 + bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + baseDepth / 2 - bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + targetParts.push(disc); + + for (const radius of [0.45, 0.85, 1.22]) { + const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); + ring.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), + ); + targetParts.push(ring); + } + + const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2); + bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); + bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); + targetParts.push(bump); + + const shaftLength = 1.5; + const shaftRadius = 0.05; + const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1); + shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0)); + arrowParts.push(shaft); + + const head = new THREE.ConeGeometry(0.12, 0.35, 10); + head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); + arrowParts.push(head); + + for (let finIndex = 0; finIndex < 3; finIndex += 1) { + const finShape = new THREE.Shape(); + finShape.moveTo(0, 0); + finShape.lineTo(0.22, 0.25); + finShape.lineTo(0, 0.5); + finShape.lineTo(0, 0); + + const finGeometry = new THREE.ExtrudeGeometry(finShape, { + depth: 0.012, + bevelEnabled: false, + }); + + finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006)); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), + ); + arrowParts.push(finGeometry); + } + + const nock = new THREE.SphereGeometry(0.065, 8, 8); + nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0)); + arrowParts.push(nock); + + const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); + const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); + const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); + + for (const geometry of arrowParts) { + geometry.applyMatrix4(aim); + geometry.applyMatrix4(tilt); + geometry.applyMatrix4(shift); + } + + const merged = mergeGeometries([...targetParts, ...arrowParts]); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function makeDollarCoin() { + const parts = []; + const baseRadius = 1.3; + const baseDepth = 0.45; + const bevel = 0.18; + const points = []; + const segments = 20; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + Math.sin(angle) * (baseDepth / 2), + ), + ); + } + + points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + parts.push(disc); + + const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + frontRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), + ); + parts.push(frontRim); + + const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + backRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), + ); + parts.push(backRim); + + const createDollarSign = () => { + const geometries = []; + const tubeRadius = 0.1; + const curveRadius = 0.28; + const verticalOffset = 0.22; + + const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); + geometries.push(bar); + + const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), + ); + geometries.push(topArc); + + const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); + bottomArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), + ); + geometries.push(bottomArc); + + const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0), + ); + geometries.push(topSerif); + + const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + bottomSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0), + ); + geometries.push(bottomSerif); + + const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); + const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); + const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12); + diagonal.applyMatrix4( + new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), + ); + geometries.push(diagonal); + + return geometries; + }; + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), + ); + parts.push(geometry); + } + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), + ); + parts.push(geometry); + } + + const merged = mergeGeometries(parts); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function createBuiltinGeometry(shapeKey) { + switch (shapeKey) { + case 'torusKnot': + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + case 'sphere': + return new THREE.SphereGeometry(1.4, 64, 64); + case 'torus': + return new THREE.TorusGeometry(1, 0.45, 64, 100); + case 'icosahedron': + return new THREE.IcosahedronGeometry(1.4, 4); + case 'box': + return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); + case 'cone': + return new THREE.ConeGeometry(1.2, 2.4, 64, 10); + case 'cylinder': + return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); + case 'octahedron': + return new THREE.OctahedronGeometry(1.5, 2); + case 'dodecahedron': + return new THREE.DodecahedronGeometry(1.35, 1); + case 'tetrahedron': + return new THREE.TetrahedronGeometry(1.7, 1); + case 'sunCoin': + return makeReliefGeometry( + makePolarShape( + (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), + ), + { depth: 0.62, waves: 12, waveDepth: 0.018 }, + ); + case 'lotusCoin': + return makeReliefGeometry( + makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), + { depth: 0.64, waves: 8, waveDepth: 0.014 }, + ); + case 'arrowTarget': + return makeArrowTarget(); + case 'dollarCoin': + return makeDollarCoin(); + default: + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + } +} +`; + +const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw` +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + return loadingManager; +} + +function normalizeImportedGeometry(geometry) { + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = 1.6 / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function extractMergedGeometry(root, emptyMessage) { + root.updateMatrixWorld(true); + const geometries = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} +`; + +const IMPORTED_FBX_RUNTIME_SOURCE = String.raw` + +function parseFbxGeometry(buffer, label) { + const originalWarn = console.warn; + + console.warn = (...args) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse(buffer, ''); + return extractMergedGeometry(root, label + ' did not contain any mesh geometry.'); + } finally { + console.warn = originalWarn; + } +} +`; + +const IMPORTED_GLB_RUNTIME_SOURCE = String.raw` +const DRACO_DECODER_PATH = ${JSON.stringify(DRACO_DECODER_PATH)}; + +function parseGlbGeometry(buffer, label) { + return new Promise((resolve, reject) => { + const loadingManager = createLoadingManager(); + const dracoLoader = new DRACOLoader(loadingManager); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const loader = new GLTFLoader(loadingManager); + loader.setDRACOLoader(dracoLoader); + + const cleanup = () => { + dracoLoader.dispose(); + }; + + loader.parse( + buffer, + '', + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + label + ' did not contain any mesh geometry.', + ), + ); + } catch (error) { + reject(error); + } finally { + cleanup(); + } + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} +`; + +function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) { + const loaderSource = + loader === 'fbx' + ? IMPORTED_FBX_RUNTIME_SOURCE + : IMPORTED_GLB_RUNTIME_SOURCE; + + return `${IMPORTED_RUNTIME_SHARED_SOURCE} +${loaderSource} + +async function loadImportedGeometryFromUrl(modelUrl, label) { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); + } + + const buffer = await response.arrayBuffer(); + + ${ + loader === 'fbx' + ? 'return parseFbxGeometry(buffer, label);' + : 'return parseGlbGeometry(buffer, label);' + } +} +`; +} + +export function getExportedShapeLoader( + shape: ExportedShapeDescriptor, +): HalftoneGeometrySpec['loader'] | null { + if (shape.loader) { + return shape.loader; + } + + const filename = shape.filename?.toLowerCase() ?? ''; + + if (filename.endsWith('.fbx')) { + return 'fbx'; + } + + if (filename.endsWith('.glb')) { + return 'glb'; + } + + return null; +} + +export function createImportedGeometryRuntimeSource( + shape: ExportedShapeDescriptor, +) { + if (shape.kind !== 'imported') { + return ''; + } + + const loader = getExportedShapeLoader(shape) ?? 'glb'; + + return createImportedRuntimeSource(loader); +} + +export const GLASS_MATERIAL_RUNTIME_SOURCE = String.raw` +const GLASS_ENVIRONMENT_DATA_URL = ${JSON.stringify(GLASS_ENVIRONMENT_DATA_URL)}; +const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; +const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; +const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; +const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; +const GLASS_ENVIRONMENT_ZOOM = 1.55; +const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); +const MAX_TEXTURE_ANISOTROPY = 8; +const HALFTONE_TRANSMISSION_SHADER_PREFIX = ${JSON.stringify( + HALFTONE_TRANSMISSION_SHADER_PREFIX, +)}; +const HALFTONE_TRANSMISSION_PARS_FRAGMENT = ${JSON.stringify( + HALFTONE_TRANSMISSION_PARS_FRAGMENT, +)}; +const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = ${JSON.stringify( + HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE, +)}; + +class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { + constructor(samples = 10) { + super(); + + this.halftoneUniforms = { + chromaticAberration: { value: 0.05 }, + transmission: { value: 0 }, + _transmission: { value: 1 }, + transmissionMap: { value: null }, + refractionEnvMap: { value: null }, + useEnvMapRefraction: { value: 0 }, + roughness: { value: 0 }, + thickness: { value: 0 }, + thicknessMap: { value: null }, + attenuationDistance: { value: Infinity }, + attenuationColor: { value: new THREE.Color('white') }, + anisotropicBlur: { value: 0.1 }, + time: { value: 0 }, + distortion: { value: 0 }, + distortionScale: { value: 0.5 }, + temporalDistortion: { value: 0 }, + buffer: { value: null }, + }; + + this.customProgramCacheKey = () => 'halftone-transmission-' + samples; + + this.onBeforeCompile = (shader) => { + shader.uniforms = { + ...shader.uniforms, + ...this.halftoneUniforms, + }; + shader.defines ??= {}; + + if (this.anisotropy > 0) { + shader.defines.USE_ANISOTROPY = ''; + } + + shader.defines.USE_TRANSMISSION = ''; + shader.fragmentShader = + HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; + shader.fragmentShader = shader.fragmentShader.replace( + '#include <transmission_pars_fragment>', + HALFTONE_TRANSMISSION_PARS_FRAGMENT, + ); + shader.fragmentShader = shader.fragmentShader.replace( + '#include <transmission_fragment>', + HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( + '__SAMPLES__', + String(samples), + ), + ); + }; + + Object.keys(this.halftoneUniforms).forEach((key) => { + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + get: () => this.halftoneUniforms[key]?.value, + set: (value) => { + this.halftoneUniforms[key].value = value; + }, + }); + }); + } +} + +function setTextureSampling(texture, renderer) { + texture.generateMipmaps = true; + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.anisotropy = Math.min( + renderer.capabilities.getMaxAnisotropy(), + MAX_TEXTURE_ANISOTROPY, + ); +} + +function disposeEnvironmentScene(scene) { + scene.traverse((object) => { + if (object.geometry) { + object.geometry.dispose(); + } + + if (Array.isArray(object.material)) { + object.material.forEach((material) => material.dispose()); + return; + } + + object.material?.dispose?.(); + }); +} + +function createSolidEnvironmentTexture(renderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function getTextureImageSize(texture) { + const image = texture.image; + + return { + height: + image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, + width: + image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, + }; +} + +function createZoomedGlassTexture(sourceTexture, renderer, zoom) { + if (zoom <= 1) { + return sourceTexture; + } + + const { width, height } = getTextureImageSize(sourceTexture); + + if (!width || !height) { + return sourceTexture; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + + if (!context) { + return sourceTexture; + } + + const cropWidth = width / zoom; + const cropHeight = height / zoom; + const sourceX = (width - cropWidth) / 2; + const sourceY = (height - cropHeight) / 2; + + context.drawImage( + sourceTexture.image, + sourceX, + sourceY, + cropWidth, + cropHeight, + 0, + 0, + width, + height, + ); + + const zoomedTexture = new THREE.CanvasTexture(canvas); + zoomedTexture.colorSpace = sourceTexture.colorSpace; + zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; + zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; + setTextureSampling(zoomedTexture, renderer); + zoomedTexture.needsUpdate = true; + + return zoomedTexture; +} + +function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = backdropTexture + ? pmremGenerator.fromEquirectangular(backdropTexture).texture + : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function createFallbackGlassBackdropTexture(renderer) { + const texture = new THREE.DataTexture( + new Uint8Array([3, 3, 3, 255]), + 1, + 1, + THREE.RGBAFormat, + ); + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.mapping = THREE.EquirectangularReflectionMapping; + setTextureSampling(texture, renderer); + texture.needsUpdate = true; + + return texture; +} + +function loadTexture(url, renderer, colorSpace) { + const loader = new THREE.TextureLoader(); + + return new Promise((resolve, reject) => { + loader.load( + url, + (texture) => { + texture.colorSpace = colorSpace; + setTextureSampling(texture, renderer); + resolve(texture); + }, + undefined, + reject, + ); + }); +} + +async function loadGlassEnvironmentTexture(renderer) { + const sourceBackgroundTexture = await loadTexture( + GLASS_ENVIRONMENT_DATA_URL, + renderer, + THREE.SRGBColorSpace, + ); + const backgroundTexture = createZoomedGlassTexture( + sourceBackgroundTexture, + renderer, + GLASS_ENVIRONMENT_ZOOM, + ); + if (backgroundTexture !== sourceBackgroundTexture) { + sourceBackgroundTexture.dispose(); + } + backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; + backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; + backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; + backgroundTexture.needsUpdate = true; + const environmentTexture = createStudioGlassEnvironmentTexture( + renderer, + backgroundTexture, + ); + + return { + backgroundTexture, + environmentTexture, + }; +} + +async function createHalftoneMaterialAssets(renderer) { + const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); + + try { + const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); + + return { + glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, + glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, + solidEnvironmentTexture, + }; + } catch { + const fallbackGlassBackdropTexture = + createFallbackGlassBackdropTexture(renderer); + const fallbackGlassEnvironmentTexture = + createStudioGlassEnvironmentTexture(renderer); + + return { + glassBackgroundTexture: fallbackGlassBackdropTexture, + glassEnvironmentTexture: fallbackGlassEnvironmentTexture, + solidEnvironmentTexture, + }; + } +} + +function createHalftoneMaterial() { + return new HalftoneTransmissionMaterial(); +} + +function applyHalftoneMaterialSettings(material, materialSettings, materialAssets) { + const isGlass = materialSettings.surface === 'glass'; + const glassThickness = + materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; + const glassEnvironmentIntensity = + GLASS_ENVIRONMENT_INTENSITY_BASE + + materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; + const glassAttenuationDistance = Math.max( + glassThickness * 4, + GLASS_ATTENUATION_DISTANCE_MIN, + ); + + material.color.set(isGlass ? '#ffffff' : materialSettings.color); + material.roughness = materialSettings.roughness; + material.metalness = materialSettings.metalness; + material.envMap = isGlass + ? materialAssets.glassEnvironmentTexture + : materialAssets.solidEnvironmentTexture; + material.envMapIntensity = isGlass + ? GLASS_ENVIRONMENT_INTENSITY_BASE + + materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER + : 0.25; + material.clearcoat = isGlass ? 1 : 0; + material.clearcoatRoughness = isGlass + ? Math.max(materialSettings.roughness * 0.25, 0.01) + : 0.08; + material.reflectivity = isGlass ? 0.98 : 0.5; + material.transmission = 0; + material._transmission = isGlass ? 1 : 0; + material.refractionEnvMap = isGlass ? materialAssets.glassBackgroundTexture : null; + material.useEnvMapRefraction = isGlass ? 1 : 0; + material.thickness = isGlass ? glassThickness : 0; + material.ior = isGlass ? materialSettings.refraction : 1.5; + material.buffer = null; + material.bumpMap = null; + material.bumpScale = 0; + material.roughnessMap = null; + material.side = THREE.FrontSide; + material.transparent = false; + material.opacity = 1; + material.depthWrite = true; + material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); + material.attenuationDistance = isGlass + ? glassAttenuationDistance + : Infinity; + material.anisotropicBlur = isGlass + ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) + : 0.1; + material.chromaticAberration = isGlass ? 0 : 0.05; + material.distortion = 0; + material.distortionScale = 0.5; + material.temporalDistortion = 0; + material.userData.halftoneIsGlass = isGlass; + material.userData.halftoneGlassBacksideThickness = isGlass + ? glassThickness * 2 + : 0; + material.userData.halftoneGlassBacksideEnvIntensity = isGlass + ? glassEnvironmentIntensity * 2.8 + : 0; + material.userData.halftoneUseEnvironmentRefraction = isGlass; + material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; + + material.needsUpdate = true; +} + +function disposeHalftoneMaterialAssets(materialAssets) { + materialAssets.glassBackgroundTexture.dispose(); + + if (materialAssets.glassEnvironmentTexture !== materialAssets.glassBackgroundTexture) { + materialAssets.glassEnvironmentTexture.dispose(); + } + + materialAssets.solidEnvironmentTexture.dispose(); +} +`; diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts new file mode 100644 index 0000000000..1898182de2 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts @@ -0,0 +1,1438 @@ +import { normalizeExportComponentName } from '@/lib/halftone/utils/export-names'; +import { VIRTUAL_RENDER_HEIGHT } from '@/lib/halftone/utils/footprint'; +import { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from '@/lib/halftone/generated/footprint-runtime-source'; +import { + IMAGE_HOVER_FADE_IN, + IMAGE_HOVER_FADE_OUT, + IMAGE_POINTER_FOLLOW, + IMAGE_POINTER_VELOCITY_DAMPING, +} from '@/lib/halftone/constants/halftone-image-constants'; +import { + RESET_INTERACTION_STATE_RUNTIME_SOURCE, + SPRING_STEP_RUNTIME_SOURCE, +} from '@/lib/halftone/utils/interaction-state'; +import type { + HalftoneExportPose, + HalftoneGeometrySpec, + HalftoneStudioSettings, +} from '@/lib/halftone/utils/state'; + +import { + emitBlurMaterials, + emitBlurPasses, + emitCleanup, + emitHalftoneMaterial, + emitPostScenes, + emitRendererSetup, + emitSizingHelpers, +} from './exporter-mount-fragments'; +import { + blurFragmentShader, + createImportedGeometryRuntimeSource, + GEOMETRY_RUNTIME_SOURCE, + getExportedShapeLoader, + GLASS_MATERIAL_RUNTIME_SOURCE, + halftoneFragmentShader, + imagePassthroughFragmentShader, + passThroughVertexShader, +} from './exporter-shaders'; +import { + normalizeExportPose, + normalizePreviewDistance, +} from './exporter-parsing'; +import { + DEFAULT_REACT_EXPORT_SETTINGS, + type ExportedShapeDescriptor, + type ReactExportOptions, + type ReactExportSettings, +} from '../types/exporter-types'; + +export type { + ExportedShapeDescriptor, + ParsedExportedPreset, + ReactExportOptions, + ReactExportSettings, +} from '../types/exporter-types'; +export { DEFAULT_REACT_EXPORT_SETTINGS } from '../types/exporter-types'; +export { + deriveExportComponentName, + parseExportedPreset, +} from './exporter-parsing'; + +function toIllustrationRegistryKey(componentName: string) { + return componentName.charAt(0).toLowerCase() + componentName.slice(1); +} + +function toPublicAssetDestination(assetUrl: string) { + return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl; +} + +function normalizeReactExportSettings( + exportSettings?: Partial<ReactExportSettings>, +): ReactExportSettings { + return { + ...DEFAULT_REACT_EXPORT_SETTINGS, + ...exportSettings, + }; +} + +function getReactImportBlock( + exportSettings: ReactExportSettings, + isImageMode: boolean, + shape: ExportedShapeDescriptor, +) { + const imports = new Set<string>(); + + imports.add("import { useEffect, useRef, type CSSProperties } from 'react';"); + imports.add("import * as THREE from 'three';"); + + if (exportSettings.includeStyledMount) { + imports.add("import { styled } from '@linaria/react';"); + } + + if (!isImageMode) { + imports.add( + "import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';", + ); + } + + const loader = getExportedShapeLoader(shape); + + if (shape.kind === 'imported' && loader === 'fbx') { + imports.add( + "import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';", + ); + } + + if (shape.kind === 'imported' && loader === 'glb') { + imports.add( + "import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';", + ); + imports.add( + "import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';", + ); + } + + return Array.from(imports).join('\n'); +} + +function getStandaloneThreeImports( + isImageMode: boolean, + shape: ExportedShapeDescriptor, +) { + if (isImageMode) { + return `import * as THREE from 'three';`; + } + + const imports = [ + `import * as THREE from 'three';`, + `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`, + ]; + + const loader = getExportedShapeLoader(shape); + + if (shape.kind === 'imported' && loader === 'fbx') { + imports.push( + `import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`, + ); + } + + if (shape.kind === 'imported' && loader === 'glb') { + imports.push( + `import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`, + `import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`, + ); + } + + return imports.join('\n '); +} + +function getTwentyReactHeaderComment( + componentName: string, + registryKey: string, + assetUrl?: string, +) { + const lines = [ + `// Suggested component destination: src/illustrations/${componentName}.tsx`, + ]; + + if (assetUrl) { + lines.push( + `// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`, + ); + } + + lines.push( + `// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`, + `// illustrations-registry.tsx: ${registryKey}: ${componentName},`, + ); + + return `${lines.join('\n')}\n`; +} + +function createShapeDescriptor( + shape: HalftoneGeometrySpec | undefined, + settings: HalftoneStudioSettings, + importedFile?: File, + modelFilenameOverride?: string, +): ExportedShapeDescriptor { + if (!shape) { + return { + filename: null, + key: settings.shapeKey, + kind: 'builtin', + label: settings.shapeKey, + loader: null, + }; + } + + const effectiveImportedFilename = + modelFilenameOverride ?? importedFile?.name ?? shape.filename ?? null; + + return { + filename: + shape.kind === 'imported' + ? effectiveImportedFilename + : (shape.filename ?? null), + key: shape.key, + kind: shape.kind, + label: + shape.kind === 'imported' + ? (effectiveImportedFilename ?? shape.label) + : shape.label, + loader: shape.loader ?? null, + }; +} + +function getModelMimeType( + file: File, + loader: HalftoneGeometrySpec['loader'] | null, +) { + if (file.type) { + return file.type; + } + + if (loader === 'glb' || file.name.toLowerCase().endsWith('.glb')) { + return 'model/gltf-binary'; + } + + if (loader === 'fbx' || file.name.toLowerCase().endsWith('.fbx')) { + return 'application/octet-stream'; + } + + return 'application/octet-stream'; +} + +async function fileToDataUrl( + file: File, + loader: HalftoneGeometrySpec['loader'] | null, +) { + const buffer = await file.arrayBuffer(); + const blob = new Blob([buffer], { + type: getModelMimeType(file, loader), + }); + + return await new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + + reader.onerror = () => { + reject(reader.error ?? new Error(`Unable to read ${file.name}.`)); + }; + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error(`Unable to encode ${file.name}.`)); + return; + } + + resolve(reader.result); + }; + + reader.readAsDataURL(blob); + }); +} + +function serializeRuntimeSource( + settings: HalftoneStudioSettings, + shape: ExportedShapeDescriptor, + initialPose: HalftoneExportPose, + previewDistance: number | undefined, +) { + const isImageMode = settings.sourceMode === 'image'; + const normalizedPreviewDistance = normalizePreviewDistance(previewDistance); + + return ` +const settings = ${JSON.stringify(settings, null, 2)}; +const shape = ${JSON.stringify(shape, null, 2)}; +const initialPose = ${JSON.stringify(initialPose, null, 2)}; +const previewDistance = ${JSON.stringify(normalizedPreviewDistance)}; +const VIRTUAL_RENDER_HEIGHT = ${VIRTUAL_RENDER_HEIGHT}; +const passThroughVertexShader = ${JSON.stringify(passThroughVertexShader)}; +const blurFragmentShader = ${JSON.stringify(blurFragmentShader)}; +const halftoneFragmentShader = ${JSON.stringify(halftoneFragmentShader)}; +${isImageMode ? `const imagePassthroughFragmentShader = ${JSON.stringify(imagePassthroughFragmentShader)};` : ''} + +${HALFTONE_FOOTPRINT_RUNTIME_SOURCE} + +${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE} + +${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)} + +${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE} + +function createRenderTarget(width, height) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function createInteractionState() { + return { + autoElapsed: initialPose.autoElapsed, + activePointerId: null, + dragging: false, + hoverStrength: 0, + mouseX: 0.5, + mouseY: 0.5, + pointerInside: false, + pointerVelocityX: 0, + pointerVelocityY: 0, + pointerX: 0, + pointerY: 0, + rotateElapsed: initialPose.rotateElapsed, + rotationX: initialPose.rotationX, + rotationVelocityX: 0, + rotationY: initialPose.rotationY, + rotationVelocityY: 0, + rotationZ: initialPose.rotationZ, + rotationVelocityZ: 0, + smoothedMouseX: 0.5, + smoothedMouseY: 0.5, + targetRotationX: initialPose.targetRotationX, + targetRotationY: initialPose.targetRotationY, + velocityX: 0, + velocityY: 0, + }; +} + +${ + isImageMode + ? '' + : ` +function setPrimaryLightPosition(light, angleDegrees, height) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set(Math.cos(lightAngle) * 5, height, Math.sin(lightAngle) * 5); +} + +${SPRING_STEP_RUNTIME_SOURCE} + +${RESET_INTERACTION_STATE_RUNTIME_SOURCE} + +async function createGeometry(modelUrl) { + if (shape.kind === 'imported') { + if (!modelUrl) { + throw new Error('No model URL was provided for ' + shape.label + '.'); + } + + return loadImportedGeometryFromUrl(modelUrl, shape.label); + } + + return createBuiltinGeometry(shape.key); +} +` +} +`; +} + +function createMountScript() { + return ` +async function mountHalftoneCanvas(options) { + const { + container, + modelUrl, + onError, + } = options; + +${emitSizingHelpers()} + + let geometry; + + try { + geometry = await createGeometry(modelUrl); + } catch (error) { + onError?.(error); + return () => {}; + } + +${emitRendererSetup("settings.animation.followDragEnabled ? 'grab' : 'default'")} + + const materialAssets = await createHalftoneMaterialAssets(renderer); + + const scene3d = new THREE.Scene(); + scene3d.background = null; + + const baseCameraDistance = previewDistance; + const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100); + camera.position.z = baseCameraDistance; + + const primaryLight = new THREE.DirectionalLight(0xffffff, settings.lighting.intensity); + setPrimaryLightPosition( + primaryLight, + settings.lighting.angleDegrees, + settings.lighting.height, + ); + scene3d.add(primaryLight); + + const fillLight = new THREE.DirectionalLight( + 0xffffff, + settings.lighting.fillIntensity, + ); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight( + 0xffffff, + settings.lighting.ambientIntensity, + ); + scene3d.add(ambientLight); + + const material = createHalftoneMaterial(); + applyHalftoneMaterialSettings(material, settings.material, materialAssets); + + const mesh = new THREE.Mesh(geometry, material); + scene3d.add(mesh); + + const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + +${emitBlurMaterials()} +${emitHalftoneMaterial({ cropToBounds: 0, hoverHalftoneRadius: '0.2', hoverLightRadius: '0.2', waveSpeed: '1' })} +${emitPostScenes()} + + const updateViewportUniforms = ( + logicalWidth, + logicalHeight, + effectWidth, + effectHeight, + ) => { + blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + halftoneMaterial.uniforms.effectResolution.value.set( + effectWidth, + effectHeight, + ); + halftoneMaterial.uniforms.logicalResolution.value.set( + logicalWidth, + logicalHeight, + ); + }; + + const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { + if (!mesh.geometry.boundingBox) { + mesh.geometry.computeBoundingBox(); + } + + if (!mesh.geometry.boundingBox) { + return 1; + } + + mesh.updateMatrixWorld(); + camera.updateMatrixWorld(); + + return getMeshFootprintScale({ + camera, + localBounds: mesh.geometry.boundingBox, + lookAtTarget, + meshMatrixWorld: mesh.matrixWorld, + viewportHeight, + viewportWidth, + }); + }; + + const interaction = createInteractionState(); + const autoRotateEnabled = settings.animation.autoRotateEnabled; + const followHoverEnabled = settings.animation.followHoverEnabled; + const followDragEnabled = settings.animation.followDragEnabled; + const rotateEnabled = settings.animation.rotateEnabled; + + const syncSize = () => { + const width = getWidth(); + const height = getHeight(); + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(virtualWidth, virtualHeight); + blurTargetA.setSize(virtualWidth, virtualHeight); + blurTargetB.setSize(virtualWidth, virtualHeight); + updateViewportUniforms( + virtualWidth, + virtualHeight, + virtualWidth, + virtualHeight, + ); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + const updatePointerPosition = (event) => { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + interaction.mouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, + 0, + 1, + ); + interaction.mouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, + 0, + 1, + ); + }; + + const handlePointerDown = (event) => { + updatePointerPosition(event); + if (!followDragEnabled) { + return; + } + + interaction.dragging = true; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + interaction.velocityX = 0; + interaction.velocityY = 0; + canvas.style.cursor = 'grabbing'; + }; + + const handlePointerMove = (event) => { + updatePointerPosition(event); + }; + + const handleWindowPointerMove = (event) => { + updatePointerPosition(event); + + if (!interaction.dragging || !followDragEnabled) { + return; + } + + const deltaX = (event.clientX - interaction.pointerX) * settings.animation.dragSens; + const deltaY = (event.clientY - interaction.pointerY) * settings.animation.dragSens; + interaction.velocityX = deltaY; + interaction.velocityY = deltaX; + interaction.targetRotationY += deltaX; + interaction.targetRotationX += deltaY; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + }; + + const handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + }; + + const handlePointerUp = () => { + interaction.dragging = false; + canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; + + if (!settings.animation.springReturnEnabled) { + return; + } + + const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); + interaction.rotationVelocityX += interaction.velocityX * springImpulse; + interaction.rotationVelocityY += interaction.velocityY * springImpulse; + interaction.rotationVelocityZ += interaction.velocityY * springImpulse * 0.12; + interaction.targetRotationX = 0; + interaction.targetRotationY = 0; + interaction.velocityX = 0; + interaction.velocityY = 0; + }; + + const handlePointerCancel = () => { + interaction.dragging = false; + interaction.velocityX = 0; + interaction.velocityY = 0; + canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; + handlePointerLeave(); + }; + + const handleWindowBlur = () => { + handlePointerUp(); + handlePointerLeave(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointermove', handleWindowPointerMove); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + const clock = new THREE.Timer(); + clock.connect(document); + let animationFrameId = 0; + + const renderFrame = (timestamp) => { + // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export + animationFrameId = window.requestAnimationFrame(renderFrame); + clock.update(timestamp); + + const delta = 1 / 60; + const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); + halftoneMaterial.uniforms.time.value = elapsedTime; + + let baseRotationX = initialPose.rotationX; + let baseRotationY = initialPose.rotationY; + let baseRotationZ = initialPose.rotationZ; + let meshOffsetY = 0; + let meshScale = 1; + let lightAngle = settings.lighting.angleDegrees; + let lightHeight = settings.lighting.height; + + if (autoRotateEnabled) { + interaction.autoElapsed += delta; + baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; + baseRotationX += Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; + } + + if (settings.animation.floatEnabled) { + const floatPhase = elapsedTime * settings.animation.floatSpeed; + const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; + + meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; + baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; + baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; + } + + if (settings.animation.breatheEnabled) { + meshScale *= + 1 + + Math.sin(elapsedTime * settings.animation.breatheSpeed) * + settings.animation.breatheAmount; + } + + if (rotateEnabled) { + interaction.rotateElapsed += delta; + const rotateProgress = settings.animation.rotatePingPong + ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * Math.PI + : interaction.rotateElapsed * settings.animation.rotateSpeed; + + if (settings.animation.rotatePreset === 'axis') { + const axisDirection = + settings.animation.rotateAxis.startsWith('-') ? -1 : 1; + const axisProgress = rotateProgress * axisDirection; + + if ( + settings.animation.rotateAxis === 'x' || + settings.animation.rotateAxis === 'xy' || + settings.animation.rotateAxis === '-x' || + settings.animation.rotateAxis === '-xy' + ) { + baseRotationX += axisProgress; + } + + if ( + settings.animation.rotateAxis === 'y' || + settings.animation.rotateAxis === 'xy' || + settings.animation.rotateAxis === '-y' || + settings.animation.rotateAxis === '-xy' + ) { + baseRotationY += axisProgress; + } + + if ( + settings.animation.rotateAxis === 'z' || + settings.animation.rotateAxis === '-z' + ) { + baseRotationZ += axisProgress; + } + } else if (settings.animation.rotatePreset === 'lissajous') { + baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; + baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; + baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; + } else if (settings.animation.rotatePreset === 'orbit') { + baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; + baseRotationY += Math.cos(rotateProgress) * 1.2; + baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; + } else if (settings.animation.rotatePreset === 'tumble') { + baseRotationX += rotateProgress * 0.55; + baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; + baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; + } + } + + if (settings.animation.lightSweepEnabled) { + const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; + lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; + lightHeight += + Math.cos(lightPhase * 0.85) * + settings.animation.lightSweepHeightRange; + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (followHoverEnabled) { + const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180; + + if ( + settings.animation.hoverReturn || + interaction.mouseX !== 0.5 || + interaction.mouseY !== 0.5 + ) { + targetX += (interaction.mouseY - 0.5) * rangeRadians; + targetY += (interaction.mouseX - 0.5) * rangeRadians; + } + + easing = settings.animation.hoverEase; + } + + if (followDragEnabled) { + if (!interaction.dragging && settings.animation.dragMomentum) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= 1 - settings.animation.dragFriction; + interaction.velocityY *= 1 - settings.animation.dragFriction; + } + + targetX += interaction.targetRotationX; + targetY += interaction.targetRotationY; + easing = settings.animation.dragFriction; + } + + if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { + targetX = baseRotationX + interaction.targetRotationX; + targetY = baseRotationY + interaction.targetRotationY; + + if (interaction.dragging) { + targetX = interaction.targetRotationX; + targetY = interaction.targetRotationY; + } + + easing = 0.08; + } + + if (settings.animation.springReturnEnabled) { + const springX = applySpringStep( + interaction.rotationX, + targetX, + interaction.rotationVelocityX, + settings.animation.springStrength, + settings.animation.springDamping, + ); + const springY = applySpringStep( + interaction.rotationY, + targetY, + interaction.rotationVelocityY, + settings.animation.springStrength, + settings.animation.springDamping, + ); + const springZ = applySpringStep( + interaction.rotationZ, + baseRotationZ, + interaction.rotationVelocityZ, + settings.animation.springStrength, + settings.animation.springDamping, + ); + + interaction.rotationX = springX.value; + interaction.rotationY = springY.value; + interaction.rotationZ = springZ.value; + interaction.rotationVelocityX = springX.velocity; + interaction.rotationVelocityY = springY.velocity; + interaction.rotationVelocityZ = springZ.velocity; + } else { + interaction.rotationX += (targetX - interaction.rotationX) * easing; + interaction.rotationY += (targetY - interaction.rotationY) * easing; + interaction.rotationZ += + (baseRotationZ - interaction.rotationZ) * + (settings.animation.rotatePingPong ? 0.18 : 0.12); + } + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, + ); + mesh.position.y = meshOffsetY; + mesh.scale.setScalar(meshScale); + + if (settings.animation.cameraParallaxEnabled) { + const cameraRange = settings.animation.cameraParallaxAmount; + const cameraEase = settings.animation.cameraParallaxEase; + const centeredX = (interaction.mouseX - 0.5) * 2; + const centeredY = (0.5 - interaction.mouseY) * 2; + const orbitYaw = centeredX * cameraRange; + const orbitPitch = centeredY * cameraRange * 0.7; + const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; + const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; + const targetCameraY = + Math.sin(orbitPitch) * baseCameraDistance * 0.85; + const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; + + camera.position.x += (targetCameraX - camera.position.x) * cameraEase; + camera.position.y += (targetCameraY - camera.position.y) * cameraEase; + camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; + } else { + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; + } + + const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); + + camera.lookAt(lookAtTarget); + setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); + halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( + getVirtualWidth(), + getVirtualHeight(), + lookAtTarget, + ); + + if (!settings.halftone.enabled) { + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(scene3d, camera); + return; + } + + renderer.setRenderTarget(sceneTarget); + renderer.render(scene3d, camera); +${emitBlurPasses()} + }; + + renderFrame(); + +${emitCleanup( + ` material.dispose();\n disposeHalftoneMaterialAssets(materialAssets);`, + ` canvas.removeEventListener('pointermove', handlePointerMove);\n canvas.removeEventListener('pointerleave', handlePointerLeave);\n window.removeEventListener('pointerup', handlePointerUp);\n window.removeEventListener('pointermove', handleWindowPointerMove);\n canvas.removeEventListener('pointercancel', handlePointerCancel);\n window.removeEventListener('blur', handleWindowBlur);\n canvas.removeEventListener('pointerdown', handlePointerDown);`, +)} +} +`; +} + +function createImageMountScript() { + return ` +async function mountHalftoneCanvas(options) { + const { + container, + imageUrl, + onError, + } = options; + +${emitSizingHelpers()} + + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = imageUrl; + }); + +${emitRendererSetup('default')} + + const imageTexture = new THREE.Texture(image); + imageTexture.colorSpace = THREE.SRGBColorSpace; + imageTexture.needsUpdate = true; + + const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight()); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const imageMaterial = new THREE.ShaderMaterial({ + uniforms: { + tImage: { value: imageTexture }, + imageSize: { value: new THREE.Vector2(image.width, image.height) }, + viewportSize: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, + zoom: { value: getImagePreviewZoom(previewDistance) }, + contrast: { value: settings.halftone.imageContrast }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: imagePassthroughFragmentShader, + }); + + const imageScene = new THREE.Scene(); + imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); + +${emitBlurMaterials()} +${emitHalftoneMaterial({ cropToBounds: 1, hoverHalftoneRadius: 'settings.animation.hoverHalftoneRadius', hoverLightRadius: 'settings.animation.hoverLightRadius', waveSpeed: 'settings.animation.waveSpeed' })} +${emitPostScenes()} + + const updateViewportUniforms = ( + logicalWidth, + logicalHeight, + effectWidth, + effectHeight, + ) => { + blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + halftoneMaterial.uniforms.effectResolution.value.set( + effectWidth, + effectHeight, + ); + halftoneMaterial.uniforms.logicalResolution.value.set( + logicalWidth, + logicalHeight, + ); + imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); + }; + + const getHalftoneScale = () => + getImageFootprintScale({ + imageHeight: image.height, + imageWidth: image.width, + previewDistance, + viewportHeight: getVirtualHeight(), + viewportWidth: getVirtualWidth(), + }); + + const interaction = createInteractionState(); + const imagePointerFollow = ${IMAGE_POINTER_FOLLOW}; + const imagePointerVelocityDamping = ${IMAGE_POINTER_VELOCITY_DAMPING}; + const imageHoverFadeIn = ${IMAGE_HOVER_FADE_IN}; + const imageHoverFadeOut = ${IMAGE_HOVER_FADE_OUT}; + + const syncSize = () => { + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + sceneTarget.setSize(virtualWidth, virtualHeight); + blurTargetA.setSize(virtualWidth, virtualHeight); + blurTargetB.setSize(virtualWidth, virtualHeight); + updateViewportUniforms( + virtualWidth, + virtualHeight, + virtualWidth, + virtualHeight, + ); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + const updatePointerPosition = (event, options = {}) => { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + const nextMouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, 0, 1, + ); + const nextMouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, 0, 1, + ); + + const deltaX = nextMouseX - interaction.mouseX; + const deltaY = nextMouseY - interaction.mouseY; + + interaction.mouseX = nextMouseX; + interaction.mouseY = nextMouseY; + interaction.pointerInside = + interaction.dragging || + (event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom); + + if (options.resetVelocity) { + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + interaction.smoothedMouseX = nextMouseX; + interaction.smoothedMouseY = nextMouseY; + } else { + interaction.pointerVelocityX = deltaX; + interaction.pointerVelocityY = deltaY; + } + + return { deltaX, deltaY }; + }; + + const releasePointerCapture = (pointerId) => { + if (pointerId === null) { + return; + } + + if (!canvas.hasPointerCapture(pointerId)) { + return; + } + + try { + canvas.releasePointerCapture(pointerId); + } catch (error) { + void error; + } + }; + + const handlePointerDown = (event) => { + updatePointerPosition(event, { resetVelocity: true }); + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + }; + + const handlePointerMove = (event) => { + const resetVelocity = !interaction.pointerInside && !interaction.dragging; + updatePointerPosition( + event, + resetVelocity ? { resetVelocity: true } : undefined, + ); + }; + + const handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + }; + + const handlePointerUp = (event) => { + updatePointerPosition(event, { resetVelocity: true }); + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + const rect = canvas.getBoundingClientRect(); + interaction.pointerInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + }; + + const handlePointerCancel = () => { + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + }; + + const handleWindowBlur = () => { + handlePointerCancel(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerup', handlePointerUp); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + const clock = new THREE.Timer(); + clock.connect(document); + let animationFrameId = 0; + + const renderFrame = (timestamp) => { + // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export + animationFrameId = window.requestAnimationFrame(renderFrame); + clock.update(timestamp); + + const deltaSeconds = clock.getDelta(); + const elapsedTime = clock.getElapsed(); + halftoneMaterial.uniforms.time.value = elapsedTime; + const hoverEasing = + 1 - + Math.exp( + -deltaSeconds * + (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut), + ); + interaction.hoverStrength += + ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) * + hoverEasing; + + interaction.smoothedMouseX += + (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow; + interaction.smoothedMouseY += + (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow; + interaction.pointerVelocityX *= imagePointerVelocityDamping; + interaction.pointerVelocityY *= imagePointerVelocityDamping; + + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.smoothedMouseX, + 1 - interaction.smoothedMouseY, + ); + halftoneMaterial.uniforms.interactionVelocity.value.set( + interaction.pointerVelocityX * getVirtualWidth(), + -interaction.pointerVelocityY * getVirtualHeight(), + ); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + halftoneMaterial.uniforms.hoverHalftoneActive.value = + settings.animation.hoverHalftoneEnabled ? interaction.hoverStrength : 0; + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = + settings.animation.hoverHalftoneEnabled + ? settings.animation.hoverHalftonePowerShift + : 0; + halftoneMaterial.uniforms.hoverHalftoneRadius.value = + settings.animation.hoverHalftoneRadius; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = + settings.animation.hoverHalftoneEnabled + ? settings.animation.hoverHalftoneWidthShift + : 0; + halftoneMaterial.uniforms.hoverLightStrength.value = + settings.animation.hoverLightEnabled + ? settings.animation.hoverLightIntensity * interaction.hoverStrength + : 0; + halftoneMaterial.uniforms.hoverLightRadius.value = + settings.animation.hoverLightRadius; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); + halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); + + renderer.setRenderTarget(sceneTarget); + renderer.render(imageScene, orthographicCamera); +${emitBlurPasses()} + }; + + renderFrame(); + +${emitCleanup( + ` imageMaterial.dispose();\n imageTexture.dispose();`, + ` canvas.removeEventListener('pointermove', handlePointerMove);\n canvas.removeEventListener('pointerleave', handlePointerLeave);\n canvas.removeEventListener('pointerup', handlePointerUp);\n canvas.removeEventListener('pointercancel', handlePointerCancel);\n window.removeEventListener('blur', handleWindowBlur);\n canvas.removeEventListener('pointerdown', handlePointerDown);`, +)} +} +`; +} + +export function getExportedModelFile( + shape: HalftoneGeometrySpec | undefined, + importedFile: File | undefined, +) { + if (!shape || shape.kind !== 'imported' || !importedFile) { + return null; + } + + return importedFile; +} + +export function generateReactComponent( + settings: HalftoneStudioSettings, + selectedShape: HalftoneGeometrySpec | undefined, + componentName = 'HalftoneDashes', + options: ReactExportOptions = {}, +) { + const isImageMode = settings.sourceMode === 'image'; + const exportSettings = normalizeReactExportSettings(options.exportSettings); + const shape = createShapeDescriptor( + selectedShape, + settings, + options.importedFile, + options.modelFilenameOverride, + ); + const pose = normalizeExportPose(options.initialPose); + const normalizedComponentName = normalizeExportComponentName(componentName); + const background = options.background ?? 'transparent'; + const assetUrl = + exportSettings.includePublicAssetUrl && options.assetUrl + ? options.assetUrl + : null; + const defaultModelFilename = + options.modelFilenameOverride ?? shape.filename ?? 'model.glb'; + const defaultImageFilename = options.imageFilename ?? 'image.png'; + const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`; + const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`; + const importBlock = getReactImportBlock(exportSettings, isImageMode, shape); + const headerComment = exportSettings.includeRegistryComment + ? getTwentyReactHeaderComment( + normalizedComponentName, + toIllustrationRegistryKey(normalizedComponentName), + isImageMode || shape.kind === 'imported' + ? (assetUrl ?? undefined) + : undefined, + ) + : ''; + const mountScript = isImageMode + ? createImageMountScript() + : createMountScript(); + const serializedRuntime = serializeRuntimeSource( + settings, + shape, + pose, + options.previewDistance, + ); + const generatedBanner = exportSettings.includeTsNoCheck + ? `/** + * @generated halftone-studio + * This file is autogenerated by the in-app halftone studio + * (src/app/halftone). Do NOT hand-edit — regenerate the export + * from the studio if the visual or runtime behaviour needs to change. + * + * Type checking is disabled at the file level because the serialized + * Three.js runtime below contains thousands of lines of legacy + * implicitly-typed code that the generator does not annotate. See + * src/app/halftone/_lib/exporters.ts for the generator source. + */ +// @ts-nocheck` + : null; + const directiveLines = [ + generatedBanner, + exportSettings.includeUseClientDirective ? "'use client';" : null, + ] + .filter((line): line is string => line !== null) + .join('\n'); + const mountStyleBlock = exportSettings.includeStyledMount + ? `const StyledVisualMount = styled.div\` + background: ${background}; + display: block; + height: 100%; + min-width: 0; + width: 100%; +\`; +` + : ''; + const assetPropName = isImageMode + ? 'imageUrl' + : shape.kind === 'imported' + ? 'modelUrl' + : null; + const assetPropDefaultValue = + assetPropName === 'imageUrl' + ? defaultImageUrl + : assetPropName === 'modelUrl' + ? defaultModelUrl + : null; + const propsTypeBlock = assetPropName + ? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};` + : `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`; + const propsSignature = assetPropName + ? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props` + : `{\n style,\n}: ${normalizedComponentName}Props`; + const mountOptionsBlock = assetPropName + ? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });` + : `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`; + const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]'; + const returnBlock = exportSettings.includeStyledMount + ? `return <StyledVisualMount aria-hidden ref={mountReference} style={style} />;` + : `return ( + <div + ref={mountReference} + style={{ + background: ${JSON.stringify(background)}, + height: '100%', + width: '100%', + ...style, + }} + /> + );`; + const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport + ? `export function ${normalizedComponentName}(${propsSignature}) { + const mountReference = useRef<HTMLDivElement>(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + ${mountOptionsBlock} + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, ${effectDependencies}); + + ${returnBlock} +} + +export default ${normalizedComponentName};` + : `export default function ${normalizedComponentName}(${propsSignature}) { + const mountReference = useRef<HTMLDivElement>(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + ${mountOptionsBlock} + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, ${effectDependencies}); + + ${returnBlock} +}`; + + return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock} + +${headerComment}${serializedRuntime} + +${mountScript} + +${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock} + +${componentFunctionBlock} +`; +} + +export async function generateStandaloneHtml( + settings: HalftoneStudioSettings, + selectedShape: HalftoneGeometrySpec | undefined, + componentName = 'HalftoneDashes', + options: ReactExportOptions = {}, +) { + const isImageMode = settings.sourceMode === 'image'; + const shape = createShapeDescriptor( + selectedShape, + settings, + options.importedFile, + options.modelFilenameOverride, + ); + const pose = normalizeExportPose(options.initialPose); + const normalizedComponentName = normalizeExportComponentName(componentName); + const defaultImageUrl = options.imageFilename ?? 'image.png'; + const embeddedImportedModelUrl = + !isImageMode && shape.kind === 'imported' && options.importedFile + ? await fileToDataUrl(options.importedFile, shape.loader) + : null; + const defaultModelUrl = + embeddedImportedModelUrl ?? + options.modelFilenameOverride ?? + shape.filename ?? + 'model.glb'; + const background = options.background ?? 'transparent'; + + const threeImports = getStandaloneThreeImports(isImageMode, shape); + + const mountScript = isImageMode + ? createImageMountScript() + : createMountScript(); + + const mountCall = isImageMode + ? `mountHalftoneCanvas({ + container, + imageUrl: ${JSON.stringify(`./${defaultImageUrl}`)}, + onError: (error) => { + console.error(error); + }, + });` + : `mountHalftoneCanvas({ + container, + modelUrl: ${JSON.stringify(`./${defaultModelUrl}`)}, + onError: (error) => { + console.error(error); + }, + });`; + + const captionText = isImageMode + ? `Place <code>${defaultImageUrl}</code> next to this HTML file.` + : `Standalone export of the current halftone scene. + ${ + shape.kind === 'imported' + ? embeddedImportedModelUrl + ? 'The uploaded model is embedded directly in this HTML file.' + : `Place <code>${defaultModelUrl}</code> next to this HTML file to keep the current uploaded shape.` + : '' + }`; + + return `<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>${normalizedComponentName} + + + + +
    +
    +
    + ${captionText} +
    +
    + + + + +`; +} diff --git a/packages/twenty-website-new/src/lib/halftone/footprint.ts b/packages/twenty-website-new/src/lib/halftone/utils/footprint.ts similarity index 53% rename from packages/twenty-website-new/src/lib/halftone/footprint.ts rename to packages/twenty-website-new/src/lib/halftone/utils/footprint.ts index c5ac2f4e51..debc85963e 100644 --- a/packages/twenty-website-new/src/lib/halftone/footprint.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/footprint.ts @@ -33,7 +33,7 @@ export interface MeshFootprintScaleArgs extends ProjectedBoundsArgs { export const VIRTUAL_RENDER_HEIGHT = 768; export const REFERENCE_PREVIEW_DISTANCE = 4; -const MIN_FOOTPRINT_SCALE = 0.001; +export const MIN_FOOTPRINT_SCALE = 0.001; function clampRectToViewport( rect: HalftoneRect, @@ -277,244 +277,3 @@ export function getMeshFootprintScale({ return getFootprintScaleFromRects(currentRect, referenceRect); } - -export const HALFTONE_FOOTPRINT_RUNTIME_SOURCE = String.raw` -const REFERENCE_PREVIEW_DISTANCE = ${REFERENCE_PREVIEW_DISTANCE}; -const MIN_FOOTPRINT_SCALE = ${MIN_FOOTPRINT_SCALE}; - -function clampRectToViewport(rect, viewportWidth, viewportHeight) { - const minX = Math.max(rect.x, 0); - const minY = Math.max(rect.y, 0); - const maxX = Math.min(rect.x + rect.width, viewportWidth); - const maxY = Math.min(rect.y + rect.height, viewportHeight); - - if (maxX <= minX || maxY <= minY) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -function getRectArea(rect) { - if (!rect) { - return 0; - } - - return Math.max(rect.width, 0) * Math.max(rect.height, 0); -} - -function createBox3Corners(bounds) { - const { min, max } = bounds; - - return [ - new THREE.Vector3(min.x, min.y, min.z), - new THREE.Vector3(min.x, min.y, max.z), - new THREE.Vector3(min.x, max.y, min.z), - new THREE.Vector3(min.x, max.y, max.z), - new THREE.Vector3(max.x, min.y, min.z), - new THREE.Vector3(max.x, min.y, max.z), - new THREE.Vector3(max.x, max.y, min.z), - new THREE.Vector3(max.x, max.y, max.z), - ]; -} - -function getImagePreviewZoom(previewDistance) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - -function getContainedImageRect({ - imageFit = 'contain', - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom, -}) { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - const imageAspect = imageWidth / imageHeight; - const viewAspect = viewportWidth / viewportHeight; - - let fittedWidth = viewportWidth; - let fittedHeight = viewportHeight; - - if (imageAspect > viewAspect) { - if (imageFit === 'cover') { - fittedWidth = viewportHeight * imageAspect; - } else { - fittedHeight = viewportWidth / imageAspect; - } - } else { - if (imageFit === 'cover') { - fittedHeight = viewportWidth / imageAspect; - } else { - fittedWidth = viewportHeight * imageAspect; - } - } - - const scaledWidth = fittedWidth * zoom; - const scaledHeight = fittedHeight * zoom; - - return clampRectToViewport( - { - x: (viewportWidth - scaledWidth) * 0.5, - y: (viewportHeight - scaledHeight) * 0.5, - width: scaledWidth, - height: scaledHeight, - }, - viewportWidth, - viewportHeight, - ); -} - -function getFootprintScaleFromRects(currentRect, referenceRect) { - const currentArea = getRectArea(currentRect); - const referenceArea = getRectArea(referenceRect); - - if (currentArea <= 0 || referenceArea <= 0) { - return 1; - } - - return Math.max( - Math.sqrt(currentArea / referenceArea), - MIN_FOOTPRINT_SCALE, - ); -} - -function getImageFootprintScale({ - imageFit = 'contain', - imageHeight, - imageWidth, - previewDistance, - viewportHeight, - viewportWidth, -}) { - const currentRect = getContainedImageRect({ - imageFit, - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: getImagePreviewZoom(previewDistance), - }); - const referenceRect = getContainedImageRect({ - imageFit, - imageHeight, - imageWidth, - viewportHeight, - viewportWidth, - zoom: 1, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} - -function projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - if ( - localBounds.isEmpty() || - viewportWidth <= 0 || - viewportHeight <= 0 - ) { - return null; - } - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let hasProjectedCorner = false; - - for (const corner of createBox3Corners(localBounds)) { - corner.applyMatrix4(meshMatrixWorld).project(camera); - - if ( - !Number.isFinite(corner.x) || - !Number.isFinite(corner.y) || - !Number.isFinite(corner.z) - ) { - continue; - } - - hasProjectedCorner = true; - - const x = (corner.x * 0.5 + 0.5) * viewportWidth; - const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - if (!hasProjectedCorner) { - return null; - } - - return clampRectToViewport( - { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }, - viewportWidth, - viewportHeight, - ); -} - -function getMeshFootprintScale({ - camera, - localBounds, - lookAtTarget, - meshMatrixWorld, - viewportHeight, - viewportWidth, -}) { - const currentRect = projectBox3ToViewport({ - camera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - const referenceCamera = camera.clone(); - const currentOffset = referenceCamera.position.clone().sub(lookAtTarget); - const referenceOffset = - currentOffset.lengthSq() > 0 - ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE) - : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE); - - referenceCamera.position.copy(lookAtTarget).add(referenceOffset); - referenceCamera.lookAt(lookAtTarget); - referenceCamera.updateProjectionMatrix(); - referenceCamera.updateMatrixWorld(true); - - const referenceRect = projectBox3ToViewport({ - camera: referenceCamera, - localBounds, - meshMatrixWorld, - viewportHeight, - viewportWidth, - }); - - return getFootprintScaleFromRects(currentRect, referenceRect); -} -`; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts b/packages/twenty-website-new/src/lib/halftone/utils/formatters.ts similarity index 74% rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts rename to packages/twenty-website-new/src/lib/halftone/utils/formatters.ts index 005de23689..e5d6dd4265 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/formatters.ts @@ -84,29 +84,3 @@ export function formatAnimationName( return activeModes.length > 0 ? activeModes.join(' + ') : 'still'; } - -export function getBackgroundTone(background: { - color: string; - transparent: boolean; -}) { - if (background.transparent) { - return 'light'; - } - - const normalized = background.color.replace('#', ''); - const expanded = - normalized.length === 3 - ? normalized - .split('') - .map((character) => `${character}${character}`) - .join('') - : normalized; - - const value = Number.parseInt(expanded, 16); - const red = (value >> 16) & 255; - const green = (value >> 8) & 255; - const blue = value & 255; - const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255; - - return luminance < 0.5 ? 'dark' : 'light'; -} diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts new file mode 100644 index 0000000000..cf9a49d3ea --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts @@ -0,0 +1,392 @@ +import * as THREE from 'three'; +import { mergeGeometries } from './geometry-loaders'; + +function makePolarShape( + radiusFunction: (angle: number) => number, + segments = 320, +) { + const shape = new THREE.Shape(); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * Math.PI * 2; + const radius = radiusFunction(angle); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (segmentIndex === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + return shape; +} + +function makeReliefGeometry( + shape: THREE.Shape, + options: { + depth?: number; + bevelSize?: number; + bevelThickness?: number; + bevelSegments?: number; + waves?: number; + waveDepth?: number; + } = {}, +) { + const { + bevelSegments = 8, + bevelSize = 0.08, + bevelThickness = 0.1, + depth = 0.58, + waveDepth = 0.016, + waves = 8, + } = options; + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 2, + bevelEnabled: true, + bevelThickness, + bevelSize, + bevelSegments, + curveSegments: 96, + }); + + geometry.center(); + + const position = geometry.attributes.position; + let maxRadius = 0; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + maxRadius = Math.max( + maxRadius, + Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), + ); + } + + const fullDepth = depth + bevelThickness * 2; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + const x = position.getX(vertexIndex); + const y = position.getY(vertexIndex); + const z = position.getZ(vertexIndex); + const radius = Math.hypot(x, y) / maxRadius; + const angle = Math.atan2(y, x); + const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); + const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); + const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); + const wave = + Math.cos(angle * waves) * + Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * + waveDepth; + const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); + + position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); + } + + position.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function makeArrowTarget() { + const targetParts: THREE.BufferGeometry[] = []; + const arrowParts: THREE.BufferGeometry[] = []; + const baseRadius = 1.35; + const baseDepth = 0.32; + const bevel = 0.12; + const points: THREE.Vector2[] = []; + const segments = 16; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + -baseDepth / 2 + bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + baseDepth / 2 - bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + targetParts.push(disc); + + for (const radius of [0.45, 0.85, 1.22]) { + const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); + ring.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), + ); + targetParts.push(ring); + } + + const bump = new THREE.SphereGeometry( + 0.32, + 32, + 24, + 0, + Math.PI * 2, + 0, + Math.PI / 2, + ); + bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); + bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); + targetParts.push(bump); + + const shaftLength = 1.5; + const shaftRadius = 0.05; + const shaft = new THREE.CylinderGeometry( + shaftRadius, + shaftRadius, + shaftLength, + 10, + 1, + ); + shaft.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), + ); + arrowParts.push(shaft); + + const head = new THREE.ConeGeometry(0.12, 0.35, 10); + head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); + arrowParts.push(head); + + for (let finIndex = 0; finIndex < 3; finIndex += 1) { + const finShape = new THREE.Shape(); + finShape.moveTo(0, 0); + finShape.lineTo(0.22, 0.25); + finShape.lineTo(0, 0.5); + finShape.lineTo(0, 0); + + const finGeometry = new THREE.ExtrudeGeometry(finShape, { + depth: 0.012, + bevelEnabled: false, + }); + + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), + ); + arrowParts.push(finGeometry); + } + + const nock = new THREE.SphereGeometry(0.065, 8, 8); + nock.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), + ); + arrowParts.push(nock); + + const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); + const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); + const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); + + for (const geometry of arrowParts) { + geometry.applyMatrix4(aim); + geometry.applyMatrix4(tilt); + geometry.applyMatrix4(shift); + } + + const merged = mergeGeometries([...targetParts, ...arrowParts]); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function makeDollarCoin() { + const parts: THREE.BufferGeometry[] = []; + const baseRadius = 1.3; + const baseDepth = 0.45; + const bevel = 0.18; + const points: THREE.Vector2[] = []; + const segments = 20; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + Math.sin(angle) * (baseDepth / 2), + ), + ); + } + + points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + parts.push(disc); + + const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + frontRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), + ); + parts.push(frontRim); + + const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + backRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), + ); + parts.push(backRim); + + const createDollarSign = () => { + const geometries: THREE.BufferGeometry[] = []; + const tubeRadius = 0.1; + const curveRadius = 0.28; + const verticalOffset = 0.22; + + const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); + geometries.push(bar); + + const topArc = new THREE.TorusGeometry( + curveRadius, + tubeRadius, + 16, + 32, + Math.PI, + ); + topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), + ); + geometries.push(topArc); + + const bottomArc = new THREE.TorusGeometry( + curveRadius, + tubeRadius, + 16, + 32, + Math.PI, + ); + bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); + bottomArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), + ); + geometries.push(bottomArc); + + const topSerif = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + 0.22, + 12, + ); + topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation( + 0.16, + verticalOffset + curveRadius, + 0, + ), + ); + geometries.push(topSerif); + + const bottomSerif = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + 0.22, + 12, + ); + bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + bottomSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation( + -0.16, + -verticalOffset - curveRadius, + 0, + ), + ); + geometries.push(bottomSerif); + + const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); + const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); + const diagonal = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + diagonalLength + 0.12, + 12, + ); + diagonal.applyMatrix4( + new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), + ); + geometries.push(diagonal); + + return geometries; + }; + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), + ); + parts.push(geometry); + } + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), + ); + parts.push(geometry); + } + + const merged = mergeGeometries(parts); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +export const BUILTIN_GEOMETRIES: Record THREE.BufferGeometry> = { + torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32), + sphere: () => new THREE.SphereGeometry(1.4, 64, 64), + torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100), + icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4), + box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6), + cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10), + cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10), + octahedron: () => new THREE.OctahedronGeometry(1.5, 2), + dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1), + tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1), + sunCoin: () => + makeReliefGeometry( + makePolarShape( + (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), + ), + { depth: 0.62, waves: 12, waveDepth: 0.018 }, + ), + lotusCoin: () => + makeReliefGeometry( + makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), + { depth: 0.64, waves: 8, waveDepth: 0.014 }, + ), + arrowTarget: () => makeArrowTarget(), + dollarCoin: () => makeDollarCoin(), +}; diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts new file mode 100644 index 0000000000..6763cbf7e8 --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts @@ -0,0 +1,94 @@ +import type * as THREE from 'three'; +import { + loadImportedGeometry, + type HalftoneModelLoader, +} from './geometry-loaders'; +import { BUILTIN_GEOMETRIES } from './geometry-builtins'; + +interface HalftoneGeometrySpec { + key: string; + label: string; + kind: 'builtin' | 'imported'; + loader?: HalftoneModelLoader; + filename?: string; + description?: string; + extensions?: readonly string[]; + userProvided?: boolean; +} + +export type GeometryCacheEntry = + | THREE.BufferGeometry + | Promise; + +async function createGeometry( + spec: HalftoneGeometrySpec, + file: File | undefined, +) { + if (spec.kind === 'imported') { + if (!file || !spec.loader) { + throw new Error(`No file is available for ${spec.label}.`); + } + + return loadImportedGeometry( + spec.loader as HalftoneModelLoader, + file, + spec.label, + ); + } + + const factory = BUILTIN_GEOMETRIES[spec.key]; + + if (!factory) { + throw new Error(`Unsupported geometry: ${spec.key}.`); + } + + return factory(); +} + +export function createFallbackGeometry() { + return BUILTIN_GEOMETRIES.torusKnot(); +} + +export async function getGeometryForSpec( + spec: HalftoneGeometrySpec, + file: File | undefined, + cache: Map, +) { + const cached = cache.get(spec.key); + + if (cached) { + return cached instanceof Promise ? cached : Promise.resolve(cached); + } + + const created = createGeometry(spec, file); + + if (created instanceof Promise) { + const pending = created + .then((geometry) => { + cache.set(spec.key, geometry); + return geometry; + }) + .catch((error) => { + cache.delete(spec.key); + throw error; + }); + + cache.set(spec.key, pending); + + return pending; + } + + cache.set(spec.key, created); + + return Promise.resolve(created); +} + +export function disposeGeometryCache(cache: Map) { + for (const value of cache.values()) { + if (!(value instanceof Promise)) { + value.dispose(); + } + } + + cache.clear(); +} diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts new file mode 100644 index 0000000000..84c330e24d --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts @@ -0,0 +1,364 @@ +import * as THREE from 'three'; +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; +import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/utils/draco-decoder-path'; + +export type HalftoneModelLoader = 'fbx' | 'glb'; + +export type ImportedGeometryNormalizationOptions = { + postRotateZ?: number; + scaleTarget?: number; + useLegacyNormalization?: boolean; +}; + +const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75; + +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +export function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for ( + let vertexIndex = 0; + vertexIndex < geometryInfo.vertexCount; + vertexIndex += 1 + ) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function normalizeImportedGeometry( + geometry: THREE.BufferGeometry, + options: ImportedGeometryNormalizationOptions = {}, +) { + const { + postRotateZ = 0, + scaleTarget, + useLegacyNormalization = false, + } = options; + + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + if (!useLegacyNormalization) { + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = useLegacyNormalization + ? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) / + Math.max(size.x, size.y, size.z, 0.001) + : (scaleTarget ?? 1.6) / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + if (postRotateZ !== 0) { + geometry.rotateZ(postRotateZ); + } + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + + return loadingManager; +} + +function extractMergedGeometry( + root: THREE.Object3D, + emptyMessage: string, + geometryOptions?: ImportedGeometryNormalizationOptions, +) { + root.updateMatrixWorld(true); + + const geometries: THREE.BufferGeometry[] = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry( + mergeGeometries(geometries), + geometryOptions, + ); +} + +function parseFbxGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, +) { + const originalWarn = console.warn; + + console.warn = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse( + buffer, + resourcePath, + ); + + return extractMergedGeometry( + root, + `${label} did not contain any mesh geometry.`, + geometryOptions, + ); + } finally { + console.warn = originalWarn; + } +} + +function parseGlbGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, +) { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + resourcePath, + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + `${label} did not contain any mesh geometry.`, + geometryOptions, + ), + ); + } catch (error) { + reject(error); + } + }, + reject, + ); + }).finally(() => { + dracoLoader.dispose(); + }); +} + +export async function loadImportedGeometry( + loader: HalftoneModelLoader, + file: File, + label: string, +) { + const buffer = await file.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, '', label); + } + + return parseGlbGeometry(buffer, '', label); +} + +export async function loadImportedGeometryFromUrl( + loader: HalftoneModelLoader, + modelUrl: string, + label: string, + geometryOptions?: ImportedGeometryNormalizationOptions, +) { + const cacheKey = getImportedGeometryCacheKey({ + geometryOptions, + loader, + modelUrl, + }); + const cachedGeometry = importedGeometryCache.get(cacheKey); + + if (cachedGeometry) { + return (await cachedGeometry).clone(); + } + + const geometryPromise = (async () => { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error(`Unable to load ${label} from ${modelUrl}.`); + } + + const buffer = await response.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, '', label, geometryOptions); + } + + return parseGlbGeometry(buffer, '', label, geometryOptions); + })(); + importedGeometryCache.set(cacheKey, geometryPromise); + + try { + const geometry = await geometryPromise; + + importedGeometryCache.set(cacheKey, geometry); + + return geometry.clone(); + } catch (error) { + importedGeometryCache.delete(cacheKey); + throw error; + } +} + +type GeometryUrlCacheEntry = + | THREE.BufferGeometry + | Promise; + +const importedGeometryCache = new Map(); + +function getImportedGeometryCacheKey({ + geometryOptions, + loader, + modelUrl, +}: { + geometryOptions?: ImportedGeometryNormalizationOptions; + loader: HalftoneModelLoader; + modelUrl: string; +}) { + return JSON.stringify({ + geometryOptions: geometryOptions ?? null, + loader, + modelUrl, + }); +} diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts new file mode 100644 index 0000000000..4877ce2a5c --- /dev/null +++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts @@ -0,0 +1,11 @@ +export { + type ImportedGeometryNormalizationOptions, + loadImportedGeometryFromUrl, +} from './geometry-loaders'; + +export { + type GeometryCacheEntry, + createFallbackGeometry, + disposeGeometryCache, + getGeometryForSpec, +} from './geometry-cache'; diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/glassEnvironmentData.ts b/packages/twenty-website-new/src/lib/halftone/utils/glass-environment-data.ts similarity index 100% rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/glassEnvironmentData.ts rename to packages/twenty-website-new/src/lib/halftone/utils/glass-environment-data.ts diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts b/packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts similarity index 98% rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts rename to packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts index 6018c1674d..e578d4ba62 100644 --- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts @@ -2,8 +2,8 @@ import { getContainedImageRect, getImageFootprintScale, getImagePreviewZoom, -} from '@/lib/halftone/footprint'; -import type { HalftoneStudioSettings } from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/footprint'; +import type { HalftoneStudioSettings } from '@/lib/halftone/utils/state'; type PixelBounds = { maxX: number; diff --git a/packages/twenty-website-new/src/lib/halftone/interaction-state.ts b/packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts similarity index 74% rename from packages/twenty-website-new/src/lib/halftone/interaction-state.ts rename to packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts index f65a72097c..e1d9812f5b 100644 --- a/packages/twenty-website-new/src/lib/halftone/interaction-state.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts @@ -1,7 +1,7 @@ import type { HalftoneExportPose, HalftoneStudioSettings, -} from '@/lib/halftone/state'; +} from '@/lib/halftone/utils/state'; export type HalftoneInteractionState = { activePointerId: number | null; @@ -106,3 +106,26 @@ export const resetHalftoneInteractionState = ( interactionState.autoElapsed = 0; } }; + +// Runtime JS string equivalents for standalone exported HTML. +// Used by the exporter to avoid maintaining parallel inline copies. +export const SPRING_STEP_RUNTIME_SOURCE = ` +function applySpringStep(current, target, velocity, strength, damping) { + const nextVelocity = (velocity + (target - current) * strength) * damping; + return { value: current + nextVelocity, velocity: nextVelocity }; +}`; + +export const RESET_INTERACTION_STATE_RUNTIME_SOURCE = ` +function resetInteractionState(interactionState) { + interactionState.dragging = false; + interactionState.mouseX = 0.5; + interactionState.mouseY = 0.5; + interactionState.targetRotationX = 0; + interactionState.targetRotationY = 0; + interactionState.velocityX = 0; + interactionState.velocityY = 0; + interactionState.rotationVelocityX = 0; + interactionState.rotationVelocityY = 0; + interactionState.rotationVelocityZ = 0; + interactionState.autoElapsed = 0; +}`; diff --git a/packages/twenty-website-new/src/lib/halftone/materials.ts b/packages/twenty-website-new/src/lib/halftone/utils/materials.ts similarity index 99% rename from packages/twenty-website-new/src/lib/halftone/materials.ts rename to packages/twenty-website-new/src/lib/halftone/utils/materials.ts index 7f37cc1c9b..12564d709d 100644 --- a/packages/twenty-website-new/src/lib/halftone/materials.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/materials.ts @@ -1,4 +1,4 @@ -import type { HalftoneMaterialSettings } from '@/lib/halftone/state'; +import type { HalftoneMaterialSettings } from '@/lib/halftone/utils/state'; import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; import * as THREE from 'three'; diff --git a/packages/twenty-website-new/src/lib/halftone/state.ts b/packages/twenty-website-new/src/lib/halftone/utils/state.ts similarity index 99% rename from packages/twenty-website-new/src/lib/halftone/state.ts rename to packages/twenty-website-new/src/lib/halftone/utils/state.ts index 79c1b0a0e4..9bb48d09bd 100644 --- a/packages/twenty-website-new/src/lib/halftone/state.ts +++ b/packages/twenty-website-new/src/lib/halftone/utils/state.ts @@ -193,7 +193,7 @@ function upsertGeometrySpec( ); } -export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [ +const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [ { key: 'torusKnot', label: 'Torus Knot', kind: 'builtin' }, { key: 'sphere', label: 'Sphere', kind: 'builtin' }, { key: 'torus', label: 'Torus', kind: 'builtin' }, @@ -450,7 +450,7 @@ export function isRoundedBandHalftoneSettings(value: unknown): value is Omit< ); } -export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) { +function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) { return sourceMode === 'image' ? DEFAULT_IMAGE_HALFTONE_SETTINGS : DEFAULT_SHAPE_HALFTONE_SETTINGS; diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts index 7224111afa..8af84c0c4c 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts @@ -1,4 +1,4 @@ -import { detectLocale } from '../detect-locale'; +import { detectLocale } from '../utils/detect-locale'; describe('detectLocale', () => { it('returns the cookie value when it is a published website locale', () => { diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts index fb82bb0612..a78daea203 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts @@ -1,7 +1,7 @@ import { getEnglishLocaleName, getNativeLocaleName, -} from '../locale-display-names'; +} from '../utils/locale-display-names'; describe('getNativeLocaleName', () => { it('returns the language name in its own language with the first character upper-cased', () => { diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts index 3dcbd49a2d..52aae589a6 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts @@ -1,4 +1,4 @@ -import { localizeHref, stripLocale } from '../localize-href'; +import { localizeHref, stripLocale } from '../utils/localize-href'; describe('localizeHref', () => { it('does not emit locale prefixes for locales the website does not publish yet', () => { @@ -34,16 +34,12 @@ describe('localizeHref', () => { }); it('strips a redundant /en prefix when targeting the default locale', () => { - expect(localizeHref('en', '/en/resources/why-twenty')).toBe( - '/resources/why-twenty', - ); + expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty'); expect(localizeHref('en', '/en')).toBe('/'); }); it('strips an /en-prefixed path when targeting an unpublished locale', () => { - expect(localizeHref('de-DE', '/en/resources/why-twenty')).toBe( - '/resources/why-twenty', - ); + expect(localizeHref('de-DE', '/en/why-twenty')).toBe('/why-twenty'); expect(localizeHref('de-DE', '/en')).toBe('/'); }); @@ -85,9 +81,7 @@ describe('localizeHref', () => { describe('stripLocale', () => { it('removes a published-locale URL-segment prefix from the pathname', () => { - expect(stripLocale('/en/resources/why-twenty')).toBe( - '/resources/why-twenty', - ); + expect(stripLocale('/en/why-twenty')).toBe('/why-twenty'); expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots'); }); @@ -97,7 +91,7 @@ describe('stripLocale', () => { }); it('returns the pathname unchanged when no published locale prefix is present', () => { - expect(stripLocale('/resources/why-twenty')).toBe('/resources/why-twenty'); + expect(stripLocale('/why-twenty')).toBe('/why-twenty'); expect(stripLocale('/')).toBe('/'); expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo'); expect(stripLocale('/zh-CN')).toBe('/zh-CN'); diff --git a/packages/twenty-website-new/src/lib/i18n/I18nProvider.tsx b/packages/twenty-website-new/src/lib/i18n/components/I18nProvider.tsx similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/I18nProvider.tsx rename to packages/twenty-website-new/src/lib/i18n/components/I18nProvider.tsx diff --git a/packages/twenty-website-new/src/lib/i18n/LocaleContext.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocaleContext.tsx similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/LocaleContext.tsx rename to packages/twenty-website-new/src/lib/i18n/components/LocaleContext.tsx diff --git a/packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx similarity index 80% rename from packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx rename to packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx index 8d52b46a7e..aa774988b1 100644 --- a/packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx +++ b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx @@ -3,8 +3,8 @@ import Link from 'next/link'; import { type ComponentProps } from 'react'; -import { localizeHref } from './localize-href'; -import { useLocale } from './use-locale'; +import { localizeHref } from '../utils/localize-href'; +import { useLocale } from '../hooks/use-locale'; type LocalizedLinkProps = Omit, 'href'> & { href: string; diff --git a/packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx similarity index 87% rename from packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx rename to packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx index 22f1753df9..fa52857a55 100644 --- a/packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx +++ b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx @@ -5,7 +5,7 @@ import { type BaseButtonProps, buttonBaseStyles, } from '@/design-system/components/Button/BaseButton'; -import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType'; +import type { LinkButtonType } from '@/design-system/components/Button/types/link-button-type'; import { styled } from '@linaria/react'; import { LocalizedLink } from './LocalizedLink'; @@ -14,8 +14,7 @@ const StyledButtonLink = styled(LocalizedLink)` ${buttonBaseStyles} `; -export type LocalizedLinkButtonProps = Omit & - LinkButtonType; +type LocalizedLinkButtonProps = Omit & LinkButtonType; export function LocalizedLinkButton({ color, diff --git a/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts b/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts deleted file mode 100644 index 3f10838227..0000000000 --- a/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { I18n, MessageDescriptor } from '@lingui/core'; - -export const createMessageDescriptorRenderer = - (i18n: I18n) => - (descriptor: MessageDescriptor): string => - i18n._(descriptor); diff --git a/packages/twenty-website-new/src/lib/i18n/use-locale.ts b/packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts similarity index 86% rename from packages/twenty-website-new/src/lib/i18n/use-locale.ts rename to packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts index 81d16034a7..8b90030293 100644 --- a/packages/twenty-website-new/src/lib/i18n/use-locale.ts +++ b/packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { type AppLocale } from 'twenty-shared/translations'; -import { LocaleContext } from './LocaleContext'; +import { LocaleContext } from '../components/LocaleContext'; export const useLocale = (): AppLocale => { const locale = useContext(LocaleContext); diff --git a/packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts b/packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts similarity index 78% rename from packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts rename to packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts index a1a27d3afb..363da47d41 100644 --- a/packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts +++ b/packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts @@ -2,7 +2,7 @@ import { usePathname } from 'next/navigation'; -import { stripLocale } from './localize-href'; +import { stripLocale } from '../utils/localize-href'; export const useUnlocalizedPathname = (): string => { const pathname = usePathname(); diff --git a/packages/twenty-website-new/src/lib/i18n/index.ts b/packages/twenty-website-new/src/lib/i18n/index.ts index b614310740..f049594737 100644 --- a/packages/twenty-website-new/src/lib/i18n/index.ts +++ b/packages/twenty-website-new/src/lib/i18n/index.ts @@ -7,25 +7,24 @@ export { PUBLIC_APP_LOCALE_LIST, WEBSITE_LOCALE_LIST, isPublicAppLocale, -} from './app-locale-set'; -export { createI18nInstance } from './create-i18n-instance'; -export { createMessageDescriptorRenderer } from './create-message-descriptor-renderer'; -export { detectLocale, LOCALE_COOKIE_NAME } from './detect-locale'; -export { getRouteI18n, type LocaleRouteParams } from './get-route-i18n'; -export { I18nProvider } from './I18nProvider'; -export { LocaleContext } from './LocaleContext'; -export { LocalizedLink } from './LocalizedLink'; -export { LocalizedLinkButton } from './LocalizedLinkButton'; -export { localizeHref, stripLocale } from './localize-href'; +} from './utils/app-locale-set'; +export { createI18nInstance } from './utils/create-i18n-instance'; +export { getServerI18n } from './utils/get-server-i18n'; +export { detectLocale, LOCALE_COOKIE_NAME } from './utils/detect-locale'; +export { getRouteI18n, type LocaleRouteParams } from './utils/get-route-i18n'; +export { I18nProvider } from './components/I18nProvider'; +export { LocaleContext } from './components/LocaleContext'; +export { LocalizedLink } from './components/LocalizedLink'; +export { LocalizedLinkButton } from './components/LocalizedLinkButton'; +export { localizeHref, stripLocale } from './utils/localize-href'; export { getEnglishLocaleName, getNativeLocaleName, -} from './locale-display-names'; -export { resolveLocaleParam } from './resolve-locale-param'; +} from './utils/locale-display-names'; +export { resolveLocaleParam } from './utils/resolve-locale-param'; export { LOCALE_BY_URL_SEGMENT, localeToUrlSegment, -} from './website-locale-segments'; -export { useLocale } from './use-locale'; -export { useRenderMessage } from './use-render-message'; -export { useUnlocalizedPathname } from './use-unlocalized-pathname'; +} from './utils/website-locale-segments'; +export { useLocale } from './hooks/use-locale'; +export { useUnlocalizedPathname } from './hooks/use-unlocalized-pathname'; diff --git a/packages/twenty-website-new/src/lib/i18n/message-body.ts b/packages/twenty-website-new/src/lib/i18n/message-body.ts deleted file mode 100644 index f85cbc1817..0000000000 --- a/packages/twenty-website-new/src/lib/i18n/message-body.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MessageDescriptor } from '@lingui/core'; - -export type MessageBody = { - text: MessageDescriptor; -}; diff --git a/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts b/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts deleted file mode 100644 index b21c7b546f..0000000000 --- a/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MessageHeadingSegment } from './message-heading-segment'; - -export type MessageEyebrow = { - heading: MessageHeadingSegment; -}; diff --git a/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts b/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts deleted file mode 100644 index 670d4a3d70..0000000000 --- a/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MessageDescriptor } from '@lingui/core'; - -export type MessageHeadingSegment = { - fontFamily: 'sans' | 'serif' | 'mono'; - text: MessageDescriptor; - fontWeight?: 'light' | 'regular' | 'medium'; - newLine?: boolean; - lineBreakBefore?: boolean; -}; diff --git a/packages/twenty-website-new/src/lib/i18n/use-render-message.ts b/packages/twenty-website-new/src/lib/i18n/use-render-message.ts deleted file mode 100644 index a51c5de424..0000000000 --- a/packages/twenty-website-new/src/lib/i18n/use-render-message.ts +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import type { MessageDescriptor } from '@lingui/core'; -import { useLingui } from '@lingui/react'; -import { useCallback } from 'react'; - -export const useRenderMessage = () => { - const { i18n } = useLingui(); - return useCallback( - (descriptor: MessageDescriptor) => i18n._(descriptor), - [i18n], - ); -}; diff --git a/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts b/packages/twenty-website-new/src/lib/i18n/utils/app-locale-set.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/app-locale-set.ts rename to packages/twenty-website-new/src/lib/i18n/utils/app-locale-set.ts diff --git a/packages/twenty-website-new/src/lib/i18n/create-i18n-instance.ts b/packages/twenty-website-new/src/lib/i18n/utils/create-i18n-instance.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/create-i18n-instance.ts rename to packages/twenty-website-new/src/lib/i18n/utils/create-i18n-instance.ts diff --git a/packages/twenty-website-new/src/lib/i18n/detect-locale.ts b/packages/twenty-website-new/src/lib/i18n/utils/detect-locale.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/detect-locale.ts rename to packages/twenty-website-new/src/lib/i18n/utils/detect-locale.ts diff --git a/packages/twenty-website-new/src/lib/i18n/get-message-descriptor-source.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-message-descriptor-source.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/get-message-descriptor-source.ts rename to packages/twenty-website-new/src/lib/i18n/utils/get-message-descriptor-source.ts diff --git a/packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-route-i18n.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts rename to packages/twenty-website-new/src/lib/i18n/utils/get-route-i18n.ts diff --git a/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts new file mode 100644 index 0000000000..bab33d5a15 --- /dev/null +++ b/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts @@ -0,0 +1,13 @@ +import type { I18n } from '@lingui/core'; +import { getI18n } from '@lingui/react/server'; + +import { createI18nInstance } from './create-i18n-instance'; + +let fallback: I18n | null = null; + +export function getServerI18n(): I18n { + const instance = getI18n() as I18n | null; + if (instance) return instance; + if (!fallback) fallback = createI18nInstance('en'); + return fallback; +} diff --git a/packages/twenty-website-new/src/lib/i18n/locale-display-names.ts b/packages/twenty-website-new/src/lib/i18n/utils/locale-display-names.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/locale-display-names.ts rename to packages/twenty-website-new/src/lib/i18n/utils/locale-display-names.ts diff --git a/packages/twenty-website-new/src/lib/i18n/localize-href.ts b/packages/twenty-website-new/src/lib/i18n/utils/localize-href.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/localize-href.ts rename to packages/twenty-website-new/src/lib/i18n/utils/localize-href.ts diff --git a/packages/twenty-website-new/src/lib/i18n/messages-by-locale.ts b/packages/twenty-website-new/src/lib/i18n/utils/messages-by-locale.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/messages-by-locale.ts rename to packages/twenty-website-new/src/lib/i18n/utils/messages-by-locale.ts diff --git a/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts b/packages/twenty-website-new/src/lib/i18n/utils/resolve-locale-param.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts rename to packages/twenty-website-new/src/lib/i18n/utils/resolve-locale-param.ts diff --git a/packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/set-server-i18n.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts rename to packages/twenty-website-new/src/lib/i18n/utils/set-server-i18n.ts diff --git a/packages/twenty-website-new/src/lib/i18n/website-locale-list.ts b/packages/twenty-website-new/src/lib/i18n/utils/website-locale-list.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/website-locale-list.ts rename to packages/twenty-website-new/src/lib/i18n/utils/website-locale-list.ts diff --git a/packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts b/packages/twenty-website-new/src/lib/i18n/utils/website-locale-segments.ts similarity index 100% rename from packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts rename to packages/twenty-website-new/src/lib/i18n/utils/website-locale-segments.ts diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts b/packages/twenty-website-new/src/lib/react/__tests__/run-cleanup-tasks.test.ts similarity index 100% rename from packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts rename to packages/twenty-website-new/src/lib/react/__tests__/run-cleanup-tasks.test.ts diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts b/packages/twenty-website-new/src/lib/react/__tests__/timeout-registry.test.ts similarity index 100% rename from packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts rename to packages/twenty-website-new/src/lib/react/__tests__/timeout-registry.test.ts diff --git a/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts b/packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts similarity index 73% rename from packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts rename to packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts index c848d7d649..c1664a0e30 100644 --- a/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts +++ b/packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts @@ -1,6 +1,6 @@ -export type CleanupErrorHandler = (error: unknown) => void; +type CleanupErrorHandler = (error: unknown) => void; -export const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => { +const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => { if (process.env.NODE_ENV !== 'production') { console.error('Cleanup task failed:', error); } diff --git a/packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts b/packages/twenty-website-new/src/lib/react/timeout-registry.ts similarity index 100% rename from packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts rename to packages/twenty-website-new/src/lib/react/timeout-registry.ts diff --git a/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts index 18e3dfa259..361ef79d0d 100644 --- a/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts +++ b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts @@ -1,7 +1,7 @@ import { createTimeoutRegistry, type TimeoutRegistry, -} from '@/lib/lifecycle/timeout-registry'; +} from '@/lib/react/timeout-registry'; import { useEffect, useRef } from 'react'; export function useTimeoutRegistry(): TimeoutRegistry { diff --git a/packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts b/packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts similarity index 96% rename from packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts rename to packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts index 1669545ff9..27ab267aa9 100644 --- a/packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts +++ b/packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts @@ -1,4 +1,4 @@ -import { compareSemanticVersions } from '@/lib/semver/compare-semantic-versions'; +import { compareSemanticVersions } from '@/lib/releases/compare-semantic-versions'; function expectOrdered(versions: string[]) { for (let i = 0; i < versions.length - 1; i++) { diff --git a/packages/twenty-website-new/src/lib/semver/compare-semantic-versions.ts b/packages/twenty-website-new/src/lib/releases/compare-semantic-versions.ts similarity index 100% rename from packages/twenty-website-new/src/lib/semver/compare-semantic-versions.ts rename to packages/twenty-website-new/src/lib/releases/compare-semantic-versions.ts diff --git a/packages/twenty-website-new/src/lib/semver/get-formatted-release-number.ts b/packages/twenty-website-new/src/lib/releases/get-formatted-release-number.ts similarity index 100% rename from packages/twenty-website-new/src/lib/semver/get-formatted-release-number.ts rename to packages/twenty-website-new/src/lib/releases/get-formatted-release-number.ts diff --git a/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts b/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts index 16fbf6650a..0b865ba303 100644 --- a/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts +++ b/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts @@ -1,5 +1,5 @@ import type { LocalReleaseNote } from '@/lib/releases/types'; -import { getFormattedReleaseNumber } from '@/lib/semver/get-formatted-release-number'; +import { getFormattedReleaseNumber } from '@/lib/releases/get-formatted-release-number'; function safeReleaseNumber(version: string): number | null { try { diff --git a/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts b/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts index 333e6a3190..84cd9bb97e 100644 --- a/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts +++ b/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts @@ -4,7 +4,7 @@ import path from 'path'; import matter from 'gray-matter'; import type { LocalReleaseNote } from '@/lib/releases/types'; -import { compareSemanticVersions } from '@/lib/semver/compare-semantic-versions'; +import { compareSemanticVersions } from '@/lib/releases/compare-semantic-versions'; function normalizeFrontmatterDate(dateValue: unknown): string { if (typeof dateValue === 'string') { diff --git a/packages/twenty-website-new/src/lib/scroll/scroll-progress-effect.tsx b/packages/twenty-website-new/src/lib/scroll/ScrollProgressEffect.tsx similarity index 100% rename from packages/twenty-website-new/src/lib/scroll/scroll-progress-effect.tsx rename to packages/twenty-website-new/src/lib/scroll/ScrollProgressEffect.tsx diff --git a/packages/twenty-website-new/src/lib/scroll/index.ts b/packages/twenty-website-new/src/lib/scroll/index.ts index 17f104e1a8..c819e00fa4 100644 --- a/packages/twenty-website-new/src/lib/scroll/index.ts +++ b/packages/twenty-website-new/src/lib/scroll/index.ts @@ -1,4 +1,2 @@ -export { computeScrollProgress } from './compute-scroll-progress'; -export { ScrollProgressEffect } from './scroll-progress-effect'; +export { ScrollProgressEffect } from './ScrollProgressEffect'; export { useScheduledOnScroll } from './use-scheduled-on-scroll'; -export { useScrollProgress } from './use-scroll-progress'; diff --git a/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts b/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts index 883d098d97..6e7679d49b 100644 --- a/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts +++ b/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts @@ -2,6 +2,7 @@ import { useEffect, type RefObject } from 'react'; +import { createAnimationFrameLoop } from '@/lib/animation'; import { computeScrollProgress } from './compute-scroll-progress'; type UseScrollProgressOptions = { @@ -20,7 +21,7 @@ export function useScrollProgress( return; } - const handleScroll = () => { + const update = () => { const element = scrollContainerRef.current; if (!element) { return; @@ -37,12 +38,22 @@ export function useScrollProgress( onProgress(progress); }; - handleScroll(); - window.addEventListener('scroll', handleScroll, { passive: true }); - window.addEventListener('resize', handleScroll); + const scrollTask = createAnimationFrameLoop({ + onFrame: () => { + update(); + return false; + }, + }); + + const schedule = scrollTask.start; + + update(); + window.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule); return () => { - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleScroll); + window.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + scrollTask.stop(); }; }, [enabled, onProgress, scrollContainerRef]); } diff --git a/packages/twenty-website-new/src/lib/seo/JsonLd.tsx b/packages/twenty-website-new/src/lib/seo/JsonLd.tsx new file mode 100644 index 0000000000..c26817204d --- /dev/null +++ b/packages/twenty-website-new/src/lib/seo/JsonLd.tsx @@ -0,0 +1,18 @@ +type JsonLdPrimitive = boolean | number | string | null; + +export type JsonLdValue = + | JsonLdPrimitive + | JsonLdValue[] + | { [key: string]: JsonLdValue | undefined }; + +const serializeJsonLd = (data: JsonLdValue): string => + JSON.stringify(data).replace(/ + ); +} diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx b/packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx similarity index 93% rename from packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx rename to packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx index f89dcf4509..be2b14c9fe 100644 --- a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx +++ b/packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx @@ -54,12 +54,12 @@ describe('buildFaqPageJsonLd', () => { it('produces a FAQPage with one Question per input, each carrying an Answer', () => { const questions = [ { - question: { fontFamily: 'sans' as const, text: descriptor('Q1?') }, - answer: { text: descriptor('A1.') }, + question: descriptor('Q1?'), + answer: descriptor('A1.'), }, { - question: { fontFamily: 'sans' as const, text: descriptor('Q2?') }, - answer: { text: descriptor('A2.') }, + question: descriptor('Q2?'), + answer: descriptor('A2.'), }, ]; @@ -85,10 +85,8 @@ describe('buildFaqPageJsonLd', () => { const data = buildFaqPageJsonLd( [ { - question: { - text: { id: 'faq.q', message: 'Resolved question' }, - }, - answer: { text: { id: 'faq.a', message: 'Resolved answer' } }, + question: { id: 'faq.q', message: 'Resolved question' }, + answer: { id: 'faq.a', message: 'Resolved answer' }, }, ], renderText, diff --git a/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts index 67df59cb2b..1db487b790 100644 --- a/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; -import { resolveLocaleParam } from '@/lib/i18n/resolve-locale-param'; -import { setServerI18n } from '@/lib/i18n/set-server-i18n'; +import { resolveLocaleParam } from '@/lib/i18n/utils/resolve-locale-param'; +import { setServerI18n } from '@/lib/i18n/utils/set-server-i18n'; import { buildPageMetadata, diff --git a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts index acc00e3aca..c34305b7d3 100644 --- a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts @@ -4,16 +4,16 @@ import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; import { PUBLIC_APP_LOCALE_LIST, isPublicAppLocale, -} from '@/lib/i18n/app-locale-set'; -import { createI18nInstance } from '@/lib/i18n/create-i18n-instance'; -import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; -import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments'; +} from '@/lib/i18n/utils/app-locale-set'; +import { createI18nInstance } from '@/lib/i18n/utils/create-i18n-instance'; +import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments'; import type { MessageDescriptor } from '@lingui/core'; import { getSiteUrl } from './site-url'; const SITE_NAME = 'Twenty'; const TWITTER_HANDLE = '@twentycrm'; +const DEFAULT_OG_IMAGE_PATH = '/images/og/default.png'; export type BuildPageMetadataInput = { locale: AppLocale; @@ -66,20 +66,17 @@ export function buildPageMetadata({ const metadataLocale = isPublicAppLocale(locale) ? locale : SOURCE_LOCALE; const canonical = localizePath(metadataLocale, normalizedPath); const i18n = createI18nInstance(metadataLocale); - const renderText = createMessageDescriptorRenderer(i18n); - const resolvedTitle = renderText(title); - const resolvedDescription = renderText(description); + const resolvedTitle = i18n._(title); + const resolvedDescription = i18n._(description); - const ogImages = - ogImage === undefined - ? undefined - : [ - { - url: /^https?:\/\//i.test(ogImage) - ? ogImage - : `${siteUrl}${ogImage.startsWith('/') ? ogImage : `/${ogImage}`}`, - }, - ]; + const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE_PATH; + const ogImages = [ + { + url: /^https?:\/\//i.test(resolvedOgImage) + ? resolvedOgImage + : `${siteUrl}${resolvedOgImage.startsWith('/') ? resolvedOgImage : `/${resolvedOgImage}`}`, + }, + ]; const baseMetadata: Metadata = { title: { absolute: resolvedTitle }, @@ -95,7 +92,7 @@ export function buildPageMetadata({ siteName: SITE_NAME, locale: metadataLocale, type, - ...(ogImages && { images: ogImages }), + images: ogImages, }, twitter: { card: 'summary_large_image', @@ -103,7 +100,7 @@ export function buildPageMetadata({ description: resolvedDescription, site: TWITTER_HANDLE, creator: TWITTER_HANDLE, - ...(ogImages && { images: ogImages.map((image) => image.url) }), + images: ogImages.map((image) => image.url), }, }; diff --git a/packages/twenty-website-new/src/lib/seo/index.ts b/packages/twenty-website-new/src/lib/seo/index.ts index e3c863a8a5..a062929cbe 100644 --- a/packages/twenty-website-new/src/lib/seo/index.ts +++ b/packages/twenty-website-new/src/lib/seo/index.ts @@ -1,16 +1,14 @@ export { buildLocalizedMetadata } from './build-localized-metadata'; -export { - buildPageMetadata, - type BuildPageMetadataInput, -} from './build-page-metadata'; +export { buildPageMetadata } from './build-page-metadata'; export { buildRouteMetadata } from './build-route-metadata'; export { - buildArticleListJsonLd, buildArticleJsonLd, + buildArticleListJsonLd, + buildBreadcrumbListJsonLd, buildFaqPageJsonLd, buildOrganizationJsonLd, buildReleaseListJsonLd, buildSoftwareApplicationJsonLd, - JsonLd, -} from './json-ld'; -export { getAbsoluteUrl, getSiteUrl } from './site-url'; +} from './json-ld-builders'; +export { JsonLd } from './JsonLd'; +export { getSiteUrl } from './site-url'; diff --git a/packages/twenty-website-new/src/lib/seo/json-ld.tsx b/packages/twenty-website-new/src/lib/seo/json-ld-builders.ts similarity index 82% rename from packages/twenty-website-new/src/lib/seo/json-ld.tsx rename to packages/twenty-website-new/src/lib/seo/json-ld-builders.ts index 2c01a89341..c22b4f495f 100644 --- a/packages/twenty-website-new/src/lib/seo/json-ld.tsx +++ b/packages/twenty-website-new/src/lib/seo/json-ld-builders.ts @@ -1,33 +1,24 @@ import type { MessageDescriptor } from '@lingui/core'; +import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; import type { Article } from '@/lib/articles'; +import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments'; import type { LocalReleaseNote } from '@/lib/releases/types'; +import type { JsonLdValue } from './JsonLd'; import { getSiteUrl } from './site-url'; type FaqEntryLike = { - question: { text: MessageDescriptor }; - answer: { text: MessageDescriptor }; + question: MessageDescriptor; + answer: MessageDescriptor; }; -type JsonLdPrimitive = boolean | number | string | null; +type RenderText = (descriptor: MessageDescriptor) => string; -export type JsonLdValue = - | JsonLdPrimitive - | JsonLdValue[] - | { [key: string]: JsonLdValue | undefined }; - -const serializeJsonLd = (data: JsonLdValue): string => - JSON.stringify(data).replace(/ - ); -} +type BreadcrumbItem = { + name: string; + path: string; +}; export const buildOrganizationJsonLd = (): JsonLdValue => { const siteUrl = getSiteUrl(); @@ -72,8 +63,6 @@ export const buildSoftwareApplicationJsonLd = (): JsonLdValue => { }; }; -type RenderText = (descriptor: MessageDescriptor) => string; - export const buildFaqPageJsonLd = ( questions: readonly FaqEntryLike[], renderText: RenderText, @@ -82,10 +71,10 @@ export const buildFaqPageJsonLd = ( '@type': 'FAQPage', mainEntity: questions.map((question) => ({ '@type': 'Question', - name: renderText(question.question.text), + name: renderText(question.question), acceptedAnswer: { '@type': 'Answer', - text: renderText(question.answer.text), + text: renderText(question.answer), }, })), }); @@ -190,3 +179,23 @@ export const buildArticleJsonLd = (post: Article): JsonLdValue => { }, }; }; + +export const buildBreadcrumbListJsonLd = ( + items: readonly BreadcrumbItem[], + locale?: AppLocale, +): JsonLdValue => { + const siteUrl = getSiteUrl(); + const prefix = + locale && locale !== SOURCE_LOCALE ? `/${localeToUrlSegment(locale)}` : ''; + + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: `${siteUrl}${prefix}${item.path}`, + })), + }; +}; diff --git a/packages/twenty-website-new/src/lib/seo/site-url.ts b/packages/twenty-website-new/src/lib/seo/site-url.ts index 4c367d754d..70153511f5 100644 --- a/packages/twenty-website-new/src/lib/seo/site-url.ts +++ b/packages/twenty-website-new/src/lib/seo/site-url.ts @@ -13,12 +13,3 @@ export function getSiteUrl(): string { return DEFAULT_SITE_URL; } - -export function getAbsoluteUrl(pathOrUrl: string): string { - if (/^https?:\/\//i.test(pathOrUrl)) { - return pathOrUrl; - } - - const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; - return `${getSiteUrl()}${path}`; -} diff --git a/packages/twenty-website-new/src/lib/stepper/index.ts b/packages/twenty-website-new/src/lib/stepper/index.ts deleted file mode 100644 index 2a62a8c627..0000000000 --- a/packages/twenty-website-new/src/lib/stepper/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { StepperSwipeDeck } from './stepper-swipe-deck'; -export { getStepperMdUpSnapshot, useStepperMdUp } from './use-stepper-md-up'; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts index ecdb89d92d..e7c86af94f 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts @@ -1,4 +1,4 @@ -import { createBoundedFailureCache } from '@/lib/visual-runtime/bounded-failure-cache'; +import { createBoundedFailureCache } from '@/lib/visual-runtime/utils/bounded-failure-cache'; describe('createBoundedFailureCache', () => { it('records added URLs and reports them via has()', () => { diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts index 8dc865dd9b..58c71ab503 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts @@ -1,4 +1,4 @@ -import { createVisualMountScheduler } from '../visual-mount-scheduler'; +import { createVisualMountScheduler } from '../utils/visual-mount-scheduler'; function createManualIdleHost() { let nextHandle = 1; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts index 2930691747..580543505c 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts @@ -1,7 +1,7 @@ import { createVisualRenderLoop, type VisualRenderLoopDocument, -} from '../visual-render-loop'; +} from '../utils/visual-render-loop'; function createAnimationFrameScheduler() { let nextHandle = 1; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx similarity index 92% rename from packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx rename to packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx index f4fc471217..fa9630098e 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx +++ b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx @@ -2,7 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; -import { WebGlUnavailableError } from './visual-runtime-policy'; +import { WebGlUnavailableError } from '../utils/visual-runtime-policy'; type WebGlErrorBoundaryProps = { children: ReactNode; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx similarity index 94% rename from packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx rename to packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx index 832aa77ab3..c054bc3b8e 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx +++ b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx @@ -8,14 +8,14 @@ import { observeElementVisibility } from '@/lib/dom/observe-element-visibility'; import { subscribeToActiveWebGlContextCount, tryReserveWebGlContextSlot, -} from './active-webgl-context-budget'; -import { SITE_WEBGL_CONTEXT_LOST_EVENT } from './create-site-webgl-renderer'; +} from '../utils/active-webgl-context-budget'; +import { SITE_WEBGL_CONTEXT_LOST_EVENT } from '../utils/create-site-webgl-renderer'; import { scheduleVisualMount, type VisualMountPriority, -} from './visual-mount-scheduler'; -import { useWebGlPolicy } from './use-webgl-policy'; -import { WebGlErrorBoundary } from './webgl-error-boundary'; +} from '../utils/visual-mount-scheduler'; +import { useWebGlPolicy } from '../hooks/use-webgl-policy'; +import { WebGlErrorBoundary } from './WebglErrorBoundary'; const NON_PRIORITY_ROOT_MARGIN = '50% 0px 50% 0px'; const PRIORITY_ROOT_MARGIN = '125% 0px 125% 0px'; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts new file mode 100644 index 0000000000..18c4c31a89 --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts @@ -0,0 +1,12 @@ +import type * as THREE from 'three'; + +import { useAsyncResource } from './use-async-resource'; + +const DISPOSE_GEOMETRY = (geometry: THREE.BufferGeometry) => geometry.dispose(); + +export function useAsyncGeometry( + loader: (() => Promise) | null, + deps: readonly unknown[], +) { + return useAsyncResource(loader, deps, { dispose: DISPOSE_GEOMETRY }); +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts new file mode 100644 index 0000000000..4e4114049a --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts @@ -0,0 +1,8 @@ +import { useAsyncResource } from './use-async-resource'; + +export function useAsyncImage( + loader: (() => Promise) | null, + deps: readonly unknown[], +) { + return useAsyncResource(loader, deps); +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts new file mode 100644 index 0000000000..17a7dbd2f2 --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type AsyncResourceOptions = { + dispose?: (resource: T) => void; +}; + +export function useAsyncResource( + loader: (() => Promise) | null, + deps: readonly unknown[], + options?: AsyncResourceOptions, +) { + const [resource, setResource] = useState(null); + + useEffect(() => { + if (!loader) { + setResource(null); + return; + } + + let cancelled = false; + let loadedResource: T | null = null; + + setResource(null); + + void Promise.resolve() + .then(() => loader()) + .then( + (result) => { + if (cancelled) { + options?.dispose?.(result); + return; + } + + loadedResource = result; + setResource(result); + }, + (error) => { + if (!cancelled) { + console.error(error); + } + }, + ); + + return () => { + cancelled = true; + if (loadedResource) { + options?.dispose?.(loadedResource); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return resource; +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts similarity index 96% rename from packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts rename to packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts index c967a87c03..7e0971e0e8 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'; import { evaluateWebGlPolicy, type WebGlPolicyDecision, -} from './visual-runtime-policy'; +} from '../utils/visual-runtime-policy'; const PESSIMISTIC_INITIAL_DECISION: WebGlPolicyDecision = { allowed: false, diff --git a/packages/twenty-website-new/src/lib/visual-runtime/index.ts b/packages/twenty-website-new/src/lib/visual-runtime/index.ts index 310af48e66..7da5912d3a 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/index.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/index.ts @@ -1,57 +1,17 @@ -export { - evaluateWebGlPolicy, - detectWebGlSupport, - detectPrefersReducedMotion, - isHeavyVisualsKillSwitchEnabled, - WebGlUnavailableError, - type WebGlPolicyDecision, - type WebGlPolicyDenialReason, -} from './visual-runtime-policy'; +export { evaluateWebGlPolicy } from './utils/visual-runtime-policy'; -export { - getActiveWebGlContextCount, - getMaxActiveWebGlContexts, - subscribeToActiveWebGlContextCount, - tryReserveWebGlContextSlot, -} from './active-webgl-context-budget'; - -export { useWebGlPolicy } from './use-webgl-policy'; -export { WebGlErrorBoundary } from './webgl-error-boundary'; -export { WebGlMount } from './webgl-mount'; -export { loadVisualImage } from './load-visual-image'; -export { - createVisualMountScheduler, - scheduleVisualMount, - visualMountScheduler, - type ScheduleVisualMountOptions, - type VisualMountPriority, - type VisualMountScheduler, -} from './visual-mount-scheduler'; +export { WebGlMount } from './components/WebglMount'; +export { loadVisualImage } from './utils/load-visual-image'; +export { scheduleVisualMount } from './utils/visual-mount-scheduler'; export { createSiteWebGlRenderer, - reportSiteWebGlRendererCreationFailure, - SITE_WEBGL_CONTEXT_LOST_EVENT, tryCreateSiteWebGlRenderer, - type SiteWebGlRendererCreationFailureHandler, - type SiteWebGlRendererParameters, -} from './create-site-webgl-renderer'; +} from './utils/create-site-webgl-renderer'; export { createVisualRenderLoop, - reportVisualRenderLoopErrorInDevelopment, - type CreateVisualRenderLoopOptions, type VisualRenderLoop, - type VisualRenderLoopCanceller, - type VisualRenderLoopDocument, - type VisualRenderLoopErrorHandler, type VisualRenderLoopFrame, - type VisualRenderLoopFrameRenderer, - type VisualRenderLoopScheduler, -} from './visual-render-loop'; +} from './utils/visual-render-loop'; -export { - createBoundedFailureCache, - type BoundedFailureCache, -} from './bounded-failure-cache'; - -export { DRACO_DECODER_PATH, DRACO_DECODER_ORIGIN } from './draco-decoder-path'; +export { createBoundedFailureCache } from './utils/bounded-failure-cache'; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts similarity index 89% rename from packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts index 31df0a5779..370b36a176 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts @@ -8,7 +8,7 @@ function readNumberEnv(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } -export function getMaxActiveWebGlContexts(): number { +function getMaxActiveWebGlContexts(): number { return readNumberEnv( process.env.NEXT_PUBLIC_MAX_WEBGL_CONTEXTS, DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS, @@ -26,10 +26,6 @@ function notify() { } } -export function getActiveWebGlContextCount(): number { - return activeCount; -} - export function tryReserveWebGlContextSlot(): (() => void) | null { if (activeCount >= getMaxActiveWebGlContexts()) { return null; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/bounded-failure-cache.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/bounded-failure-cache.ts similarity index 100% rename from packages/twenty-website-new/src/lib/visual-runtime/bounded-failure-cache.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/bounded-failure-cache.ts diff --git a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts similarity index 89% rename from packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts index 9f33a01c10..b526b0d915 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; -export type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & { +type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & { onContextLost?: (event: WebGLContextEvent) => void; }; @@ -59,9 +59,9 @@ export function createSiteWebGlRenderer( return renderer; } -export type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void; +type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void; -export const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler = +const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler = (error) => { if (process.env.NODE_ENV !== 'production') { console.error('WebGL renderer creation failed:', error); diff --git a/packages/twenty-website-new/src/lib/visual-runtime/draco-decoder-path.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/draco-decoder-path.ts similarity index 100% rename from packages/twenty-website-new/src/lib/visual-runtime/draco-decoder-path.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/draco-decoder-path.ts diff --git a/packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/load-visual-image.ts similarity index 100% rename from packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/load-visual-image.ts diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts similarity index 97% rename from packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts index 00a6789414..7cacff38cc 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts @@ -14,12 +14,12 @@ type VisualMountSchedulerHost = { setTimeout?: (callback: () => void, delayMs: number) => TimeoutHandle; }; -export type ScheduleVisualMountOptions = { +type ScheduleVisualMountOptions = { priority?: VisualMountPriority; timeoutMs?: number; }; -export type VisualMountScheduler = { +type VisualMountScheduler = { schedule: ( callback: () => void, options?: ScheduleVisualMountOptions, @@ -207,6 +207,6 @@ export function createVisualMountScheduler( }; } -export const visualMountScheduler = createVisualMountScheduler(); +const visualMountScheduler = createVisualMountScheduler(); export const scheduleVisualMount = visualMountScheduler.schedule; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts similarity index 89% rename from packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts index 5ed9c61ba1..c81fcc95fb 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts @@ -2,7 +2,7 @@ import { observeElementVisibility, type ObserveElementVisibilityOptions, } from '@/lib/dom/observe-element-visibility'; -import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks'; +import { runCleanupTasks } from '@/lib/react/run-cleanup-tasks'; export type VisualRenderLoop = { dispose: () => void; @@ -11,7 +11,7 @@ export type VisualRenderLoop = { stop: () => void; }; -export type VisualRenderLoopErrorHandler = (error: unknown) => void; +type VisualRenderLoopErrorHandler = (error: unknown) => void; export type VisualRenderLoopDocument = { addEventListener: Document['addEventListener']; @@ -19,22 +19,20 @@ export type VisualRenderLoopDocument = { removeEventListener: Document['removeEventListener']; }; -export type VisualRenderLoopScheduler = ( - callback: FrameRequestCallback, -) => number; +type VisualRenderLoopScheduler = (callback: FrameRequestCallback) => number; -export type VisualRenderLoopCanceller = (handle: number) => void; +type VisualRenderLoopCanceller = (handle: number) => void; export type VisualRenderLoopFrame = { deltaSeconds: number; elapsedSeconds: number; timestamp: DOMHighResTimeStamp; }; -export type VisualRenderLoopFrameRenderer = ( +type VisualRenderLoopFrameRenderer = ( timestamp: DOMHighResTimeStamp, frame: VisualRenderLoopFrame, ) => boolean | void; -export type CreateVisualRenderLoopOptions = { +type CreateVisualRenderLoopOptions = { cancelAnimationFrame?: VisualRenderLoopCanceller; document?: VisualRenderLoopDocument | null; onFrameError?: VisualRenderLoopErrorHandler; @@ -47,12 +45,13 @@ export type CreateVisualRenderLoopOptions = { visibilityObserver?: typeof observeElementVisibility; }; -export const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler = - (error) => { - if (process.env.NODE_ENV !== 'production') { - console.error('Visual render loop failed:', error); - } - }; +const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler = ( + error, +) => { + if (process.env.NODE_ENV !== 'production') { + console.error('Visual render loop failed:', error); + } +}; const MAX_FRAME_DELTA_SECONDS = 0.1; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts similarity index 90% rename from packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts index 055e7ce4b7..cc5ca0c5cd 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts @@ -2,7 +2,7 @@ export type WebGlPolicyDecision = | { allowed: true; reducedMotion: boolean } | { allowed: false; reason: WebGlPolicyDenialReason; reducedMotion: boolean }; -export type WebGlPolicyDenialReason = 'kill-switch' | 'no-webgl-support'; +type WebGlPolicyDenialReason = 'kill-switch' | 'no-webgl-support'; export class WebGlUnavailableError extends Error { readonly reason: WebGlPolicyDenialReason; @@ -22,13 +22,13 @@ function readBooleanEnv(value: string | undefined): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } -export function isHeavyVisualsKillSwitchEnabled(): boolean { +function isHeavyVisualsKillSwitchEnabled(): boolean { return readBooleanEnv(process.env.NEXT_PUBLIC_DISABLE_HEAVY_VISUALS); } let cachedSupportProbe: boolean | null = null; -export function detectWebGlSupport(): boolean { +function detectWebGlSupport(): boolean { if (cachedSupportProbe !== null) { return cachedSupportProbe; } @@ -63,7 +63,7 @@ export function detectWebGlSupport(): boolean { } } -export function detectPrefersReducedMotion(): boolean { +function detectPrefersReducedMotion(): boolean { if (typeof window === 'undefined' || !('matchMedia' in window)) { return false; } diff --git a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts index 17547491ed..be73fe438c 100644 --- a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts +++ b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts @@ -14,7 +14,7 @@ export const STATIC_WEBSITE_ROUTES = [ }, { id: 'whyTwenty', - path: '/resources/why-twenty', + path: '/why-twenty', title: msg`Why Twenty — The Open Source CRM Built to Be Customised`, description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`, changeFrequency: 'monthly', diff --git a/packages/twenty-website-new/src/proxy.ts b/packages/twenty-website-new/src/proxy.ts index cadd25fa06..8d623b8432 100644 --- a/packages/twenty-website-new/src/proxy.ts +++ b/packages/twenty-website-new/src/proxy.ts @@ -1,8 +1,8 @@ import { NextResponse, type NextRequest } from 'next/server'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; -import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set'; -import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/website-locale-segments'; +import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/utils/app-locale-set'; +import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/utils/website-locale-segments'; export const proxy = (request: NextRequest) => { const { pathname, search } = request.nextUrl; diff --git a/packages/twenty-website-new/src/sections/Articles/index.ts b/packages/twenty-website-new/src/sections/Articles/index.ts new file mode 100644 index 0000000000..30329eaad1 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Articles/index.ts @@ -0,0 +1 @@ +export { Articles } from './components'; diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx index 0a3becf6fd..d3c26f07d1 100644 --- a/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRenderMessage } from '@/lib/i18n/use-render-message'; +import { useLingui } from '@lingui/react'; import { useScheduledOnScroll } from '@/lib/scroll'; import { theme } from '@/theme'; import type { MessageDescriptor } from '@lingui/core'; @@ -154,9 +154,9 @@ export function CaseStudySectionNav({ items, label = DEFAULT_LABEL, }: CaseStudySectionNavProps) { - const renderText = useRenderMessage(); - const resolvedLabel = renderText(label); - const resolvedItems = items.map(renderText); + const { i18n } = useLingui(); + const resolvedLabel = i18n._(label); + const resolvedItems = items.map((item) => i18n._(item)); const [visible, setVisible] = useState(false); const [activeIndex, setActiveIndex] = useState(0); diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx index 7fd0c472c9..e9298e656e 100644 --- a/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx @@ -1,15 +1,14 @@ import type { CaseStudyData } from '@/lib/customers'; -import { Container, Heading } from '@/design-system/components'; +import { Container } from '@/design-system/components'; import { CLIENT_ICONS } from '@/icons'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { LocalizedLink } from '@/lib/i18n'; import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { styled } from '@linaria/react'; import { IconArrowLeft, IconClock } from '@tabler/icons-react'; import Image from 'next/image'; - const CATALOG_LOGO_WIDTHS: Record = { 'nine-dots': 72, 'alternative-partners': 220, @@ -230,15 +229,10 @@ type HeroProps = { hero: CaseStudyData['hero']; dashColor?: string; hoverDashColor?: string; - renderText: (descriptor: MessageDescriptor) => string; }; -export function Hero({ - hero, - dashColor, - hoverDashColor, - renderText, -}: HeroProps) { +export function Hero({ hero, dashColor, hoverDashColor }: HeroProps) { + const i18n = getServerI18n(); const ClientIcon = CLIENT_ICONS[hero.clientIcon]; const authorInitials = hero.author .split(' ') @@ -262,15 +256,7 @@ export function Hero({ - - - + {hero.title} {hero.authorAvatarSrc ? ( @@ -289,13 +275,13 @@ export function Hero({ - {renderText(msg`Back`)} + {i18n._(msg`Back`)} {hero.author} {hero.authorRole ? ( - {renderText(hero.authorRole)} + {i18n._(hero.authorRole)} ) : null} diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx index 56753666ae..1ffa43106e 100644 --- a/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx @@ -1,6 +1,7 @@ import { Container } from '@/design-system/components'; -import type { MessageDescriptor } from '@lingui/core'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { theme } from '@/theme'; +import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { styled } from '@linaria/react'; @@ -115,10 +116,10 @@ const Label = styled.span` type HighlightsProps = { industry?: MessageDescriptor; kpis?: { value: MessageDescriptor; label: MessageDescriptor }[]; - renderText: (descriptor: MessageDescriptor) => string; }; -export function Highlights({ industry, kpis, renderText }: HighlightsProps) { +export function Highlights({ industry, kpis }: HighlightsProps) { + const i18n = getServerI18n(); const cells: { value: MessageDescriptor; label: MessageDescriptor }[] = []; if (industry) { cells.push({ value: industry, label: msg`Industry` }); @@ -140,8 +141,8 @@ export function Highlights({ industry, kpis, renderText }: HighlightsProps) { {cells.map((cell, index) => ( - {renderText(cell.value)} - + {i18n._(cell.value)} + ))} diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx index e3663f013f..1565d9abd6 100644 --- a/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx @@ -3,12 +3,11 @@ import { Body as BaseBody, Container, Eyebrow, - Heading, + HeadingPart, } from '@/design-system/components'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { styled } from '@linaria/react'; - const Section = styled.section` background-color: ${theme.colors.primary.background[100]}; color: ${theme.colors.primary.text[100]}; @@ -125,47 +124,40 @@ function parseCallout(raw: string): { type TextBlockProps = { block: CaseStudyTextBlock; isLast?: boolean; - renderText: (descriptor: MessageDescriptor) => string; sectionId?: string; }; export function TextBlock({ block, isLast = false, - renderText, sectionId, }: TextBlockProps) { + const i18n = getServerI18n(); return (
    {block.eyebrow && ( - + + + {i18n._(block.eyebrow)} + + )} - + {block.heading} {block.paragraphs.map((paragraph, index) => ( + > + {i18n._(paragraph)} + ))} {block.callout && (() => { diff --git a/packages/twenty-website-new/src/sections/CaseStudy/index.ts b/packages/twenty-website-new/src/sections/CaseStudy/index.ts new file mode 100644 index 0000000000..d4fadf29a2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudy/index.ts @@ -0,0 +1 @@ +export { CaseStudy } from './components'; diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx index df2432707a..8ba0bb5cff 100644 --- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx @@ -1,13 +1,14 @@ import type { CaseStudyCatalogEntry } from '@/lib/customers'; -import { ArrowRightIcon, CLIENT_ICONS } from '@/icons'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { LocalizedLink } from '@/lib/i18n'; -import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { styled } from '@linaria/react'; -import Image from 'next/image'; import type { CSSProperties } from 'react'; +import { CardFooter } from './CardFooter'; +import { CardKpisDefault, CardKpisLarge } from './CardKpis'; +import { CardThumbnail } from './CardThumbnail'; + type CardVariant = 'default' | 'large'; type CaseStudyCardStyle = CSSProperties & { @@ -63,56 +64,6 @@ const CardLink = styled(LocalizedLink)` } `; -const Thumbnail = styled.div<{ variant: 'default' | 'large' }>` - align-items: center; - background-color: #000; - display: flex; - flex-shrink: 0; - height: 200px; - justify-content: center; - overflow: hidden; - position: relative; - width: 100%; - - @media (min-width: ${theme.breakpoints.md}px) { - height: ${({ variant }) => (variant === 'large' ? 'auto' : '240px')}; - min-height: ${({ variant }) => (variant === 'large' ? '460px' : '0')}; - width: ${({ variant }) => (variant === 'large' ? '50%' : '100%')}; - } -`; - -const CoverLayer = styled.div` - inset: 0; - position: absolute; -`; - -const LogoLayer = styled.div` - align-items: center; - display: flex; - inset: 0; - justify-content: center; - pointer-events: none; - position: absolute; -`; - -const ThumbnailBadge = styled.span` - bottom: ${theme.spacing(4)}; - color: ${theme.colors.secondary.text[60]}; - font-family: ${theme.font.family.mono}; - font-size: ${theme.font.size(3)}; - font-weight: ${theme.font.weight.medium}; - left: ${theme.spacing(6)}; - letter-spacing: 0.08em; - position: absolute; - text-transform: uppercase; - z-index: 2; - - @media (min-width: ${theme.breakpoints.md}px) { - bottom: ${theme.spacing(6)}; - left: ${theme.spacing(8)}; - } -`; - const ContentWrapper = styled.div<{ variant: 'default' | 'large' }>` display: flex; flex: 1; @@ -168,10 +119,6 @@ const Title = styled.h3<{ variant: 'default' | 'large' }>` } `; -const TitleSans = styled.span` - font-family: ${theme.font.family.sans}; -`; - const Quote = styled.blockquote` border-left: 2px solid ${theme.colors.highlight[100]}; color: ${theme.colors.primary.text[60]}; @@ -202,224 +149,6 @@ const Summary = styled.p` overflow: hidden; `; -const KpiRow = styled.div<{ count: number }>` - border-top: 1px solid ${theme.colors.primary.border[10]}; - display: grid; - grid-template-columns: repeat( - ${({ count }) => (count > 2 ? 2 : count)}, - minmax(0, 1fr) - ); - margin-top: ${theme.spacing(2)}; - - @media (min-width: ${theme.breakpoints.md}px) { - grid-template-columns: ${({ count }) => `repeat(${count}, minmax(0, 1fr))`}; - margin-top: auto; - } -`; - -const KpiCell = styled.div<{ index: number; count: number }>` - border-left: 1px solid ${theme.colors.primary.border[10]}; - border-top: ${({ index, count }) => - count > 2 && index >= 2 - ? `1px solid ${theme.colors.primary.border[10]}` - : 'none'}; - display: flex; - flex-direction: column; - gap: ${theme.spacing(1.5)}; - padding: ${theme.spacing(4)} ${theme.spacing(3)}; - - &:nth-child(odd) { - border-left: none; - padding-left: 0; - } - - @media (min-width: ${theme.breakpoints.md}px) { - border-left: 1px solid ${theme.colors.primary.border[10]}; - border-top: none; - padding: ${theme.spacing(5)} ${theme.spacing(4)}; - - &:first-child { - border-left: none; - padding-left: 0; - } - - &:nth-child(odd) { - border-left: 1px solid ${theme.colors.primary.border[10]}; - padding-left: ${theme.spacing(4)}; - } - - &:first-child { - border-left: none; - padding-left: 0; - } - } -`; - -const KpiValue = styled.span` - color: ${theme.colors.primary.text[100]}; - font-family: ${theme.font.family.serif}; - font-size: ${theme.font.size(6)}; - font-weight: ${theme.font.weight.light}; - line-height: ${theme.lineHeight(7)}; - - @media (min-width: ${theme.breakpoints.md}px) { - font-size: ${theme.font.size(7)}; - line-height: ${theme.lineHeight(8)}; - } -`; - -const KpiLabel = styled.span` - color: ${theme.colors.primary.text[40]}; - font-family: ${theme.font.family.mono}; - font-size: ${theme.font.size(3)}; - font-weight: ${theme.font.weight.medium}; - letter-spacing: 0.08em; - line-height: ${theme.lineHeight(4)}; - text-transform: uppercase; -`; - -const KpiChipRow = styled.div` - display: flex; - flex-wrap: wrap; - gap: ${theme.spacing(2)}; - margin-top: ${theme.spacing(1)}; - padding-bottom: ${theme.spacing(4)}; - padding-left: ${theme.spacing(6)}; - padding-right: ${theme.spacing(6)}; - - @media (min-width: ${theme.breakpoints.md}px) { - padding-bottom: ${theme.spacing(5)}; - } -`; - -const KpiChip = styled.span` - align-items: center; - background-color: ${theme.colors.primary.text[5]}; - border: 1px solid ${theme.colors.primary.border[10]}; - border-radius: 999px; - color: ${theme.colors.primary.text[60]}; - display: inline-flex; - font-family: ${theme.font.family.sans}; - font-size: ${theme.font.size(3)}; - font-weight: ${theme.font.weight.regular}; - gap: ${theme.spacing(2.5)}; - line-height: ${theme.lineHeight(4)}; - padding: ${theme.spacing(1.5)} ${theme.spacing(3.5)}; - white-space: nowrap; -`; - -const KpiChipValue = styled.span` - color: ${theme.colors.primary.text[100]}; - font-family: ${theme.font.family.sans}; - font-size: ${theme.font.size(3.5)}; - font-weight: ${theme.font.weight.medium}; - letter-spacing: -0.01em; -`; - -const KpiChipDivider = styled.span` - background-color: ${theme.colors.primary.border[20]}; - flex-shrink: 0; - height: 14px; - width: 1px; -`; - -const KpiChipLabel = styled.span` - color: ${theme.colors.primary.text[60]}; - font-family: ${theme.font.family.mono}; - font-size: ${theme.font.size(2.5)}; - font-weight: ${theme.font.weight.medium}; - letter-spacing: 0.08em; - text-transform: uppercase; -`; - -const Footer = styled.div<{ variant: 'default' | 'large' }>` - align-items: center; - border-top: 1px solid ${theme.colors.primary.border[10]}; - display: flex; - gap: ${theme.spacing(3)}; - justify-content: space-between; - margin-left: ${theme.spacing(6)}; - margin-right: ${theme.spacing(6)}; - padding-bottom: ${theme.spacing(5)}; - padding-top: ${theme.spacing(5)}; - - @media (min-width: ${theme.breakpoints.md}px) { - margin-left: ${({ variant }) => - variant === 'large' ? '0' : theme.spacing(6)}; - margin-right: ${({ variant }) => - variant === 'large' ? '0' : theme.spacing(6)}; - padding-bottom: ${({ variant }) => - variant === 'large' ? '0' : theme.spacing(5)}; - padding-top: ${({ variant }) => - variant === 'large' ? theme.spacing(6) : theme.spacing(5)}; - } -`; - -const AuthorGroup = styled.div` - align-items: center; - display: flex; - flex: 1; - gap: ${theme.spacing(3)}; - min-width: 0; -`; - -const AuthorAvatar = styled.div` - align-items: center; - background-color: ${theme.colors.highlight[100]}; - border-radius: 50%; - color: ${theme.colors.secondary.text[100]}; - display: flex; - flex-shrink: 0; - font-family: ${theme.font.family.sans}; - font-size: ${theme.font.size(3)}; - font-weight: ${theme.font.weight.medium}; - height: 32px; - justify-content: center; - width: 32px; -`; - -const AuthorAvatarPhoto = styled.div` - border-radius: 50%; - flex-shrink: 0; - height: 32px; - overflow: hidden; - position: relative; - width: 32px; -`; - -const AuthorText = styled.div` - color: ${theme.colors.primary.text[80]}; - font-family: ${theme.font.family.sans}; - font-size: ${theme.font.size(3.5)}; - font-weight: ${theme.font.weight.regular}; - line-height: ${theme.lineHeight(4)}; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const AuthorRoleText = styled.span` - color: ${theme.colors.primary.text[40]}; -`; - -const ReadIconButton = styled.span` - align-items: center; - border: 1px solid ${theme.colors.primary.border[20]}; - border-radius: ${theme.radius(2)}; - color: ${theme.colors.primary.text[80]}; - display: inline-flex; - flex-shrink: 0; - height: 40px; - justify-content: center; - transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); - width: 40px; - - ${`a:hover &`} { - transform: scale(1.08); - } -`; - const IndustryLabel = styled.span` color: ${theme.colors.primary.text[60]}; font-family: ${theme.font.family.mono}; @@ -430,47 +159,22 @@ const IndustryLabel = styled.span` text-transform: uppercase; `; -const CATALOG_LOGO_WIDTHS: Record< - CaseStudyCatalogEntry['hero']['clientIcon'], - number -> = { - 'nine-dots': 72, - 'alternative-partners': 220, - netzero: 180, - 'act-education': 110, - w3villa: 150, - 'elevate-consulting': 160, -}; - -const LARGE_LOGO_SCALE = 1.4; - type CardProps = { - entry: CaseStudyCatalogEntry; - index?: number; - renderText: (descriptor: MessageDescriptor) => string; - variant?: CardVariant; dashColor?: string; + entry: CaseStudyCatalogEntry; hoverDashColor?: string; + index?: number; + variant?: CardVariant; }; export function Card({ - entry, - index = 0, - renderText, - variant = 'default', dashColor, + entry, hoverDashColor, + index = 0, + variant = 'default', }: CardProps) { - const ClientIcon = CLIENT_ICONS[entry.hero.clientIcon]; - const baseLogoWidth = CATALOG_LOGO_WIDTHS[entry.hero.clientIcon] ?? 140; - const logoWidth = - variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth; - const initials = entry.hero.author - .split(' ') - .map((word) => word[0]) - .join(''); - - const coverImageSrc = entry.catalogCard.coverImageSrc; + const i18n = getServerI18n(); const isLarge = variant === 'large'; const hasQuote = isLarge && entry.quote; const kpiCount = entry.kpis.length; @@ -480,104 +184,40 @@ export function Card({ return ( - - {coverImageSrc ? ( - - - - ) : null} - {ClientIcon ? ( - - - - ) : null} - {isLarge ? ( - Case · {entry.hero.readingTime} - ) : null} - + - {renderText(entry.industry)} - - {entry.hero.title.map((segment, segmentIndex) => - segment.fontFamily === 'sans' ? ( - <TitleSans key={segmentIndex}> - {renderText(segment.text)} - </TitleSans> - ) : ( - <span key={segmentIndex}>{renderText(segment.text)}</span> - ), - )} - + {i18n._(entry.industry)} + {i18n._(entry.hero.title)} {hasQuote && entry.quote ? ( - “{renderText(entry.quote.text)}” + “{i18n._(entry.quote.text)}” ) : !isLarge ? ( - {renderText(entry.catalogCard.summary)} + {i18n._(entry.catalogCard.summary)} ) : null} {isLarge && kpiCount > 0 ? ( - - {entry.kpis.map((kpi, kpiIndex) => ( - - {renderText(kpi.value)} - {renderText(kpi.label)} - - ))} - + ) : null} {!isLarge && kpiCount > 0 ? ( - - {entry.kpis.map((kpi, kpiIndex) => ( - - {renderText(kpi.value)} - - {renderText(kpi.label)} - - ))} - + ) : null} -
    - - {entry.hero.authorAvatarSrc ? ( - - {entry.hero.author} - - ) : ( - {initials} - )} - {hasQuote && entry.quote ? ( - - {entry.quote.author}{' '} - - · {renderText(entry.quote.role)} - - - ) : ( - - {entry.hero.author}{' '} - - · {renderText(entry.authorRole)} - - - )} - - - - -
    +
    ); diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx new file mode 100644 index 0000000000..8727f7e1e5 --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx @@ -0,0 +1,154 @@ +import { ArrowRightIcon } from '@/icons'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; +import { theme } from '@/theme'; +import type { MessageDescriptor } from '@lingui/core'; +import { styled } from '@linaria/react'; +import Image from 'next/image'; + +const StyledFooter = styled.div<{ variant: 'default' | 'large' }>` + align-items: center; + border-top: 1px solid ${theme.colors.primary.border[10]}; + display: flex; + gap: ${theme.spacing(3)}; + justify-content: space-between; + margin-left: ${theme.spacing(6)}; + margin-right: ${theme.spacing(6)}; + padding-bottom: ${theme.spacing(5)}; + padding-top: ${theme.spacing(5)}; + + @media (min-width: ${theme.breakpoints.md}px) { + margin-left: ${({ variant }) => + variant === 'large' ? '0' : theme.spacing(6)}; + margin-right: ${({ variant }) => + variant === 'large' ? '0' : theme.spacing(6)}; + padding-bottom: ${({ variant }) => + variant === 'large' ? '0' : theme.spacing(5)}; + padding-top: ${({ variant }) => + variant === 'large' ? theme.spacing(6) : theme.spacing(5)}; + } +`; + +const AuthorGroup = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: ${theme.spacing(3)}; + min-width: 0; +`; + +const AuthorAvatar = styled.div` + align-items: center; + background-color: ${theme.colors.highlight[100]}; + border-radius: 50%; + color: ${theme.colors.secondary.text[100]}; + display: flex; + flex-shrink: 0; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.medium}; + height: 32px; + justify-content: center; + width: 32px; +`; + +const AuthorAvatarPhoto = styled.div` + border-radius: 50%; + flex-shrink: 0; + height: 32px; + overflow: hidden; + position: relative; + width: 32px; +`; + +const AuthorText = styled.div` + color: ${theme.colors.primary.text[80]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3.5)}; + font-weight: ${theme.font.weight.regular}; + line-height: ${theme.lineHeight(4)}; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const AuthorRoleText = styled.span` + color: ${theme.colors.primary.text[40]}; +`; + +const ReadIconButton = styled.span` + align-items: center; + border: 1px solid ${theme.colors.primary.border[20]}; + border-radius: ${theme.radius(2)}; + color: ${theme.colors.primary.text[80]}; + display: inline-flex; + flex-shrink: 0; + height: 40px; + justify-content: center; + transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + width: 40px; + + ${`a:hover &`} { + transform: scale(1.08); + } +`; + +type CardFooterProps = { + author: string; + authorAvatarSrc?: string; + authorRole: MessageDescriptor; + quote?: { + author: string; + role: MessageDescriptor; + text: MessageDescriptor; + }; + variant: 'default' | 'large'; +}; + +export function CardFooter({ + author, + authorAvatarSrc, + authorRole, + quote, + variant, +}: CardFooterProps) { + const i18n = getServerI18n(); + const initials = author + .split(' ') + .map((word) => word[0]) + .join(''); + const showQuoteAuthor = variant === 'large' && quote; + + return ( + + + {authorAvatarSrc ? ( + + {author} + + ) : ( + {initials} + )} + {showQuoteAuthor ? ( + + {quote.author}{' '} + · {i18n._(quote.role)} + + ) : ( + + {author} · {i18n._(authorRole)} + + )} + + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx new file mode 100644 index 0000000000..6608a9609a --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx @@ -0,0 +1,180 @@ +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; +import { theme } from '@/theme'; +import type { MessageDescriptor } from '@lingui/core'; +import { styled } from '@linaria/react'; + +type Kpi = { + label: MessageDescriptor; + value: MessageDescriptor; +}; + +const KpiRow = styled.div<{ count: number }>` + border-top: 1px solid ${theme.colors.primary.border[10]}; + display: grid; + grid-template-columns: repeat( + ${({ count }) => (count > 2 ? 2 : count)}, + minmax(0, 1fr) + ); + margin-top: ${theme.spacing(2)}; + + @media (min-width: ${theme.breakpoints.md}px) { + grid-template-columns: ${({ count }) => `repeat(${count}, minmax(0, 1fr))`}; + margin-top: auto; + } +`; + +const KpiCell = styled.div<{ index: number; count: number }>` + border-left: 1px solid ${theme.colors.primary.border[10]}; + border-top: ${({ index, count }) => + count > 2 && index >= 2 + ? `1px solid ${theme.colors.primary.border[10]}` + : 'none'}; + display: flex; + flex-direction: column; + gap: ${theme.spacing(1.5)}; + padding: ${theme.spacing(4)} ${theme.spacing(3)}; + + &:nth-child(odd) { + border-left: none; + padding-left: 0; + } + + @media (min-width: ${theme.breakpoints.md}px) { + border-left: 1px solid ${theme.colors.primary.border[10]}; + border-top: none; + padding: ${theme.spacing(5)} ${theme.spacing(4)}; + + &:first-child { + border-left: none; + padding-left: 0; + } + + &:nth-child(odd) { + border-left: 1px solid ${theme.colors.primary.border[10]}; + padding-left: ${theme.spacing(4)}; + } + + &:first-child { + border-left: none; + padding-left: 0; + } + } +`; + +const KpiValue = styled.span` + color: ${theme.colors.primary.text[100]}; + font-family: ${theme.font.family.serif}; + font-size: ${theme.font.size(6)}; + font-weight: ${theme.font.weight.light}; + line-height: ${theme.lineHeight(7)}; + + @media (min-width: ${theme.breakpoints.md}px) { + font-size: ${theme.font.size(7)}; + line-height: ${theme.lineHeight(8)}; + } +`; + +const KpiLabel = styled.span` + color: ${theme.colors.primary.text[40]}; + font-family: ${theme.font.family.mono}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.medium}; + letter-spacing: 0.08em; + line-height: ${theme.lineHeight(4)}; + text-transform: uppercase; +`; + +const KpiChipRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${theme.spacing(2)}; + margin-top: ${theme.spacing(1)}; + padding-bottom: ${theme.spacing(4)}; + padding-left: ${theme.spacing(6)}; + padding-right: ${theme.spacing(6)}; + + @media (min-width: ${theme.breakpoints.md}px) { + padding-bottom: ${theme.spacing(5)}; + } +`; + +const KpiChip = styled.span` + align-items: center; + background-color: ${theme.colors.primary.text[5]}; + border: 1px solid ${theme.colors.primary.border[10]}; + border-radius: 999px; + color: ${theme.colors.primary.text[60]}; + display: inline-flex; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.regular}; + gap: ${theme.spacing(2.5)}; + line-height: ${theme.lineHeight(4)}; + padding: ${theme.spacing(1.5)} ${theme.spacing(3.5)}; + white-space: nowrap; +`; + +const KpiChipValue = styled.span` + color: ${theme.colors.primary.text[100]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3.5)}; + font-weight: ${theme.font.weight.medium}; + letter-spacing: -0.01em; +`; + +const KpiChipDivider = styled.span` + background-color: ${theme.colors.primary.border[20]}; + flex-shrink: 0; + height: 14px; + width: 1px; +`; + +const KpiChipLabel = styled.span` + color: ${theme.colors.primary.text[60]}; + font-family: ${theme.font.family.mono}; + font-size: ${theme.font.size(2.5)}; + font-weight: ${theme.font.weight.medium}; + letter-spacing: 0.08em; + text-transform: uppercase; +`; + +type CardKpisLargeProps = { + kpis: Kpi[]; + variant: 'large'; +}; + +type CardKpisDefaultProps = { + kpis: Kpi[]; + variant: 'default'; +}; + +export function CardKpisLarge({ kpis }: CardKpisLargeProps) { + const i18n = getServerI18n(); + + return ( + + {kpis.map((kpi, kpiIndex) => ( + + {i18n._(kpi.value)} + {i18n._(kpi.label)} + + ))} + + ); +} + +export function CardKpisDefault({ kpis }: CardKpisDefaultProps) { + const i18n = getServerI18n(); + + return ( + + {kpis.map((kpi, kpiIndex) => ( + + {i18n._(kpi.value)} + + {i18n._(kpi.label)} + + ))} + + ); +} diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx new file mode 100644 index 0000000000..4f2fa85f2e --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx @@ -0,0 +1,112 @@ +import { CLIENT_ICONS } from '@/icons'; +import type { CaseStudyCatalogEntry } from '@/lib/customers'; +import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +const StyledThumbnail = styled.div<{ variant: 'default' | 'large' }>` + align-items: center; + background-color: #000; + display: flex; + flex-shrink: 0; + height: 200px; + justify-content: center; + overflow: hidden; + position: relative; + width: 100%; + + @media (min-width: ${theme.breakpoints.md}px) { + height: ${({ variant }) => (variant === 'large' ? 'auto' : '240px')}; + min-height: ${({ variant }) => (variant === 'large' ? '460px' : '0')}; + width: ${({ variant }) => (variant === 'large' ? '50%' : '100%')}; + } +`; + +const CoverLayer = styled.div` + inset: 0; + position: absolute; +`; + +const LogoLayer = styled.div` + align-items: center; + display: flex; + inset: 0; + justify-content: center; + pointer-events: none; + position: absolute; +`; + +const Badge = styled.span` + bottom: ${theme.spacing(4)}; + color: ${theme.colors.secondary.text[60]}; + font-family: ${theme.font.family.mono}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.medium}; + left: ${theme.spacing(6)}; + letter-spacing: 0.08em; + position: absolute; + text-transform: uppercase; + z-index: 2; + + @media (min-width: ${theme.breakpoints.md}px) { + bottom: ${theme.spacing(6)}; + left: ${theme.spacing(8)}; + } +`; + +const CATALOG_LOGO_WIDTHS: Record< + CaseStudyCatalogEntry['hero']['clientIcon'], + number +> = { + 'nine-dots': 72, + 'alternative-partners': 220, + netzero: 180, + 'act-education': 110, + w3villa: 150, + 'elevate-consulting': 160, +}; + +const LARGE_LOGO_SCALE = 1.4; + +type CardThumbnailProps = { + clientIcon: CaseStudyCatalogEntry['hero']['clientIcon']; + coverImageSrc?: string; + dashColor?: string; + hoverDashColor?: string; + readingTime: string; + variant: 'default' | 'large'; +}; + +export function CardThumbnail({ + clientIcon, + coverImageSrc, + dashColor, + hoverDashColor, + readingTime, + variant, +}: CardThumbnailProps) { + const ClientIcon = CLIENT_ICONS[clientIcon]; + const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon] ?? 140; + const logoWidth = + variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth; + + return ( + + {coverImageSrc ? ( + + + + ) : null} + {ClientIcon ? ( + + + + ) : null} + {variant === 'large' ? Case · {readingTime} : null} + + ); +} diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx index 36e6c51ee7..395f18eed0 100644 --- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx @@ -6,7 +6,6 @@ import { Container } from '@/design-system/components'; import { PlusIcon } from '@/icons'; import { Card } from '@/sections/CaseStudyCatalog/components/Card'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; @@ -141,7 +140,6 @@ type GridProps = { compactTop?: boolean; entries: readonly CaseStudyCatalogEntry[]; intro?: ReactNode; - renderText: (descriptor: MessageDescriptor) => string; }; export function Grid({ @@ -149,7 +147,6 @@ export function Grid({ compactTop = false, entries, intro, - renderText, }: GridProps) { const lastIndex = entries.length - 1; @@ -203,7 +200,6 @@ export function Grid({ hoverDashColor={palette.hoverDashColor} index={index} key={entry.href} - renderText={renderText} variant={isLarge ? 'large' : 'default'} /> ); diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx index 24f059b79a..480c78b81c 100644 --- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx @@ -1,8 +1,15 @@ import { msg } from '@lingui/core/macro'; import type { CaseStudyCatalogEntry } from '@/lib/customers'; -import { Body, Container, Eyebrow, Heading } from '@/design-system/components'; +import { + Body, + Container, + Eyebrow, + Heading, + HeadingPart, +} from '@/design-system/components'; import { PlusIcon, UsersIcon } from '@/icons'; -import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLinkButton'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; +import { LocalizedLinkButton } from '@/lib/i18n/components/LocalizedLinkButton'; import { PromoMic } from '@/sections/CaseStudyCatalog/visuals/PromoMic'; import { theme } from '@/theme'; import type { MessageDescriptor } from '@lingui/core'; @@ -15,19 +22,7 @@ const LINE_INSET = '20px'; const TRUSTED_BY_BOTTOM_PADDING = 12; const TRUSTED_BY_BOTTOM_PADDING_DESKTOP = 16; -const EYEBROW = { - text: msg`Customer Stories`, - fontFamily: 'mono' as const, -}; - -const HEADING = [ - { text: msg`How teams`, fontFamily: 'serif' as const }, - { text: msg`built with Twenty`, fontFamily: 'sans' as const, newLine: true }, -]; - -const BODY = { - text: msg`Meet the teams who shaped Twenty into their own CRM with self-hosted deployments, AI-assisted workflows, and API-first product stacks.`, -}; +const BODY = msg`Meet the teams who shaped Twenty into their own CRM with self-hosted deployments, AI-assisted workflows, and API-first product stacks.`; const Section = styled.section` background-color: ${theme.colors.primary.background[100]}; @@ -258,7 +253,6 @@ type PromoProps = { ctaHref?: string; ctaLabel?: MessageDescriptor; compactTop?: boolean; - renderText: (descriptor: MessageDescriptor) => string; }; export function Promo({ @@ -266,8 +260,8 @@ export function Promo({ ctaHref = '/customers', ctaLabel = msg`Explore customer stories`, compactTop = false, - renderText, }: PromoProps) { + const i18n = getServerI18n(); return (
    @@ -331,25 +325,26 @@ export function Promo({ - - - + + + {i18n._(msg`Customer Stories`)} + + + + + {i18n._(msg`How teams`)} + +
    + + {i18n._(msg`built with Twenty`)} + +
    + {i18n._(BODY)} diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts b/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts new file mode 100644 index 0000000000..f2bc98a323 --- /dev/null +++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts @@ -0,0 +1 @@ +export { CaseStudyCatalog } from './components'; diff --git a/packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx b/packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx similarity index 78% rename from packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx rename to packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx index 618e6af572..5a68ab5831 100644 --- a/packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx +++ b/packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CONTACT_CAL_FORM_CAL_LINK } from '@/lib/contact-cal/config'; +import { CONTACT_CAL_FORM_CAL_LINK } from '@/sections/ContactCal/config'; import Cal from '@calcom/embed-react'; export function CalFormEmbed() { diff --git a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModal.tsx b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModal.tsx similarity index 100% rename from packages/twenty-website-new/src/lib/contact-cal/ContactCalModal.tsx rename to packages/twenty-website-new/src/sections/ContactCal/ContactCalModal.tsx diff --git a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx similarity index 57% rename from packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx rename to packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx index 6d663968cd..44a4b150e4 100644 --- a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx +++ b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - createContext, - useCallback, - useContext, - useMemo, - useState, - type ReactNode, -} from 'react'; +import { createContext, useContext, useState, type ReactNode } from 'react'; import { ContactCalModal } from './ContactCalModal'; @@ -31,23 +24,12 @@ export function useContactCalModal() { export function ContactCalModalRoot({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); - const openContactCalModal = useCallback(() => { - setOpen(true); - }, []); - - const closeContactCalModal = useCallback(() => { - setOpen(false); - }, []); - - const contextValue = useMemo( - () => ({ openContactCalModal }), - [openContactCalModal], - ); - return ( - + setOpen(true) }} + > {children} - + setOpen(false)} open={open} /> ); } diff --git a/packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx b/packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx similarity index 80% rename from packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx rename to packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx index 46e26096c3..ba65986807 100644 --- a/packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx +++ b/packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx @@ -4,7 +4,7 @@ import { BaseButton, buttonBaseStyles, } from '@/design-system/components/Button/BaseButton'; -import { useRenderMessage } from '@/lib/i18n/use-render-message'; +import { useLingui } from '@lingui/react'; import type { MessageDescriptor } from '@lingui/core'; import { styled } from '@linaria/react'; @@ -21,7 +21,7 @@ type TalkToUsButtonProps = { }; export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) { - const renderText = useRenderMessage(); + const { i18n } = useLingui(); const { openContactCalModal } = useContactCalModal(); return ( @@ -31,7 +31,7 @@ export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) { type="button" onClick={openContactCalModal} > - + ); } diff --git a/packages/twenty-website-new/src/lib/contact-cal/config.ts b/packages/twenty-website-new/src/sections/ContactCal/config.ts similarity index 100% rename from packages/twenty-website-new/src/lib/contact-cal/config.ts rename to packages/twenty-website-new/src/sections/ContactCal/config.ts diff --git a/packages/twenty-website-new/src/lib/contact-cal/index.ts b/packages/twenty-website-new/src/sections/ContactCal/index.ts similarity index 100% rename from packages/twenty-website-new/src/lib/contact-cal/index.ts rename to packages/twenty-website-new/src/sections/ContactCal/index.ts diff --git a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx b/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx index 38292aec8c..72734f3d4e 100644 --- a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx +++ b/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx @@ -1,6 +1,10 @@ import type { ImageType } from '@/design-system/components/Image'; -import { theme } from '@/theme'; import { css } from '@linaria/core'; + +const coverImageClassName = css` + object-fit: cover; +`; +import { theme } from '@/theme'; import { styled } from '@linaria/react'; import NextImage from 'next/image'; @@ -20,10 +24,6 @@ const ScreenshotRoot = styled.div` } `; -const screenshotImageClassName = css` - object-fit: cover; -`; - type ScreenshotProps = { image: ImageType }; export function Screenshot({ image }: ScreenshotProps) { @@ -31,7 +31,7 @@ export function Screenshot({ image }: ScreenshotProps) { ` - --body-paragraph-color: ${({ $color }) => $color}; - color: ${({ $color }) => $color}; +const bodyParagraphClassName = css` + color: var(--color-text-muted); min-width: 0; `; @@ -46,45 +44,29 @@ const SingleColumnBody = styled.div` `; type EditorialBodyProps = { - body: MessageBody | MessageBody[]; - color: string; + children: ReactNode; layout: | 'centered' | 'indented' | 'two-column' | 'two-column-left' | 'two-column-right'; - renderText: (descriptor: MessageDescriptor) => ReactNode; }; -export function Body({ body, color, layout, renderText }: EditorialBodyProps) { - const paragraphs = Array.isArray(body) ? ( - body.map((item, index) => ( - - - - )) - ) : ( - +export function Body({ children, layout }: EditorialBodyProps) { + const paragraphs = Children.map(children, (child) => ( +
    - - ); + > + {child} + +
    + )); if ( layout === 'two-column' || diff --git a/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx b/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx index 545e5a9d55..bb9e5d4b0b 100644 --- a/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx +++ b/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx @@ -1,24 +1,11 @@ import { Eyebrow as BaseEyebrow } from '@/design-system/components'; -import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow'; -import type { MessageDescriptor } from '@lingui/core'; import type { ReactNode } from 'react'; type EditorialEyebrowProps = { - colorScheme: 'primary' | 'secondary'; - eyebrow: MessageEyebrow; - renderText: (descriptor: MessageDescriptor) => ReactNode; + children: ReactNode; + colorScheme?: 'primary' | 'secondary'; }; -export function Eyebrow({ - colorScheme, - eyebrow, - renderText, -}: EditorialEyebrowProps) { - return ( - - ); +export function Eyebrow({ children, colorScheme }: EditorialEyebrowProps) { + return {children}; } diff --git a/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx b/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx index bc5e9da2b5..06ffc4c61d 100644 --- a/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx +++ b/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx @@ -1,5 +1,5 @@ import { Container, GuideCrosshair } from '@/design-system/components'; -import { theme } from '@/theme'; +import { theme, type Scheme } from '@/theme'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; @@ -10,6 +10,21 @@ const StyledSection = styled.section` &[data-has-crosshair] { position: relative; } + + &[data-scheme='light'] { + background-color: var(--color-white); + color: var(--color-text); + } + + &[data-scheme='muted'] { + background-color: var(--color-neutral); + color: var(--color-text); + } + + &[data-scheme='dark'] { + background-color: var(--color-black); + color: var(--color-text); + } `; const Inner = styled.div` @@ -39,10 +54,11 @@ type EditorialCrosshair = { }; type RootProps = { - backgroundColor: string; + backgroundColor?: string; children: ReactNode; - color: string; + color?: string; crosshair?: EditorialCrosshair; + scheme?: Scheme; }; export function Root({ @@ -50,11 +66,13 @@ export function Root({ children, color, crosshair, + scheme, }: RootProps) { return ( {crosshair ? ( ReactNode; + children: ReactNode; }; -export function Body({ body, renderText }: BodyProps) { +export function Body({ children }: BodyProps) { return ( - + + {children} + ); } diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx index a267944db8..0b5e89c0b5 100644 --- a/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx +++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx @@ -1,8 +1,6 @@ import { Heading as BaseHeading } from '@/design-system/components'; import type { HeadingSize } from '@/design-system/components/Heading'; -import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; @@ -15,18 +13,14 @@ const StyledHeading = styled(BaseHeading)` `; type HeadingProps = { - segments: MessageHeadingSegment; - renderText: (descriptor: MessageDescriptor) => ReactNode; + children: ReactNode; size?: HeadingSize; }; -export function Heading({ segments, renderText, size = 'sm' }: HeadingProps) { +export function Heading({ children, size = 'sm' }: HeadingProps) { return ( - + + {children} + ); } diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx index baf44a0995..06dd5a525c 100644 --- a/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx +++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx @@ -1,5 +1,5 @@ import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; +import { theme, type Scheme } from '@/theme'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; @@ -7,6 +7,18 @@ const StyledSection = styled.section<{ compactBottom: boolean }>` padding-bottom: ${({ compactBottom }) => compactBottom ? theme.spacing(6) : theme.spacing(20)}; width: 100%; + + &[data-scheme='light'] { + background-color: var(--color-white); + } + + &[data-scheme='muted'] { + background-color: var(--color-neutral); + } + + &[data-scheme='dark'] { + background-color: var(--color-black); + } `; const StyledContainer = styled(Container)` @@ -23,18 +35,24 @@ const StyledContainer = styled(Container)` `; type RootProps = { - backgroundColor: string; + backgroundColor?: string; children: ReactNode; compactBottom?: boolean; + scheme?: Scheme; }; export function Root({ backgroundColor, children, compactBottom = false, + scheme, }: RootProps) { return ( - + {children} ); diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx index 1d5d9feefd..f65520ab59 100644 --- a/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx +++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx @@ -5,8 +5,8 @@ import NextImage from 'next/image'; import type { ReactNode } from 'react'; const OVERLAY_SRC_BY_TONE = { - white: '/images/shared/engagement-band/halftone-on-white.png', - gray: '/images/shared/engagement-band/halftone-on-gray.png', + white: '/images/shared/engagement-band/halftone-on-white.webp', + gray: '/images/shared/engagement-band/halftone-on-gray.webp', } as const; type StripTone = keyof typeof OVERLAY_SRC_BY_TONE; diff --git a/packages/twenty-website-new/src/sections/EngagementBand/index.ts b/packages/twenty-website-new/src/sections/EngagementBand/index.ts new file mode 100644 index 0000000000..474ae3024b --- /dev/null +++ b/packages/twenty-website-new/src/sections/EngagementBand/index.ts @@ -0,0 +1 @@ +export { EngagementBand } from './components'; diff --git a/packages/twenty-website-new/src/sections/Faq/components/Root.tsx b/packages/twenty-website-new/src/sections/Faq/Faq.tsx similarity index 52% rename from packages/twenty-website-new/src/sections/Faq/components/Root.tsx rename to packages/twenty-website-new/src/sections/Faq/Faq.tsx index 3b6d083668..17d9222404 100644 --- a/packages/twenty-website-new/src/sections/Faq/components/Root.tsx +++ b/packages/twenty-website-new/src/sections/Faq/Faq.tsx @@ -1,7 +1,9 @@ -import { Container } from '@/design-system/components'; +import { Container, Heading as BaseHeading } from '@/design-system/components'; import { WebGlMount } from '@/lib/visual-runtime'; import { FaqBackground } from '@/sections/Faq/visuals/Background'; +import { FaqItems } from '@/sections/Faq/FaqItems'; import { theme } from '@/theme'; +import { css } from '@linaria/core'; import { styled } from '@linaria/react'; import type { ReactNode } from 'react'; @@ -39,13 +41,34 @@ const StyledContainer = styled(Container)` } `; -type RootProps = { +const StyledIntro = styled.div` + display: grid; + grid-template-columns: 1fr; + row-gap: ${theme.spacing(6)}; +`; + +const faqHeadingClassName = css` + white-space: pre-line; + + @media (min-width: ${theme.breakpoints.md}px) { + max-width: 700px; + } +`; + +const StyledCta = styled.div` + column-gap: ${theme.spacing(2)}; + display: grid; + grid-template-columns: auto auto; + justify-content: start; +`; + +type ChildrenProps = { children: ReactNode; }; -export function Root({ children }: RootProps) { +function Root({ children }: ChildrenProps) { return ( - + @@ -55,3 +78,32 @@ export function Root({ children }: RootProps) { ); } + +function Intro({ children }: ChildrenProps) { + return {children}; +} + +function Heading({ children }: ChildrenProps) { + return ( + + {children} + + ); +} + +function Cta({ children }: ChildrenProps) { + return {children}; +} + +export const Faq = { + Cta, + Heading, + Intro, + Items: FaqItems, + Root, +}; diff --git a/packages/twenty-website-new/src/sections/Faq/components/Item.tsx b/packages/twenty-website-new/src/sections/Faq/FaqItems.tsx similarity index 86% rename from packages/twenty-website-new/src/sections/Faq/components/Item.tsx rename to packages/twenty-website-new/src/sections/Faq/FaqItems.tsx index 000af100f8..734701c74a 100644 --- a/packages/twenty-website-new/src/sections/Faq/components/Item.tsx +++ b/packages/twenty-website-new/src/sections/Faq/FaqItems.tsx @@ -1,17 +1,28 @@ 'use client'; +import { Accordion as BaseAccordion } from '@base-ui/react/accordion'; import { MinusIcon, PlusIcon, RectangleFillIcon, RectangleOutlineIcon, } from '@/icons'; -import type { FaqQuestionType } from '@/sections/Faq/types/FaqQuestion'; -import { useRenderMessage } from '@/lib/i18n/use-render-message'; +import type { FaqQuestionType } from '@/sections/Faq/faq.data'; +import { useLingui } from '@lingui/react'; import { theme } from '@/theme'; -import { Accordion as BaseAccordion } from '@base-ui/react/accordion'; import { styled } from '@linaria/react'; +const AccordionList = styled.div` + margin-bottom: 0; + margin-left: auto; + margin-right: auto; + margin-top: 0; + + @media (min-width: ${theme.breakpoints.md}px) { + width: 66%; + } +`; + const QuestionText = styled.span` color: ${theme.colors.secondary.text[40]}; font-size: 2rem; @@ -118,7 +129,7 @@ const RowTrigger = styled.button` } `; -export const ItemRow = styled.div` +const ItemRow = styled.div` border-top: 1px solid ${theme.colors.secondary.text[40]}; display: grid; grid-template-columns: 14px 12px 1fr 32px 36px; @@ -207,8 +218,8 @@ type ItemProps = { value: string; }; -export function Item({ question, value }: ItemProps) { - const renderText = useRenderMessage(); +function Item({ question, value }: ItemProps) { + const { i18n } = useLingui(); return ( }> @@ -229,7 +240,7 @@ export function Item({ question, value }: ItemProps) { - {renderText(question.question.text)} + {i18n._(question.question)} @@ -252,9 +263,23 @@ export function Item({ question, value }: ItemProps) { } keepMounted> - {renderText(question.answer.text)} + {i18n._(question.answer)} ); } + +type FaqItemsProps = { + questions: FaqQuestionType[]; +}; + +export function FaqItems({ questions }: FaqItemsProps) { + return ( + }> + {questions.map((faqQuestion, index) => ( + + ))} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx b/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx deleted file mode 100644 index 3a65dd915b..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledCta = styled.div` - column-gap: ${theme.spacing(2)}; - display: grid; - grid-template-columns: auto auto; - justify-content: start; -`; - -type CtaProps = { - children: ReactNode; -}; - -export function Cta({ children }: CtaProps) { - return {children}; -} diff --git a/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx b/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx deleted file mode 100644 index 1b585524de..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Heading as BaseHeading } from '@/design-system/components'; -import { theme } from '@/theme'; -import { css } from '@linaria/core'; -import type { ReactNode } from 'react'; - -const faqHeadingClassName = css` - white-space: pre-line; - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 700px; - } -`; - -type HeadingProps = { - children: ReactNode; -}; - -export function Heading({ children }: HeadingProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx b/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx deleted file mode 100644 index b73d532ca7..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledIntro = styled.div` - display: grid; - grid-template-columns: 1fr; - row-gap: ${theme.spacing(6)}; -`; - -type IntroProps = { - children: ReactNode; -}; - -export function Intro({ children }: IntroProps) { - return {children}; -} diff --git a/packages/twenty-website-new/src/sections/Faq/components/Items.tsx b/packages/twenty-website-new/src/sections/Faq/components/Items.tsx deleted file mode 100644 index 056e4ce10b..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/components/Items.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { Accordion as BaseAccordion } from '@base-ui/react/accordion'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { FaqQuestionType } from '@/sections/Faq/types/FaqQuestion'; -import { Item } from './Item'; - -type ItemsProps = { - questions: FaqQuestionType[]; -}; - -const AccordionList = styled.div` - margin-bottom: 0; - margin-left: auto; - margin-right: auto; - margin-top: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - width: 66%; - } -`; - -export function Items({ questions }: ItemsProps) { - return ( - }> - {questions.map((faqQuestion, index) => ( - - ))} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Faq/components/index.ts b/packages/twenty-website-new/src/sections/Faq/components/index.ts deleted file mode 100644 index 8cfe5ef227..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/components/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Cta } from './Cta'; -import { Heading } from './Heading'; -import { Intro } from './Intro'; -import { Items } from './Items'; -import { Root } from './Root'; - -export const Faq = { - Cta, - Heading, - Intro, - Items, - Root, -}; diff --git a/packages/twenty-website-new/src/sections/Faq/data.ts b/packages/twenty-website-new/src/sections/Faq/data.ts deleted file mode 100644 index 7c73b96e06..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/data.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import type { FaqDataType } from '@/sections/Faq/types'; - -export const FAQ_DATA: FaqDataType = { - eyebrow: { - heading: { - text: msg`Any Questions?`, - fontFamily: 'sans', - }, - }, - questions: [ - { - question: { - text: msg`Is Twenty really open-source?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`, - }, - }, - { - question: { - text: msg`How long does it take to get started?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Sign up for Cloud in under a minute and start your 30-day trial. For larger rollouts, our 4-hour Onboarding Packs or certified partners get you live in 1–2 weeks.`, - }, - }, - { - question: { - text: msg`Can I migrate from Salesforce or HubSpot?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Yes. Import your data via CSV, or use our API for 50,000+ records. Our partners can handle the full migration for you.`, - }, - }, - { - question: { - text: msg`Do I need a developer to customize Twenty?`, - fontFamily: 'sans', - }, - answer: { - text: msg`No. Build custom objects, fields, views, and no-code workflows straight from Settings. Unlimited, no extra charge.`, - }, - }, - { - question: { - text: msg`Can developers extend Twenty with code?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Yes, with our Apps framework. Scaffold an extension with \`npx create-twenty-app\` and ship custom objects, server-side logic functions, React components that render inside Twenty\u2019s UI, AI skills and agents, views, and navigation, all in TypeScript, deployable to any workspace.`, - }, - }, - { - question: { - text: msg`Does Twenty work with Claude, ChatGPT, and Cursor?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Yes. Every Cloud workspace ships with a native MCP server. Connect your AI assistant via OAuth and it can read and write your CRM data in natural language.`, - }, - }, - { - question: { - text: msg`What does Twenty cost?`, - fontFamily: 'sans', - }, - answer: { - text: msg`Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control.`, - }, - }, - ], -}; diff --git a/packages/twenty-website-new/src/sections/Faq/faq.data.ts b/packages/twenty-website-new/src/sections/Faq/faq.data.ts new file mode 100644 index 0000000000..fae0454dd2 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Faq/faq.data.ts @@ -0,0 +1,38 @@ +import { msg } from '@lingui/core/macro'; +import type { MessageDescriptor } from '@lingui/core'; + +export type FaqQuestionType = { + question: MessageDescriptor; + answer: MessageDescriptor; +}; + +export const FAQ_QUESTIONS: FaqQuestionType[] = [ + { + question: msg`Is Twenty really open-source?`, + answer: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`, + }, + { + question: msg`How long does it take to get started?`, + answer: msg`Sign up for Cloud in under a minute and start your 30-day trial. For larger rollouts, our 4-hour Onboarding Packs or certified partners get you live in 1–2 weeks.`, + }, + { + question: msg`Can I migrate from Salesforce or HubSpot?`, + answer: msg`Yes. Import your data via CSV, or use our API for 50,000+ records. Our partners can handle the full migration for you.`, + }, + { + question: msg`Do I need a developer to customize Twenty?`, + answer: msg`No. Build custom objects, fields, views, and no-code workflows straight from Settings. Unlimited, no extra charge.`, + }, + { + question: msg`Can developers extend Twenty with code?`, + answer: msg`Yes, with our Apps framework. Scaffold an extension with \`npx create-twenty-app\` and ship custom objects, server-side logic functions, React components that render inside Twenty\u2019s UI, AI skills and agents, views, and navigation, all in TypeScript, deployable to any workspace.`, + }, + { + question: msg`Does Twenty work with Claude, ChatGPT, and Cursor?`, + answer: msg`Yes. Every Cloud workspace ships with a native MCP server. Connect your AI assistant via OAuth and it can read and write your CRM data in natural language.`, + }, + { + question: msg`What does Twenty cost?`, + answer: msg`Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control.`, + }, +]; diff --git a/packages/twenty-website-new/src/sections/Faq/index.ts b/packages/twenty-website-new/src/sections/Faq/index.ts new file mode 100644 index 0000000000..d444ce801e --- /dev/null +++ b/packages/twenty-website-new/src/sections/Faq/index.ts @@ -0,0 +1,2 @@ +export { Faq } from './Faq'; +export { FAQ_QUESTIONS } from './faq.data'; diff --git a/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts b/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts deleted file mode 100644 index 5b2b1f2ccf..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow'; -import { type FaqQuestionType } from '@/sections/Faq/types/FaqQuestion'; - -export type FaqDataType = { - eyebrow: MessageEyebrow; - questions: FaqQuestionType[]; -}; diff --git a/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts b/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts deleted file mode 100644 index 4bcf494bd0..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { MessageBody } from '@/lib/i18n/message-body'; -import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment'; - -export type FaqQuestionType = { - question: MessageHeadingSegment; - answer: MessageBody; -}; diff --git a/packages/twenty-website-new/src/sections/Faq/types/index.ts b/packages/twenty-website-new/src/sections/Faq/types/index.ts deleted file mode 100644 index 08f9220111..0000000000 --- a/packages/twenty-website-new/src/sections/Faq/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { FaqDataType } from './FaqData'; -export type { FaqQuestionType } from './FaqQuestion'; diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx index 45d1b39baa..adc25b4951 100644 --- a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx @@ -1,331 +1,10 @@ 'use client'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { loadImportedGeometryFromUrl } from '@/lib/halftone'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; -import { useEffect, useLayoutEffect, useRef } from 'react'; -import * as THREE from 'three'; +import { useLayoutEffect, useRef } from 'react'; import { createAnimationFrameLoop } from '@/lib/animation'; -import { observeElementSize } from '@/lib/dom/observe-element-size'; -import { - createVisualRenderLoop, - tryCreateSiteWebGlRenderer, - type VisualRenderLoop, - type VisualRenderLoopFrame, -} from '@/lib/visual-runtime'; - -const VIRTUAL_RENDER_HEIGHT = 768; - -const passThroughVertexShader = /* glsl */ ` - varying vec2 vUv; - - void main() { - vUv = uv; - gl_Position = vec4(position, 1.0); - } -`; - -const blurFragmentShader = /* glsl */ ` - precision highp float; - - uniform sampler2D tInput; - uniform vec2 dir; - uniform vec2 res; - - varying vec2 vUv; - - void main() { - vec4 sum = vec4(0.0); - vec2 px = dir / res; - - float w[5]; - w[0] = 0.227027; - w[1] = 0.1945946; - w[2] = 0.1216216; - w[3] = 0.054054; - w[4] = 0.016216; - - sum += texture2D(tInput, vUv) * w[0]; - - for (int i = 1; i < 5; i++) { - float fi = float(i) * 3.0; - sum += texture2D(tInput, vUv + px * fi) * w[i]; - sum += texture2D(tInput, vUv - px * fi) * w[i]; - } - - gl_FragColor = sum; - } -`; - -const halftoneFragmentShader = /* glsl */ ` - precision highp float; - - uniform sampler2D tScene; - uniform sampler2D tGlow; - uniform vec2 resolution; - uniform float numRows; - uniform float glowStr; - uniform float contrast; - uniform float power; - uniform float shading; - uniform float baseInk; - uniform float maxBar; - uniform float rowMerge; - uniform float cellRatio; - uniform float cutoff; - uniform float highlightOpen; - uniform float shadowGrouping; - uniform float shadowCrush; - uniform vec3 dashColor; - uniform float time; - uniform float waveAmount; - uniform float waveSpeed; - uniform float distanceScale; - uniform vec2 interactionUv; - uniform vec2 interactionVelocity; - uniform vec2 dragOffset; - uniform float hoverLightStrength; - uniform float hoverLightRadius; - uniform float hoverFlowStrength; - uniform float hoverFlowRadius; - uniform float dragFlowStrength; - uniform float dragFlowRadius; - uniform float cropToBounds; - - varying vec2 vUv; - - void main() { - // Crop to image bounds: discard fragments outside source image (image mode only) - if (cropToBounds > 0.5) { - vec4 boundsCheck = texture2D(tScene, vUv); - if (boundsCheck.a < 0.01) { - gl_FragColor = vec4(0.0); - return; - } - } - - float baseRowH = resolution.y / (numRows * distanceScale); - vec2 pointerPx = interactionUv * resolution; - vec2 fragDelta = gl_FragCoord.xy - pointerPx; - float fragDist = length(fragDelta); - vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); - float velocityMagnitude = length(interactionVelocity); - vec2 motionDir = velocityMagnitude > 0.001 - ? interactionVelocity / velocityMagnitude - : vec2(0.0, 0.0); - float motionBias = velocityMagnitude > 0.001 - ? dot(-radialDir, motionDir) * 0.5 + 0.5 - : 0.5; - - float hoverLightMask = 0.0; - if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * resolution.y; - hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); - } - - float hoverFlowMask = 0.0; - if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * resolution.y; - hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); - } - - float dragFlowMask = 0.0; - if (dragFlowStrength > 0.0) { - float dragRadiusPx = dragFlowRadius * resolution.y; - dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); - } - - vec2 hoverDisplacement = - radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + - motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; - vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; - vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; - - float densityBoost = - hoverFlowStrength * hoverFlowMask * 0.22 + - dragFlowStrength * dragFlowMask * 0.16; - float rowH = baseRowH / (1.0 + densityBoost); - - float offsetY = effectCoord.y; - float row = floor(offsetY / rowH); - float rowFrac = offsetY / rowH - row; - float rowV = (row + 0.5) * rowH / resolution.y; - float dy = abs(rowFrac - 0.5); - - float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; - float effectiveX = effectCoord.x + waveOffset; - - float localCellRatio = cellRatio * ( - 1.0 + - hoverFlowStrength * hoverFlowMask * 0.08 + - dragFlowStrength * dragFlowMask * 0.1 * motionBias - ); - float cellW = rowH * localCellRatio; - float cellIdx = floor(effectiveX / cellW); - float cellFrac = (effectiveX - cellIdx * cellW) / cellW; - float cellU = (cellIdx + 0.5) * cellW / resolution.x; - - vec2 sampleUv = vec2( - clamp(cellU, 0.0, 1.0), - clamp(rowV, 0.0, 1.0) - ); - - vec4 sceneSample = texture2D(tScene, sampleUv); - vec4 glowCell = texture2D(tGlow, sampleUv); - - float mask = smoothstep(0.02, 0.08, sceneSample.a); - float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); - float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); - float detail = lum - avgLum; - - float litLum = lum + max(detail, 0.0) * shading - - max(-detail, 0.0) * shading * 0.55; - float lightLift = - hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; - float lightFocus = hoverLightStrength * hoverLightMask * 0.12; - litLum = clamp(litLum + lightLift, 0.0, 1.0); - litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); - litLum = pow(litLum, max(contrast - lightFocus, 0.25)); - - float darkness = 1.0 - litLum; - float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); - groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); - float groupedDarkness = 1.0 - groupedLum; - darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); - darkness = clamp( - (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), - 0.0, - 1.0 - ); - - float shadowMask = smoothstep(0.42, 0.96, darkness); - darkness = mix( - darkness, - mix(darkness, 1.0, shadowMask), - shadowCrush - ); - - float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); - float ink = mix(inkBase, 1.0, darkness); - float fill = pow(ink, 1.05) * power; - fill = clamp(fill, 0.0, 1.0) * mask; - - float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); - float dynamicBarHalfY = min( - dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), - 0.78 - ); - float dx2 = abs(cellFrac - 0.5); - float halfFill = fill * 0.5; - float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); - float capRX = dynamicBarHalf * rowH; - float capRY = dynamicBarHalfY * rowH; - - float inDash = 0.0; - if (dx2 <= bodyHalfW) { - float edgeDist = dynamicBarHalfY - dy; - inDash = smoothstep(-0.03, 0.03, edgeDist); - } else { - float cdx = (dx2 - bodyHalfW) * cellW; - float cdy = dy * rowH; - float ellipseDist = sqrt( - (cdx * cdx) / max(capRX * capRX, 0.0001) + - (cdy * cdy) / max(capRY * capRY, 0.0001) - ); - inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); - } - - inDash *= step(0.001, ink) * mask; - inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); - - vec4 glow = texture2D(tGlow, vUv); - float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); - float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); - float sharp = smoothstep(0.3, 0.5, inDash + halo); - vec3 color = dashColor * sharp; - - gl_FragColor = vec4(color, sharp); - - #include - #include - } -`; - -const GLB_URL = '/illustrations/common/faq/faq.glb'; - -const LIGHT_INTENSITY = 3.1; -const FILL_LIGHT_INTENSITY = 0.15; -const AMBIENT_LIGHT_INTENSITY = 0.08; -const LIGHT_ANGLE_DEGREES = 45; -const LIGHT_HEIGHT = 2; - -const MATERIAL_ROUGHNESS = 0.42; -const MATERIAL_METALNESS = 0.16; - -const HALFTONE_ROWS = 127; -const HALFTONE_CONTRAST = 1.6; -const HALFTONE_POWER = 1.2; -const HALFTONE_SHADING = 1.6; -const HALFTONE_BASE_INK = 0.16; -const HALFTONE_MAX_BAR = 0.24; -const HALFTONE_CELL_RATIO = 2.2; -const HALFTONE_CUTOFF = 0.02; -const HALFTONE_DASH_COLOR = '#4A38F5'; - -const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z'; -const ROTATE_SPEED = 0.1; -const ROTATE_PING_PONG = false; -const INITIAL_ROTATE_ELAPSED = 275.10000000007847; -const INITIAL_TIME_ELAPSED = 249.21849999998116; -const BASE_CAMERA_DISTANCE = 4; -const MODEL_OFFSET_Y = 0.52; - -function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -function createRenderTarget(width: number, height: number) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function setPrimaryLightPosition( - light: THREE.DirectionalLight, - angleDegrees: number, - height: number, -) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function createFaqMaterial(environmentTexture: THREE.Texture) { - return new THREE.MeshPhysicalMaterial({ - color: 0xd4d0c8, - roughness: MATERIAL_ROUGHNESS, - metalness: MATERIAL_METALNESS, - envMap: environmentTexture, - envMapIntensity: 0.25, - clearcoat: 0, - clearcoatRoughness: 0.08, - reflectivity: 0.5, - transmission: 0, - }); -} +import { useFaqBackground } from './use-faq-background'; const FaqVisualShell = styled.div` bottom: 0; @@ -399,325 +78,7 @@ export function FaqBackground() { }; }, []); - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - let cancelled = false; - let renderLoop: VisualRenderLoop | null = null; - let rotateElapsed = INITIAL_ROTATE_ELAPSED; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round( - getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), - ), - 1, - ); - - const renderer = tryCreateSiteWebGlRenderer({ - antialias: false, - alpha: true, - onContextLost: () => { - renderLoop?.stop(); - }, - }); - - if (renderer === null) { - return; - } - - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const environmentTexture = createEnvironmentTexture(renderer); - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = BASE_CAMERA_DISTANCE; - - const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); - setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - FILL_LIGHT_INTENSITY, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - AMBIENT_LIGHT_INTENSITY, - ); - scene3d.add(ambientLight); - - const material = createFaqMaterial(environmentTexture); - const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); - mesh.visible = false; - scene3d.add(mesh); - - const sceneTarget = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const blurTargetA = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const blurTargetB = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - resolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - numRows: { value: HALFTONE_ROWS }, - glowStr: { value: 0 }, - contrast: { value: HALFTONE_CONTRAST }, - power: { value: HALFTONE_POWER }, - shading: { value: HALFTONE_SHADING }, - baseInk: { value: HALFTONE_BASE_INK }, - maxBar: { value: HALFTONE_MAX_BAR }, - cellRatio: { value: HALFTONE_CELL_RATIO }, - cutoff: { value: HALFTONE_CUTOFF }, - dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - distanceScale: { value: 1 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - dragFlowRadius: { value: 0.24 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const applySize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - blurHorizontalMaterial.uniforms.res.value.set( - virtualWidth, - virtualHeight, - ); - blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight); - halftoneMaterial.uniforms.resolution.value.set( - virtualWidth, - virtualHeight, - ); - }; - - applySize(); - const stopObservingSize = observeElementSize(container, applySize); - - loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model') - .then((geometry) => { - if (cancelled) { - geometry.dispose(); - return; - } - - mesh.geometry.dispose(); - mesh.geometry = geometry; - mesh.visible = true; - }) - .catch((error) => { - console.error(error); - }); - - const renderFrame = ( - _timestamp: DOMHighResTimeStamp, - { elapsedSeconds }: VisualRenderLoopFrame, - ) => { - if (cancelled) { - return; - } - - const delta = 1 / 60; - const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; - halftoneMaterial.uniforms.time.value = elapsedTime; - - rotateElapsed += delta; - - const rotateProgress = ROTATE_PING_PONG - ? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI - : rotateElapsed * ROTATE_SPEED; - const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1; - const axisProgress = rotateProgress * axisDirection; - - let rotationX = 0; - let rotationY = 0; - let rotationZ = 0; - - if ( - ROTATE_AXIS === 'x' || - ROTATE_AXIS === 'xy' || - ROTATE_AXIS === '-x' || - ROTATE_AXIS === '-xy' - ) { - rotationX += axisProgress; - } - - if ( - ROTATE_AXIS === 'y' || - ROTATE_AXIS === 'xy' || - ROTATE_AXIS === '-y' || - ROTATE_AXIS === '-xy' - ) { - rotationY += axisProgress; - } - - if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') { - rotationZ += axisProgress; - } - - mesh.rotation.set(rotationX, rotationY, rotationZ); - mesh.position.y = MODEL_OFFSET_Y; - mesh.scale.setScalar(1); - - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; - camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0); - setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); - - renderer.setRenderTarget(sceneTarget); - renderer.clear(); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderLoop = createVisualRenderLoop({ - renderFrame, - shouldRender: () => !cancelled, - target: container, - targetVisibilityOptions: { rootMargin: '100px' }, - }); - renderLoop.start(); - - return () => { - cancelled = true; - renderLoop?.dispose(); - stopObservingSize(); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - mesh.geometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - environmentTexture.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; - }, []); + useFaqBackground({ mountRef: mountReference }); return ( diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts b/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts new file mode 100644 index 0000000000..46545bb448 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts @@ -0,0 +1,651 @@ +'use client'; + +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { loadImportedGeometryFromUrl } from '@/lib/halftone'; +import { useEffect, type RefObject } from 'react'; +import * as THREE from 'three'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; +import { + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, +} from '@/lib/visual-runtime'; + +const VIRTUAL_RENDER_HEIGHT = 768; + +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +const blurFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; + + void main() { + vec4 sum = vec4(0.0); + vec2 px = dir / res; + + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; + + sum += texture2D(tInput, vUv) * w[0]; + + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; + } + + gl_FragColor = sum; + } +`; + +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float rowMerge; + uniform float cellRatio; + uniform float cutoff; + uniform float highlightOpen; + uniform float shadowGrouping; + uniform float shadowCrush; + uniform vec3 dashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float distanceScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float dragFlowRadius; + uniform float cropToBounds; + + varying vec2 vUv; + + void main() { + // Crop to image bounds: discard fragments outside source image (image mode only) + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + float baseRowH = resolution.y / (numRows * distanceScale); + vec2 pointerPx = interactionUv * resolution; + vec2 fragDelta = gl_FragCoord.xy - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * resolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * resolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + float dragFlowMask = 0.0; + if (dragFlowStrength > 0.0) { + float dragRadiusPx = dragFlowRadius * resolution.y; + dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; + vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; + vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; + + float densityBoost = + hoverFlowStrength * hoverFlowMask * 0.22 + + dragFlowStrength * dragFlowMask * 0.16; + float rowH = baseRowH / (1.0 + densityBoost); + + float offsetY = effectCoord.y; + float row = floor(offsetY / rowH); + float rowFrac = offsetY / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; + + float localCellRatio = cellRatio * ( + 1.0 + + hoverFlowStrength * hoverFlowMask * 0.08 + + dragFlowStrength * dragFlowMask * 0.1 * motionBias + ); + float cellW = rowH * localCellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; + float lightFocus = hoverLightStrength * hoverLightMask * 0.12; + litLum = clamp(litLum + lightLift, 0.0, 1.0); + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, max(contrast - lightFocus, 0.25)); + + float darkness = 1.0 - litLum; + float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); + float groupedDarkness = 1.0 - groupedLum; + darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); + darkness = clamp( + (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), + 0.0, + 1.0 + ); + + float shadowMask = smoothstep(0.42, 0.96, darkness); + darkness = mix( + darkness, + mix(darkness, 1.0, shadowMask), + shadowCrush + ); + + float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); + float ink = mix(inkBase, 1.0, darkness); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dynamicBarHalfY = min( + dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), + 0.78 + ); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalfY - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float ellipseDist = sqrt( + (cdx * cdx) / max(capRX * capRX, 0.0001) + + (cdy * cdy) / max(capRY * capRY, 0.0001) + ); + inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +const GLB_URL = '/illustrations/common/faq/faq.glb'; + +const LIGHT_INTENSITY = 3.1; +const FILL_LIGHT_INTENSITY = 0.15; +const AMBIENT_LIGHT_INTENSITY = 0.08; +const LIGHT_ANGLE_DEGREES = 45; +const LIGHT_HEIGHT = 2; + +const MATERIAL_ROUGHNESS = 0.42; +const MATERIAL_METALNESS = 0.16; + +const HALFTONE_ROWS = 127; +const HALFTONE_CONTRAST = 1.6; +const HALFTONE_POWER = 1.2; +const HALFTONE_SHADING = 1.6; +const HALFTONE_BASE_INK = 0.16; +const HALFTONE_MAX_BAR = 0.24; +const HALFTONE_CELL_RATIO = 2.2; +const HALFTONE_CUTOFF = 0.02; +const HALFTONE_DASH_COLOR = '#4A38F5'; + +const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z'; +const ROTATE_SPEED = 0.1; +const ROTATE_PING_PONG = false; +const INITIAL_ROTATE_ELAPSED = 275.10000000007847; +const INITIAL_TIME_ELAPSED = 249.21849999998116; +const BASE_CAMERA_DISTANCE = 4; +const MODEL_OFFSET_Y = 0.52; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, +) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +function createFaqMaterial(environmentTexture: THREE.Texture) { + return new THREE.MeshPhysicalMaterial({ + color: 0xd4d0c8, + roughness: MATERIAL_ROUGHNESS, + metalness: MATERIAL_METALNESS, + envMap: environmentTexture, + envMapIntensity: 0.25, + clearcoat: 0, + clearcoatRoughness: 0.08, + reflectivity: 0.5, + transmission: 0, + }); +} + +export function useFaqBackground({ + mountRef, +}: { + mountRef: RefObject; +}) { + useEffect(() => { + const container = mountRef.current; + + if (!container) { + return; + } + + let cancelled = false; + let renderLoop: VisualRenderLoop | null = null; + let rotateElapsed = INITIAL_ROTATE_ELAPSED; + + const getWidth = () => Math.max(container.clientWidth, 1); + const getHeight = () => Math.max(container.clientHeight, 1); + const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualWidth = () => + Math.max( + Math.round( + getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), + ), + 1, + ); + + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + + const canvas = renderer.domElement; + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.width = '100%'; + container.appendChild(canvas); + + const environmentTexture = createEnvironmentTexture(renderer); + const scene3d = new THREE.Scene(); + scene3d.background = null; + + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, + ); + camera.position.z = BASE_CAMERA_DISTANCE; + + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + scene3d.add(primaryLight); + + const fillLight = new THREE.DirectionalLight( + 0xffffff, + FILL_LIGHT_INTENSITY, + ); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight( + 0xffffff, + AMBIENT_LIGHT_INTENSITY, + ); + scene3d.add(ambientLight); + + const material = createFaqMaterial(environmentTexture); + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); + mesh.visible = false; + scene3d.add(mesh); + + const sceneTarget = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetA = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetB = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + numRows: { value: HALFTONE_ROWS }, + glowStr: { value: 0 }, + contrast: { value: HALFTONE_CONTRAST }, + power: { value: HALFTONE_POWER }, + shading: { value: HALFTONE_SHADING }, + baseInk: { value: HALFTONE_BASE_INK }, + maxBar: { value: HALFTONE_MAX_BAR }, + cellRatio: { value: HALFTONE_CELL_RATIO }, + cutoff: { value: HALFTONE_CUTOFF }, + dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add( + new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), + ); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const applySize = () => { + const width = getWidth(); + const height = getHeight(); + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(virtualWidth, virtualHeight); + blurTargetA.setSize(virtualWidth, virtualHeight); + blurTargetB.setSize(virtualWidth, virtualHeight); + blurHorizontalMaterial.uniforms.res.value.set( + virtualWidth, + virtualHeight, + ); + blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight); + halftoneMaterial.uniforms.resolution.value.set( + virtualWidth, + virtualHeight, + ); + }; + + applySize(); + const stopObservingSize = observeElementSize(container, applySize); + + loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model') + .then((geometry) => { + if (cancelled) { + geometry.dispose(); + return; + } + + mesh.geometry.dispose(); + mesh.geometry = geometry; + mesh.visible = true; + }) + .catch((error) => { + console.error(error); + }); + + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { + if (cancelled) { + return; + } + + const delta = 1 / 60; + const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; + halftoneMaterial.uniforms.time.value = elapsedTime; + + rotateElapsed += delta; + + const rotateProgress = ROTATE_PING_PONG + ? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI + : rotateElapsed * ROTATE_SPEED; + const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1; + const axisProgress = rotateProgress * axisDirection; + + let rotationX = 0; + let rotationY = 0; + let rotationZ = 0; + + if ( + ROTATE_AXIS === 'x' || + ROTATE_AXIS === 'xy' || + ROTATE_AXIS === '-x' || + ROTATE_AXIS === '-xy' + ) { + rotationX += axisProgress; + } + + if ( + ROTATE_AXIS === 'y' || + ROTATE_AXIS === 'xy' || + ROTATE_AXIS === '-y' || + ROTATE_AXIS === '-xy' + ) { + rotationY += axisProgress; + } + + if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') { + rotationZ += axisProgress; + } + + mesh.rotation.set(rotationX, rotationY, rotationZ); + mesh.position.y = MODEL_OFFSET_Y; + mesh.scale.setScalar(1); + + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; + camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + + renderer.setRenderTarget(sceneTarget); + renderer.clear(); + renderer.render(scene3d, camera); + + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + }; + + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); + + return () => { + cancelled = true; + renderLoop?.dispose(); + stopObservingSize(); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + mesh.geometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); + renderer.dispose(); + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [mountRef]); +} diff --git a/packages/twenty-website-new/src/sections/Feature/Feature.tsx b/packages/twenty-website-new/src/sections/Feature/Feature.tsx new file mode 100644 index 0000000000..cd5e60b616 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Feature/Feature.tsx @@ -0,0 +1,177 @@ +import { Container } from '@/design-system/components'; +import type { ImageType } from '@/design-system/components/Image'; +import type { MessageDescriptor } from '@lingui/core'; +import { type Page, Pages } from '@/lib/pages'; +import { theme, type Scheme } from '@/theme'; +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; + +import { TileContent } from './TileContent'; +import { TileVisual } from './TileVisual'; + +export type FeatureTileType = { + bullets: MessageDescriptor[]; + heading: MessageDescriptor; + icon: string; + image: ImageType; +}; + +// --- Root --- + +const StyledSection = styled.section` + width: 100%; + + &[data-scheme='light'] { + background-color: var(--color-white); + } + + &[data-scheme='muted'] { + background-color: var(--color-neutral); + } + + &[data-scheme='dark'] { + background-color: var(--color-black); + } +`; + +const StyledContainer = styled(Container)` + display: grid; + grid-template-columns: 1fr; + gap: ${theme.spacing(6)}; + padding-bottom: ${theme.spacing(12)}; + padding-left: ${theme.spacing(4)}; + padding-right: ${theme.spacing(4)}; + padding-top: ${theme.spacing(12)}; + + @media (min-width: ${theme.breakpoints.md}px) { + gap: ${theme.spacing(10)}; + padding-bottom: ${theme.spacing(20)}; + padding-left: ${theme.spacing(10)}; + padding-right: ${theme.spacing(10)}; + padding-top: ${theme.spacing(20)}; + } +`; + +type RootProps = { + backgroundColor?: string; + children: ReactNode; + scheme?: Scheme; +}; + +function Root({ backgroundColor, children, scheme }: RootProps) { + return ( + + {children} + + ); +} + +// --- Intro --- + +const StyledIntro = styled.div` + display: grid; + grid-template-columns: 1fr; + row-gap: ${theme.spacing(6)}; + text-align: start; + + &[data-align='center'] { + justify-items: center; + margin-left: auto; + margin-right: auto; + text-align: center; + } + + @media (min-width: ${theme.breakpoints.md}px) { + max-width: 688px; + + &[data-page=${Pages.Home}] { + max-width: 900px; + } + } +`; + +type IntroProps = { + align: 'center' | 'left'; + children: ReactNode; + page: Page; +}; + +function Intro({ align, children, page }: IntroProps) { + return ( + + {children} + + ); +} + +// --- Tiles --- + +const TilesGrid = styled.div` + display: grid; + gap: ${theme.spacing(4)}; + grid-template-columns: 1fr; + + @media (min-width: ${theme.breakpoints.md}px) { + gap: ${theme.spacing(4)}; + grid-template-columns: repeat(12, minmax(0, 1fr)); + } +`; + +const TileCell = styled.div` + min-width: 0; + + @media (min-width: ${theme.breakpoints.md}px) { + &[data-index='0'] { + grid-column: span 12; + } + &[data-index='1'], + &[data-index='2'], + &[data-index='5'], + &[data-index='6'] { + grid-column: span 6; + } + &[data-index='3'] { + grid-column: span 4; + } + &[data-index='4'] { + grid-column: span 8; + } + } +`; + +const TileRoot = styled.article` + background-color: ${theme.colors.primary.background[100]}; + border: 1px solid ${theme.colors.primary.border[20]}; + border-radius: ${theme.radius(2)}; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + height: 100%; + min-width: 0; + overflow: hidden; +`; + +type TilesProps = { + mask: ImageType; + tiles: FeatureTileType[]; +}; + +function Tiles({ mask, tiles }: TilesProps) { + return ( + + {tiles.map((tile, index) => ( + + + + + + + ))} + + ); +} + +export const Feature = { Intro, Root, Tiles }; diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx b/packages/twenty-website-new/src/sections/Feature/TileContent.tsx similarity index 69% rename from packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx rename to packages/twenty-website-new/src/sections/Feature/TileContent.tsx index f8e0bd8e58..f7714f96d7 100644 --- a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx +++ b/packages/twenty-website-new/src/sections/Feature/TileContent.tsx @@ -1,10 +1,11 @@ import { styled } from '@linaria/react'; -import { Body, Heading } from '@/design-system/components'; +import { Body, Heading, HeadingPart } from '@/design-system/components'; import { INFORMATIVE_ICONS } from '@/icons'; -import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; + +import type { FeatureTileType } from './Feature'; const Content = styled.div` display: grid; @@ -53,11 +54,11 @@ const BulletItem = styled.li` `; type TileContentProps = { - renderText: (descriptor: MessageDescriptor) => string; tile: FeatureTileType; }; -export function TileContent({ renderText, tile }: TileContentProps) { +export function TileContent({ tile }: TileContentProps) { + const i18n = getServerI18n(); const Icon = INFORMATIVE_ICONS[tile.icon]; return ( @@ -69,25 +70,17 @@ export function TileContent({ renderText, tile }: TileContentProps) { ) : null} - + + {i18n._(tile.heading)} + {tile.bullets.map((bullet, bulletIndex) => ( - + + {i18n._(bullet)} + ))} diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileVisual.tsx b/packages/twenty-website-new/src/sections/Feature/TileVisual.tsx similarity index 100% rename from packages/twenty-website-new/src/sections/Feature/components/Tile/TileVisual.tsx rename to packages/twenty-website-new/src/sections/Feature/TileVisual.tsx diff --git a/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx b/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx deleted file mode 100644 index 028a9d62e4..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type Page, Pages } from '@/lib/pages'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledIntro = styled.div` - display: grid; - grid-template-columns: 1fr; - row-gap: ${theme.spacing(6)}; - text-align: start; - - &[data-align='center'] { - justify-items: center; - margin-left: auto; - margin-right: auto; - text-align: center; - } - - @media (min-width: ${theme.breakpoints.md}px) { - max-width: 688px; - - &[data-page=${Pages.Home}] { - max-width: 900px; - } - } -`; - -type IntroProps = { - align: 'center' | 'left'; - children: ReactNode; - page: Page; -}; - -export function Intro({ align, children, page }: IntroProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Feature/components/Root.tsx b/packages/twenty-website-new/src/sections/Feature/components/Root.tsx deleted file mode 100644 index de39a31775..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/components/Root.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Container } from '@/design-system/components'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { ReactNode } from 'react'; - -const StyledSection = styled.section` - width: 100%; -`; - -const StyledContainer = styled(Container)` - display: grid; - grid-template-columns: 1fr; - gap: ${theme.spacing(6)}; - padding-bottom: ${theme.spacing(12)}; - padding-left: ${theme.spacing(4)}; - padding-right: ${theme.spacing(4)}; - padding-top: ${theme.spacing(12)}; - - @media (min-width: ${theme.breakpoints.md}px) { - gap: ${theme.spacing(10)}; - padding-bottom: ${theme.spacing(20)}; - padding-left: ${theme.spacing(10)}; - padding-right: ${theme.spacing(10)}; - padding-top: ${theme.spacing(20)}; - } -`; - -type RootProps = { - backgroundColor: string; - children: ReactNode; -}; - -export function Root({ backgroundColor, children }: RootProps) { - return ( - - {children} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx b/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx deleted file mode 100644 index 377ee7c3a8..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { styled } from '@linaria/react'; - -import type { ImageType } from '@/design-system/components/Image'; -import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile'; -import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; - -import { TileContent } from './TileContent'; -import { TileVisual } from './TileVisual'; - -const TileRoot = styled.article` - background-color: ${theme.colors.primary.background[100]}; - border: 1px solid ${theme.colors.primary.border[20]}; - border-radius: ${theme.radius(2)}; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto auto; - height: 100%; - min-width: 0; - overflow: hidden; -`; - -type TileProps = { - index: number; - mask: ImageType; - renderText: (descriptor: MessageDescriptor) => string; - tile: FeatureTileType; -}; - -export function Tile({ index, mask, renderText, tile }: TileProps) { - return ( - - - - - ); -} diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx b/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx deleted file mode 100644 index 1693214d7e..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ImageType } from '@/design-system/components/Image'; -import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile'; -import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; -import { styled } from '@linaria/react'; -import { Tile } from './Tile/Tile'; - -const TilesGrid = styled.div` - display: grid; - gap: ${theme.spacing(4)}; - grid-template-columns: 1fr; - - @media (min-width: ${theme.breakpoints.md}px) { - gap: ${theme.spacing(4)}; - grid-template-columns: repeat(12, minmax(0, 1fr)); - } -`; - -const TileCell = styled.div` - min-width: 0; - - @media (min-width: ${theme.breakpoints.md}px) { - &[data-index='0'] { - grid-column: span 12; - } - &[data-index='1'], - &[data-index='2'], - &[data-index='5'], - &[data-index='6'] { - grid-column: span 6; - } - &[data-index='3'] { - grid-column: span 4; - } - &[data-index='4'] { - grid-column: span 8; - } - } -`; - -type TilesProps = { - mask: ImageType; - renderText: (descriptor: MessageDescriptor) => string; - tiles: FeatureTileType[]; -}; - -export function Tiles({ mask, renderText, tiles }: TilesProps) { - return ( - - {tiles.map((tile, index) => ( - - - - ))} - - ); -} diff --git a/packages/twenty-website-new/src/sections/Feature/components/index.ts b/packages/twenty-website-new/src/sections/Feature/components/index.ts deleted file mode 100644 index b0f2a64a81..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Intro } from './Intro'; -import { Root } from './Root'; -import { Tile } from './Tile/Tile'; -import { Tiles } from './Tiles'; - -export const Feature = { Intro, Root, Tile, Tiles }; diff --git a/packages/twenty-website-new/src/sections/Feature/index.ts b/packages/twenty-website-new/src/sections/Feature/index.ts new file mode 100644 index 0000000000..28c791c954 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Feature/index.ts @@ -0,0 +1 @@ +export { Feature, type FeatureTileType } from './Feature'; diff --git a/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts b/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts deleted file mode 100644 index 4b36929cce..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow'; -import type { ImageType } from '@/design-system/components/Image'; -import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile'; - -export type FeatureDataType = { - eyebrow: MessageEyebrow; - mask: ImageType; - tiles: FeatureTileType[]; -}; diff --git a/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts b/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts deleted file mode 100644 index 7426e809fe..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MessageBody } from '@/lib/i18n/message-body'; -import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment'; -import type { ImageType } from '@/design-system/components/Image'; - -export type FeatureTileType = { - bullets: MessageBody[]; - heading: MessageHeadingSegment; - icon: string; - image: ImageType; -}; diff --git a/packages/twenty-website-new/src/sections/Feature/types/index.ts b/packages/twenty-website-new/src/sections/Feature/types/index.ts deleted file mode 100644 index 06ea14a9e4..0000000000 --- a/packages/twenty-website-new/src/sections/Feature/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { FeatureDataType } from './FeatureData'; -export type { FeatureTileType } from './FeatureTile'; diff --git a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx index 721e369942..ba523f1cb6 100644 --- a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx +++ b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx @@ -1,4 +1,5 @@ import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import type { MessageDescriptor } from '@lingui/core'; import type { FooterSocialLinkType } from '@/sections/Footer/types'; import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher'; @@ -86,14 +87,14 @@ const SocialLink = styled.a` type BottomProps = { copyright: MessageDescriptor; links: FooterSocialLinkType[]; - renderText: (descriptor: MessageDescriptor) => string; }; -export function Bottom({ copyright, links, renderText }: BottomProps) { +export function Bottom({ copyright, links }: BottomProps) { + const i18n = getServerI18n(); return ( - {renderText(copyright)} + {i18n._(copyright)} diff --git a/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx b/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx index 69c09db90e..1b4efb5b00 100644 --- a/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx +++ b/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx @@ -1,14 +1,14 @@ -import { TalkToUsButton } from '@/lib/contact-cal'; +import { TalkToUsButton } from '@/sections/ContactCal'; import { LinkButton } from '@/design-system/components'; +import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n'; import type { FooterCtaType } from '@/sections/Footer/types'; -import type { MessageDescriptor } from '@lingui/core'; type FooterNavCtaProps = { cta: FooterCtaType; - renderText: (descriptor: MessageDescriptor) => string; }; -export function FooterNavCta({ cta, renderText }: FooterNavCtaProps) { +export function FooterNavCta({ cta }: FooterNavCtaProps) { + const i18n = getServerI18n(); if (cta.kind === 'contactModal') { return ( ); diff --git a/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx index 76c1757dae..393fb8f28a 100644 --- a/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx +++ b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx @@ -3,17 +3,24 @@ import { styled } from '@linaria/react'; import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react'; import Link from 'next/link'; -import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { + type ChangeEvent, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import { type AppLocale } from 'twenty-shared/translations'; -import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/app-locale-set'; -import { localizeHref } from '@/lib/i18n/localize-href'; +import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/utils/app-locale-set'; +import { localizeHref } from '@/lib/i18n/utils/localize-href'; import { getEnglishLocaleName, getNativeLocaleName, -} from '@/lib/i18n/locale-display-names'; -import { useLocale } from '@/lib/i18n/use-locale'; -import { useUnlocalizedPathname } from '@/lib/i18n/use-unlocalized-pathname'; +} from '@/lib/i18n/utils/locale-display-names'; +import { useLocale } from '@/lib/i18n/hooks/use-locale'; +import { useUnlocalizedPathname } from '@/lib/i18n/hooks/use-unlocalized-pathname'; +import { LocaleSwitcherDismissEffect } from '@/sections/Footer/effect-components/LocaleSwitcherDismissEffect'; import { theme } from '@/theme'; const SEARCH_THRESHOLD = 6; @@ -178,34 +185,9 @@ export function LocaleSwitcher() { return ALL_LOCALES.filter((entry) => matchesQuery(entry, trimmed)); }, [query]); - useEffect(() => { - if (!open) return; - - const handlePointerDown = (event: MouseEvent) => { - if (wrapperRef.current?.contains(event.target as Node)) return; - setOpen(false); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpen(false); - triggerRef.current?.focus(); - } - }; - - document.addEventListener('mousedown', handlePointerDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handlePointerDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [open]); - - useEffect(() => { - if (open && ALL_LOCALES.length > SEARCH_THRESHOLD) { - searchRef.current?.focus(); - } - }, [open]); + const handleDismiss = useCallback(() => { + setOpen(false); + }, []); if (ALL_LOCALES.length < 2) return null; @@ -230,6 +212,14 @@ export function LocaleSwitcher() { return ( + string; }; -export function Nav({ groups, renderText }: NavProps) { +export function Nav({ groups }: NavProps) { + const i18n = getServerI18n(); return ( }> {groups.map((group, index) => ( @@ -150,9 +150,7 @@ export function Nav({ groups, renderText }: NavProps) { )} - - {renderText(group.title)} - + {i18n._(group.title)} {group.links.map((link) => ( @@ -175,7 +173,7 @@ export function Nav({ groups, renderText }: NavProps) { fillColor={theme.colors.secondary.background[100]} /> - {renderText(link.label)} + {i18n._(link.label)} ))} @@ -188,7 +186,6 @@ export function Nav({ groups, renderText }: NavProps) { cta.kind === 'link' ? `${cta.href}-${cta.kind}` : cta.kind } cta={cta} - renderText={renderText} /> ))} diff --git a/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx index 72203622c4..10b5407c75 100644 --- a/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx +++ b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx @@ -4,13 +4,13 @@ jest.mock('next/navigation', () => ({ usePathname: jest.fn(), })); -jest.mock('@/lib/i18n/use-locale', () => ({ +jest.mock('@/lib/i18n/hooks/use-locale', () => ({ useLocale: jest.fn(), })); import { usePathname } from 'next/navigation'; -import { useLocale } from '@/lib/i18n/use-locale'; +import { useLocale } from '@/lib/i18n/hooks/use-locale'; import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher'; const mockUsePathname = usePathname as jest.MockedFunction; diff --git a/packages/twenty-website-new/src/sections/Footer/data.ts b/packages/twenty-website-new/src/sections/Footer/data.ts index 17fe8c8be9..f6eda75bf0 100644 --- a/packages/twenty-website-new/src/sections/Footer/data.ts +++ b/packages/twenty-website-new/src/sections/Footer/data.ts @@ -17,7 +17,7 @@ export const FOOTER_DATA: FooterDataType = { { label: msg`Partners`, href: '/partners', external: false }, { label: msg`Why Twenty`, - href: '/resources/why-twenty', + href: '/why-twenty', external: false, }, { label: msg`Articles`, href: '/articles', external: false }, diff --git a/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx b/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx new file mode 100644 index 0000000000..3803845492 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, type RefObject } from 'react'; + +type LocaleSwitcherDismissEffectProps = { + open: boolean; + onDismiss: () => void; + wrapperRef: RefObject; + triggerRef: RefObject; + searchRef: RefObject; + showSearch: boolean; +}; + +export function LocaleSwitcherDismissEffect({ + open, + onDismiss, + wrapperRef, + triggerRef, + searchRef, + showSearch, +}: LocaleSwitcherDismissEffectProps) { + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (wrapperRef.current?.contains(event.target as Node)) return; + onDismiss(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onDismiss(); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, onDismiss, wrapperRef, triggerRef]); + + useEffect(() => { + if (open && showSearch) { + searchRef.current?.focus(); + } + }, [open, showSearch, searchRef]); + + return null; +} diff --git a/packages/twenty-website-new/src/sections/Footer/index.ts b/packages/twenty-website-new/src/sections/Footer/index.ts new file mode 100644 index 0000000000..b85f2a7850 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/index.ts @@ -0,0 +1,10 @@ +export { Footer } from './components'; +export { FOOTER_DATA } from './data'; +export type { + FooterBottomType, + FooterCtaType, + FooterDataType, + FooterNavGroupType, + FooterNavLinkType, + FooterSocialLinkType, +} from './types'; diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts b/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts deleted file mode 100644 index ad1bb7f698..0000000000 --- a/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { FooterBottomType } from './FooterBottom'; -import type { FooterNavGroupType } from './FooterNavGroup'; -import type { FooterSocialLinkType } from './FooterSocialLink'; - -export type FooterDataType = { - bottom: FooterBottomType; - navGroups: FooterNavGroupType[]; - socialLinks: FooterSocialLinkType[]; -}; diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterBottom.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-bottom.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Footer/types/FooterBottom.ts rename to packages/twenty-website-new/src/sections/Footer/types/footer-bottom.ts diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterCta.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-cta.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Footer/types/FooterCta.ts rename to packages/twenty-website-new/src/sections/Footer/types/footer-cta.ts diff --git a/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts new file mode 100644 index 0000000000..035272a6ff --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts @@ -0,0 +1,9 @@ +import type { FooterBottomType } from './footer-bottom'; +import type { FooterNavGroupType } from './footer-nav-group'; +import type { FooterSocialLinkType } from './footer-social-link'; + +export type FooterDataType = { + bottom: FooterBottomType; + navGroups: FooterNavGroupType[]; + socialLinks: FooterSocialLinkType[]; +}; diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts similarity index 63% rename from packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts rename to packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts index 8f79d6f0cb..e22c7714cd 100644 --- a/packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts +++ b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts @@ -1,6 +1,6 @@ import type { MessageDescriptor } from '@lingui/core'; -import type { FooterCtaType } from './FooterCta'; -import type { FooterNavLinkType } from './FooterNavLink'; +import type { FooterCtaType } from './footer-cta'; +import type { FooterNavLinkType } from './footer-nav-link'; export type FooterNavGroupType = { ctas: FooterCtaType[]; diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterNavLink.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-link.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Footer/types/FooterNavLink.ts rename to packages/twenty-website-new/src/sections/Footer/types/footer-nav-link.ts diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterSocialLink.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-social-link.ts similarity index 100% rename from packages/twenty-website-new/src/sections/Footer/types/FooterSocialLink.ts rename to packages/twenty-website-new/src/sections/Footer/types/footer-social-link.ts diff --git a/packages/twenty-website-new/src/sections/Footer/types/index.ts b/packages/twenty-website-new/src/sections/Footer/types/index.ts index 2efff9e4b6..d1a55b2cd7 100644 --- a/packages/twenty-website-new/src/sections/Footer/types/index.ts +++ b/packages/twenty-website-new/src/sections/Footer/types/index.ts @@ -1,6 +1,6 @@ -export type { FooterBottomType } from './FooterBottom'; -export type { FooterCtaType } from './FooterCta'; -export type { FooterDataType } from './FooterData'; -export type { FooterNavGroupType } from './FooterNavGroup'; -export type { FooterNavLinkType } from './FooterNavLink'; -export type { FooterSocialLinkType } from './FooterSocialLink'; +export type { FooterBottomType } from './footer-bottom'; +export type { FooterCtaType } from './footer-cta'; +export type { FooterDataType } from './footer-data'; +export type { FooterNavGroupType } from './footer-nav-group'; +export type { FooterNavLinkType } from './footer-nav-link'; +export type { FooterSocialLinkType } from './footer-social-link'; diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx index dfcbe1aaef..92d12dc19c 100644 --- a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx @@ -1,367 +1,8 @@ 'use client'; -import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { loadImportedGeometryFromUrl } from '@/lib/halftone'; import { styled } from '@linaria/react'; -import { useEffect, useRef } from 'react'; -import * as THREE from 'three'; -import { observeElementSize } from '@/lib/dom/observe-element-size'; -import { - createVisualRenderLoop, - tryCreateSiteWebGlRenderer, - type VisualRenderLoop, - type VisualRenderLoopFrame, -} from '@/lib/visual-runtime'; - -const VIRTUAL_RENDER_HEIGHT = 768; - -const passThroughVertexShader = /* glsl */ ` - varying vec2 vUv; - - void main() { - vUv = uv; - gl_Position = vec4(position, 1.0); - } -`; - -const blurFragmentShader = /* glsl */ ` - precision highp float; - - uniform sampler2D tInput; - uniform vec2 dir; - uniform vec2 res; - - varying vec2 vUv; - - void main() { - vec4 sum = vec4(0.0); - vec2 px = dir / res; - - float w[5]; - w[0] = 0.227027; - w[1] = 0.1945946; - w[2] = 0.1216216; - w[3] = 0.054054; - w[4] = 0.016216; - - sum += texture2D(tInput, vUv) * w[0]; - - for (int i = 1; i < 5; i++) { - float fi = float(i) * 3.0; - sum += texture2D(tInput, vUv + px * fi) * w[i]; - sum += texture2D(tInput, vUv - px * fi) * w[i]; - } - - gl_FragColor = sum; - } -`; - -const halftoneFragmentShader = /* glsl */ ` - precision highp float; - - uniform sampler2D tScene; - uniform sampler2D tGlow; - uniform vec2 resolution; - uniform float numRows; - uniform float glowStr; - uniform float contrast; - uniform float power; - uniform float shading; - uniform float baseInk; - uniform float maxBar; - uniform float rowMerge; - uniform float cellRatio; - uniform float cutoff; - uniform float highlightOpen; - uniform float shadowGrouping; - uniform float shadowCrush; - uniform vec3 dashColor; - uniform float time; - uniform float waveAmount; - uniform float waveSpeed; - uniform float distanceScale; - uniform vec2 interactionUv; - uniform vec2 interactionVelocity; - uniform vec2 dragOffset; - uniform float hoverLightStrength; - uniform float hoverLightRadius; - uniform float hoverFlowStrength; - uniform float hoverFlowRadius; - uniform float dragFlowStrength; - uniform float dragFlowRadius; - uniform float cropToBounds; - - varying vec2 vUv; - - void main() { - // Crop to image bounds: discard fragments outside source image (image mode only) - if (cropToBounds > 0.5) { - vec4 boundsCheck = texture2D(tScene, vUv); - if (boundsCheck.a < 0.01) { - gl_FragColor = vec4(0.0); - return; - } - } - - float baseRowH = resolution.y / (numRows * distanceScale); - vec2 pointerPx = interactionUv * resolution; - vec2 fragDelta = gl_FragCoord.xy - pointerPx; - float fragDist = length(fragDelta); - vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); - float velocityMagnitude = length(interactionVelocity); - vec2 motionDir = velocityMagnitude > 0.001 - ? interactionVelocity / velocityMagnitude - : vec2(0.0, 0.0); - float motionBias = velocityMagnitude > 0.001 - ? dot(-radialDir, motionDir) * 0.5 + 0.5 - : 0.5; - - float hoverLightMask = 0.0; - if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * resolution.y; - hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); - } - - float hoverFlowMask = 0.0; - if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * resolution.y; - hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); - } - - float dragFlowMask = 0.0; - if (dragFlowStrength > 0.0) { - float dragRadiusPx = dragFlowRadius * resolution.y; - dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); - } - - vec2 hoverDisplacement = - radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + - motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; - vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; - vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; - - float densityBoost = - hoverFlowStrength * hoverFlowMask * 0.22 + - dragFlowStrength * dragFlowMask * 0.16; - float rowH = baseRowH / (1.0 + densityBoost); - - float offsetY = effectCoord.y; - float row = floor(offsetY / rowH); - float rowFrac = offsetY / rowH - row; - float rowV = (row + 0.5) * rowH / resolution.y; - float dy = abs(rowFrac - 0.5); - - float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; - float effectiveX = effectCoord.x + waveOffset; - - float localCellRatio = cellRatio * ( - 1.0 + - hoverFlowStrength * hoverFlowMask * 0.08 + - dragFlowStrength * dragFlowMask * 0.1 * motionBias - ); - float cellW = rowH * localCellRatio; - float cellIdx = floor(effectiveX / cellW); - float cellFrac = (effectiveX - cellIdx * cellW) / cellW; - float cellU = (cellIdx + 0.5) * cellW / resolution.x; - - vec2 sampleUv = vec2( - clamp(cellU, 0.0, 1.0), - clamp(rowV, 0.0, 1.0) - ); - - vec4 sceneSample = texture2D(tScene, sampleUv); - vec4 glowCell = texture2D(tGlow, sampleUv); - - float mask = smoothstep(0.02, 0.08, sceneSample.a); - float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); - float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); - float detail = lum - avgLum; - - float litLum = lum + max(detail, 0.0) * shading - - max(-detail, 0.0) * shading * 0.55; - float lightLift = - hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; - float lightFocus = hoverLightStrength * hoverLightMask * 0.12; - litLum = clamp(litLum + lightLift, 0.0, 1.0); - litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); - litLum = pow(litLum, max(contrast - lightFocus, 0.25)); - - float darkness = 1.0 - litLum; - float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); - groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); - float groupedDarkness = 1.0 - groupedLum; - darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); - darkness = clamp( - (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), - 0.0, - 1.0 - ); - - float shadowMask = smoothstep(0.42, 0.96, darkness); - darkness = mix( - darkness, - mix(darkness, 1.0, shadowMask), - shadowCrush - ); - - float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); - float ink = mix(inkBase, 1.0, darkness); - float fill = pow(ink, 1.05) * power; - fill = clamp(fill, 0.0, 1.0) * mask; - - float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); - float dynamicBarHalfY = min( - dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), - 0.78 - ); - float dx2 = abs(cellFrac - 0.5); - float halfFill = fill * 0.5; - float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); - float capRX = dynamicBarHalf * rowH; - float capRY = dynamicBarHalfY * rowH; - - float inDash = 0.0; - if (dx2 <= bodyHalfW) { - float edgeDist = dynamicBarHalfY - dy; - inDash = smoothstep(-0.03, 0.03, edgeDist); - } else { - float cdx = (dx2 - bodyHalfW) * cellW; - float cdy = dy * rowH; - float ellipseDist = sqrt( - (cdx * cdx) / max(capRX * capRX, 0.0001) + - (cdy * cdy) / max(capRY * capRY, 0.0001) - ); - inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); - } - - inDash *= step(0.001, ink) * mask; - inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); - - vec4 glow = texture2D(tGlow, vUv); - float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); - float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); - float sharp = smoothstep(0.3, 0.5, inDash + halo); - vec3 color = dashColor * sharp; - - gl_FragColor = vec4(color, sharp); - - #include - #include - } -`; - -const GLB_URL = '/illustrations/common/footer/footer.glb'; - -const LIGHT_INTENSITY = 4; -const FILL_LIGHT_INTENSITY = 1.33; -const AMBIENT_LIGHT_INTENSITY = 0.28; -const LIGHT_ANGLE_DEGREES = 167; -const LIGHT_HEIGHT = 2.1; - -const MATERIAL_ROUGHNESS = 0.42; -const MATERIAL_METALNESS = 0.16; - -const HALFTONE_ROWS = 124; -const HALFTONE_CONTRAST = 2.7; -const HALFTONE_POWER = 1; -const HALFTONE_SHADING = 2.9; -const HALFTONE_BASE_INK = 0.19; -const HALFTONE_MAX_BAR = 0.25; -const HALFTONE_CELL_RATIO = 2.2; -const HALFTONE_CUTOFF = 0.02; -const HALFTONE_DASH_COLOR = '#2a2a2a'; - -const DRAG_SENSITIVITY = 0.008; -const DRAG_FRICTION = 0.08; -const DRAG_MOMENTUM = true; -const BREATHE_AMOUNT = 0.04; -const BREATHE_SPEED = 0.8; - -const INITIAL_ROTATION_X = -0.5858908325312259; -const INITIAL_ROTATION_Y = 0.45733435420243096; -const INITIAL_ROTATION_Z = 0; -const INITIAL_TARGET_ROTATION_X = -0.5858908325312265; -const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313; -const INITIAL_TIME_ELAPSED = 287.372499999977; -const BASE_CAMERA_DISTANCE = 4; - -function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - -type FooterInteractionState = { - dragging: boolean; - mouseX: number; - mouseY: number; - pointerX: number; - pointerY: number; - rotationX: number; - rotationY: number; - rotationZ: number; - targetRotationX: number; - targetRotationY: number; - velocityX: number; - velocityY: number; -}; - -function createInteractionState(): FooterInteractionState { - return { - dragging: false, - mouseX: 0.5, - mouseY: 0.5, - pointerX: 0, - pointerY: 0, - rotationX: INITIAL_ROTATION_X, - rotationY: INITIAL_ROTATION_Y, - rotationZ: INITIAL_ROTATION_Z, - targetRotationX: INITIAL_TARGET_ROTATION_X, - targetRotationY: INITIAL_TARGET_ROTATION_Y, - velocityX: 0, - velocityY: 0, - }; -} - -function createRenderTarget(width: number, height: number) { - return new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); -} - -function setPrimaryLightPosition( - light: THREE.DirectionalLight, - angleDegrees: number, - height: number, -) { - const lightAngle = (angleDegrees * Math.PI) / 180; - light.position.set( - Math.cos(lightAngle) * 5, - height, - Math.sin(lightAngle) * 5, - ); -} - -function createFooterMaterial(environmentTexture: THREE.Texture) { - return new THREE.MeshPhysicalMaterial({ - color: 0xd4d0c8, - roughness: MATERIAL_ROUGHNESS, - metalness: MATERIAL_METALNESS, - envMap: environmentTexture, - envMapIntensity: 0.25, - clearcoat: 0, - clearcoatRoughness: 0.08, - reflectivity: 0.5, - transmission: 0, - }); -} +import { useRef } from 'react'; +import { useFooterBackground } from './use-footer-background'; const FooterBackgroundCanvasRoot = styled.div` position: absolute; @@ -388,398 +29,7 @@ const FooterVisualCanvasMount = styled.div` export function FooterBackground() { const mountReference = useRef(null); - useEffect(() => { - const container = mountReference.current; - - if (!container) { - return; - } - - let cancelled = false; - let renderLoop: VisualRenderLoop | null = null; - - const getWidth = () => Math.max(container.clientWidth, 1); - const getHeight = () => Math.max(container.clientHeight, 1); - const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); - const getVirtualWidth = () => - Math.max( - Math.round( - getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), - ), - 1, - ); - - const renderer = tryCreateSiteWebGlRenderer({ - antialias: false, - alpha: true, - onContextLost: () => { - renderLoop?.stop(); - }, - }); - - if (renderer === null) { - return; - } - - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.setPixelRatio(1); - renderer.setClearColor(0x000000, 0); - renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); - - const canvas = renderer.domElement; - canvas.style.cursor = 'grab'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const environmentTexture = createEnvironmentTexture(renderer); - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = BASE_CAMERA_DISTANCE; - - const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); - setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight( - 0xffffff, - FILL_LIGHT_INTENSITY, - ); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight( - 0xffffff, - AMBIENT_LIGHT_INTENSITY, - ); - scene3d.add(ambientLight); - - const material = createFooterMaterial(environmentTexture); - const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); - mesh.visible = false; - scene3d.add(mesh); - - const sceneTarget = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const blurTargetA = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const blurTargetB = createRenderTarget( - getVirtualWidth(), - getVirtualHeight(), - ); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - resolution: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - numRows: { value: HALFTONE_ROWS }, - glowStr: { value: 0 }, - contrast: { value: HALFTONE_CONTRAST }, - power: { value: HALFTONE_POWER }, - shading: { value: HALFTONE_SHADING }, - baseInk: { value: HALFTONE_BASE_INK }, - maxBar: { value: HALFTONE_MAX_BAR }, - cellRatio: { value: HALFTONE_CELL_RATIO }, - cutoff: { value: HALFTONE_CUTOFF }, - dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, - time: { value: 0 }, - waveAmount: { value: 0 }, - waveSpeed: { value: 1 }, - distanceScale: { value: 1 }, - interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, - interactionVelocity: { value: new THREE.Vector2(0, 0) }, - dragOffset: { value: new THREE.Vector2(0, 0) }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - dragFlowRadius: { value: 0.24 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const interaction = createInteractionState(); - - const syncSize = () => { - const width = getWidth(); - const height = getHeight(); - const virtualWidth = getVirtualWidth(); - const virtualHeight = getVirtualHeight(); - - renderer.setSize(virtualWidth, virtualHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(virtualWidth, virtualHeight); - blurTargetA.setSize(virtualWidth, virtualHeight); - blurTargetB.setSize(virtualWidth, virtualHeight); - blurHorizontalMaterial.uniforms.res.value.set( - virtualWidth, - virtualHeight, - ); - blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight); - halftoneMaterial.uniforms.resolution.value.set( - virtualWidth, - virtualHeight, - ); - }; - - const stopObservingSize = observeElementSize(container, syncSize); - - const updatePointerPosition = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - interaction.mouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - interaction.mouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - }; - - const handlePointerDown = (event: PointerEvent) => { - updatePointerPosition(event); - interaction.dragging = false; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - interaction.velocityX = 0; - interaction.velocityY = 0; - canvas.style.cursor = 'default'; - }; - - const handlePointerMove = (event: PointerEvent) => { - updatePointerPosition(event); - }; - - const handleWindowPointerMove = (event: PointerEvent) => { - updatePointerPosition(event); - - if (!interaction.dragging) { - return; - } - - const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENSITIVITY; - const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENSITIVITY; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - if (interaction.dragging) { - return; - } - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - }; - - const handlePointerUp = () => { - interaction.dragging = false; - canvas.style.cursor = 'grab'; - }; - - const handleWindowBlur = () => { - handlePointerUp(); - handlePointerLeave(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointerdown', handlePointerDown); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointermove', handleWindowPointerMove); - window.addEventListener('blur', handleWindowBlur); - - loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', { - scaleTarget: 2, - }) - .then((geometry) => { - if (cancelled) { - geometry.dispose(); - return; - } - - mesh.geometry.dispose(); - mesh.geometry = geometry; - mesh.visible = true; - }) - .catch((error) => { - console.error(error); - }); - - const renderFrame = ( - _timestamp: DOMHighResTimeStamp, - { elapsedSeconds }: VisualRenderLoopFrame, - ) => { - if (cancelled) { - return; - } - - const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; - halftoneMaterial.uniforms.time.value = elapsedTime; - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.mouseX, - 1 - interaction.mouseY, - ); - - if (!interaction.dragging && DRAG_MOMENTUM) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - DRAG_FRICTION; - interaction.velocityY *= 1 - DRAG_FRICTION; - } - - interaction.rotationX += - (interaction.targetRotationX - interaction.rotationX) * DRAG_FRICTION; - interaction.rotationY += - (interaction.targetRotationY - interaction.rotationY) * DRAG_FRICTION; - interaction.rotationZ += (0 - interaction.rotationZ) * 0.12; - - const meshScale = - 1 + Math.sin(elapsedTime * BREATHE_SPEED) * BREATHE_AMOUNT; - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = 0; - mesh.scale.setScalar(meshScale); - - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; - camera.lookAt(0, 0, 0); - setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); - - renderer.setRenderTarget(sceneTarget); - renderer.clear(); - renderer.render(scene3d, camera); - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderLoop = createVisualRenderLoop({ - renderFrame, - shouldRender: () => !cancelled, - target: container, - targetVisibilityOptions: { rootMargin: '100px' }, - }); - renderLoop.start(); - - return () => { - cancelled = true; - renderLoop?.dispose(); - stopObservingSize(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerdown', handlePointerDown); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointermove', handleWindowPointerMove); - window.removeEventListener('blur', handleWindowBlur); - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - fullScreenGeometry.dispose(); - mesh.geometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - environmentTexture.dispose(); - renderer.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } - }; - }, []); + useFooterBackground({ mountRef: mountReference }); return ( diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts b/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts new file mode 100644 index 0000000000..544217f6bb --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts @@ -0,0 +1,762 @@ +'use client'; + +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { loadImportedGeometryFromUrl } from '@/lib/halftone'; +import { useEffect, type RefObject } from 'react'; +import * as THREE from 'three'; +import { observeElementSize } from '@/lib/dom/observe-element-size'; +import { + createVisualRenderLoop, + tryCreateSiteWebGlRenderer, + type VisualRenderLoop, + type VisualRenderLoopFrame, +} from '@/lib/visual-runtime'; + +const VIRTUAL_RENDER_HEIGHT = 768; + +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +const blurFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; + + void main() { + vec4 sum = vec4(0.0); + vec2 px = dir / res; + + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; + + sum += texture2D(tInput, vUv) * w[0]; + + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; + } + + gl_FragColor = sum; + } +`; + +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float rowMerge; + uniform float cellRatio; + uniform float cutoff; + uniform float highlightOpen; + uniform float shadowGrouping; + uniform float shadowCrush; + uniform vec3 dashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float distanceScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float dragFlowRadius; + uniform float cropToBounds; + + varying vec2 vUv; + + void main() { + // Crop to image bounds: discard fragments outside source image (image mode only) + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + float baseRowH = resolution.y / (numRows * distanceScale); + vec2 pointerPx = interactionUv * resolution; + vec2 fragDelta = gl_FragCoord.xy - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * resolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * resolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + float dragFlowMask = 0.0; + if (dragFlowStrength > 0.0) { + float dragRadiusPx = dragFlowRadius * resolution.y; + dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; + vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; + vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; + + float densityBoost = + hoverFlowStrength * hoverFlowMask * 0.22 + + dragFlowStrength * dragFlowMask * 0.16; + float rowH = baseRowH / (1.0 + densityBoost); + + float offsetY = effectCoord.y; + float row = floor(offsetY / rowH); + float rowFrac = offsetY / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; + + float localCellRatio = cellRatio * ( + 1.0 + + hoverFlowStrength * hoverFlowMask * 0.08 + + dragFlowStrength * dragFlowMask * 0.1 * motionBias + ); + float cellW = rowH * localCellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; + float lightFocus = hoverLightStrength * hoverLightMask * 0.12; + litLum = clamp(litLum + lightLift, 0.0, 1.0); + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, max(contrast - lightFocus, 0.25)); + + float darkness = 1.0 - litLum; + float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); + float groupedDarkness = 1.0 - groupedLum; + darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); + darkness = clamp( + (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), + 0.0, + 1.0 + ); + + float shadowMask = smoothstep(0.42, 0.96, darkness); + darkness = mix( + darkness, + mix(darkness, 1.0, shadowMask), + shadowCrush + ); + + float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); + float ink = mix(inkBase, 1.0, darkness); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dynamicBarHalfY = min( + dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), + 0.78 + ); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalfY - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float ellipseDist = sqrt( + (cdx * cdx) / max(capRX * capRX, 0.0001) + + (cdy * cdy) / max(capRY * capRY, 0.0001) + ); + inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +const GLB_URL = '/illustrations/common/footer/footer.glb'; + +const LIGHT_INTENSITY = 4; +const FILL_LIGHT_INTENSITY = 1.33; +const AMBIENT_LIGHT_INTENSITY = 0.28; +const LIGHT_ANGLE_DEGREES = 167; +const LIGHT_HEIGHT = 2.1; + +const MATERIAL_ROUGHNESS = 0.42; +const MATERIAL_METALNESS = 0.16; + +const HALFTONE_ROWS = 124; +const HALFTONE_CONTRAST = 2.7; +const HALFTONE_POWER = 1; +const HALFTONE_SHADING = 2.9; +const HALFTONE_BASE_INK = 0.19; +const HALFTONE_MAX_BAR = 0.25; +const HALFTONE_CELL_RATIO = 2.2; +const HALFTONE_CUTOFF = 0.02; +const HALFTONE_DASH_COLOR = '#2a2a2a'; + +const DRAG_SENSITIVITY = 0.008; +const DRAG_FRICTION = 0.08; +const DRAG_MOMENTUM = true; +const BREATHE_AMOUNT = 0.04; +const BREATHE_SPEED = 0.8; + +const INITIAL_ROTATION_X = -0.5858908325312259; +const INITIAL_ROTATION_Y = 0.45733435420243096; +const INITIAL_ROTATION_Z = 0; +const INITIAL_TARGET_ROTATION_X = -0.5858908325312265; +const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313; +const INITIAL_TIME_ELAPSED = 287.372499999977; +const BASE_CAMERA_DISTANCE = 4; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +type FooterInteractionState = { + dragging: boolean; + mouseX: number; + mouseY: number; + pointerX: number; + pointerY: number; + rotationX: number; + rotationY: number; + rotationZ: number; + targetRotationX: number; + targetRotationY: number; + velocityX: number; + velocityY: number; +}; + +function createInteractionState(): FooterInteractionState { + return { + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerX: 0, + pointerY: 0, + rotationX: INITIAL_ROTATION_X, + rotationY: INITIAL_ROTATION_Y, + rotationZ: INITIAL_ROTATION_Z, + targetRotationX: INITIAL_TARGET_ROTATION_X, + targetRotationY: INITIAL_TARGET_ROTATION_Y, + velocityX: 0, + velocityY: 0, + }; +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, +) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +function createFooterMaterial(environmentTexture: THREE.Texture) { + return new THREE.MeshPhysicalMaterial({ + color: 0xd4d0c8, + roughness: MATERIAL_ROUGHNESS, + metalness: MATERIAL_METALNESS, + envMap: environmentTexture, + envMapIntensity: 0.25, + clearcoat: 0, + clearcoatRoughness: 0.08, + reflectivity: 0.5, + transmission: 0, + }); +} + +export function useFooterBackground({ + mountRef, +}: { + mountRef: RefObject; +}) { + useEffect(() => { + const container = mountRef.current; + + if (!container) { + return; + } + + let cancelled = false; + let renderLoop: VisualRenderLoop | null = null; + + const getWidth = () => Math.max(container.clientWidth, 1); + const getHeight = () => Math.max(container.clientHeight, 1); + const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualWidth = () => + Math.max( + Math.round( + getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), + ), + 1, + ); + + const renderer = tryCreateSiteWebGlRenderer({ + antialias: false, + alpha: true, + onContextLost: () => { + renderLoop?.stop(); + }, + }); + + if (renderer === null) { + return; + } + + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + + const canvas = renderer.domElement; + canvas.style.cursor = 'grab'; + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.touchAction = 'none'; + canvas.style.width = '100%'; + container.appendChild(canvas); + + const environmentTexture = createEnvironmentTexture(renderer); + const scene3d = new THREE.Scene(); + scene3d.background = null; + + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, + ); + camera.position.z = BASE_CAMERA_DISTANCE; + + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + scene3d.add(primaryLight); + + const fillLight = new THREE.DirectionalLight( + 0xffffff, + FILL_LIGHT_INTENSITY, + ); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight( + 0xffffff, + AMBIENT_LIGHT_INTENSITY, + ); + scene3d.add(ambientLight); + + const material = createFooterMaterial(environmentTexture); + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); + mesh.visible = false; + scene3d.add(mesh); + + const sceneTarget = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetA = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetB = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + numRows: { value: HALFTONE_ROWS }, + glowStr: { value: 0 }, + contrast: { value: HALFTONE_CONTRAST }, + power: { value: HALFTONE_POWER }, + shading: { value: HALFTONE_SHADING }, + baseInk: { value: HALFTONE_BASE_INK }, + maxBar: { value: HALFTONE_MAX_BAR }, + cellRatio: { value: HALFTONE_CELL_RATIO }, + cutoff: { value: HALFTONE_CUTOFF }, + dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add( + new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), + ); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const interaction = createInteractionState(); + + const syncSize = () => { + const width = getWidth(); + const height = getHeight(); + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(virtualWidth, virtualHeight); + blurTargetA.setSize(virtualWidth, virtualHeight); + blurTargetB.setSize(virtualWidth, virtualHeight); + blurHorizontalMaterial.uniforms.res.value.set( + virtualWidth, + virtualHeight, + ); + blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight); + halftoneMaterial.uniforms.resolution.value.set( + virtualWidth, + virtualHeight, + ); + }; + + const stopObservingSize = observeElementSize(container, syncSize); + + const updatePointerPosition = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + interaction.mouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, + 0, + 1, + ); + interaction.mouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, + 0, + 1, + ); + }; + + const handlePointerDown = (event: PointerEvent) => { + updatePointerPosition(event); + interaction.dragging = false; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + interaction.velocityX = 0; + interaction.velocityY = 0; + canvas.style.cursor = 'default'; + }; + + const handlePointerMove = (event: PointerEvent) => { + updatePointerPosition(event); + }; + + const handleWindowPointerMove = (event: PointerEvent) => { + updatePointerPosition(event); + + if (!interaction.dragging) { + return; + } + + const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENSITIVITY; + const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENSITIVITY; + interaction.velocityX = deltaY; + interaction.velocityY = deltaX; + interaction.targetRotationY += deltaX; + interaction.targetRotationX += deltaY; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + }; + + const handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + }; + + const handlePointerUp = () => { + interaction.dragging = false; + canvas.style.cursor = 'grab'; + }; + + const handleWindowBlur = () => { + handlePointerUp(); + handlePointerLeave(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointermove', handleWindowPointerMove); + window.addEventListener('blur', handleWindowBlur); + + loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', { + scaleTarget: 2, + }) + .then((geometry) => { + if (cancelled) { + geometry.dispose(); + return; + } + + mesh.geometry.dispose(); + mesh.geometry = geometry; + mesh.visible = true; + }) + .catch((error) => { + console.error(error); + }); + + const renderFrame = ( + _timestamp: DOMHighResTimeStamp, + { elapsedSeconds }: VisualRenderLoopFrame, + ) => { + if (cancelled) { + return; + } + + const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds; + halftoneMaterial.uniforms.time.value = elapsedTime; + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.mouseX, + 1 - interaction.mouseY, + ); + + if (!interaction.dragging && DRAG_MOMENTUM) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= 1 - DRAG_FRICTION; + interaction.velocityY *= 1 - DRAG_FRICTION; + } + + interaction.rotationX += + (interaction.targetRotationX - interaction.rotationX) * DRAG_FRICTION; + interaction.rotationY += + (interaction.targetRotationY - interaction.rotationY) * DRAG_FRICTION; + interaction.rotationZ += (0 - interaction.rotationZ) * 0.12; + + const meshScale = + 1 + Math.sin(elapsedTime * BREATHE_SPEED) * BREATHE_AMOUNT; + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, + ); + mesh.position.y = 0; + mesh.scale.setScalar(meshScale); + + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; + camera.lookAt(0, 0, 0); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + + renderer.setRenderTarget(sceneTarget); + renderer.clear(); + renderer.render(scene3d, camera); + + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + }; + + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); + + return () => { + cancelled = true; + renderLoop?.dispose(); + stopObservingSize(); + canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointermove', handleWindowPointerMove); + window.removeEventListener('blur', handleWindowBlur); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + mesh.geometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); + renderer.dispose(); + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [mountRef]); +} diff --git a/packages/twenty-website-new/src/sections/Helped/Helped.tsx b/packages/twenty-website-new/src/sections/Helped/Helped.tsx new file mode 100644 index 0000000000..2461de6398 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Helped/Helped.tsx @@ -0,0 +1,41 @@ +import type { Scheme } from '@/theme'; +import { styled } from '@linaria/react'; +import type { ReactNode } from 'react'; +export type { HeadingCardType } from './types/heading-card-type'; +export type { HelpedVisualId } from './types/helped-visual-id'; +import { Scene } from './components/HelpedScene'; + +const StyledSection = styled.section` + width: 100%; + + &[data-scheme='light'] { + background-color: var(--color-white); + } + + &[data-scheme='muted'] { + background-color: var(--color-neutral); + } + + &[data-scheme='dark'] { + background-color: var(--color-black); + } +`; + +type RootProps = { + backgroundColor?: string; + children: ReactNode; + scheme?: Scheme; +}; + +function Root({ backgroundColor, children, scheme }: RootProps) { + return ( + + {children} + + ); +} + +export const Helped = { Root, Scene }; diff --git a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx b/packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx similarity index 55% rename from packages/twenty-website-new/src/sections/Helped/components/Card.tsx rename to packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx index 7221dbc23c..873cdbb4a8 100644 --- a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx +++ b/packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx @@ -1,14 +1,59 @@ -import { Body, Heading } from '@/design-system/components'; +import { Body, Heading, HeadingPart } from '@/design-system/components'; import { CLIENT_ICONS } from '@/icons'; -import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLinkButton'; +import { LocalizedLinkButton } from '@/lib/i18n/components/LocalizedLinkButton'; import { WebGlMount } from '@/lib/visual-runtime'; -import { HelpedCardShape } from '@/sections/Helped/components/HelpedCardShape'; -import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard'; -import { HELPED_VISUALS } from '@/sections/Helped/visuals'; import { theme } from '@/theme'; -import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; import { styled } from '@linaria/react'; +import type { HeadingCardType } from '../types/heading-card-type'; +import { HELPED_VISUALS } from '../utils/helped-visuals-map'; + +const HELPED_CARD_SHAPE_FILL_PATH = + 'M0 490V4a4 4 0 0 1 4-4h288.23c.932 0 1.856.163 2.731.48l60.814 22.09c.875.318 1.8.48 2.731.48H439a4 4 0 0 1 4 4V490a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4'; + +const HELPED_CARD_SHAPE_STROKE_PATH = + 'M4 .5h288.23c.874 0 1.74.152 2.561.45l60.813 22.09c.931.338 1.912.51 2.902.51H439a3.5 3.5 0 0 1 3.5 3.5V490a3.5 3.5 0 0 1-3.5 3.5H4A3.5 3.5 0 0 1 .5 490V4A3.5 3.5 0 0 1 4 .5Z'; + +function HelpedCardShape({ + fillColor, + strokeColor, +}: { + fillColor: string; + strokeColor: string; +}) { + return ( +
    + + + + +
    + ); +} const CardRoot = styled.article` display: grid; @@ -42,6 +87,7 @@ const Rule = styled.div` const VisualShell = styled.div` background-color: ${theme.colors.secondary.background[100]}; border-radius: ${theme.radius(2)}; + contain: strict; height: 240px; overflow: hidden; position: relative; @@ -59,8 +105,7 @@ const CardTitleWrap = styled.div` `; const CardBodyWrap = styled.div` - color: ${theme.colors.secondary.text[80]}; - --body-sm-color: currentColor; + --color-text-muted: ${theme.colors.secondary.text[80]}; `; const CtaRow = styled.div` @@ -74,10 +119,10 @@ const LOGO_FILL = theme.colors.secondary.text[100]; type CardProps = { card: HeadingCardType; - renderText: (descriptor: MessageDescriptor) => string; }; -export function Card({ card, renderText }: CardProps) { +export function HelpedCard({ card }: CardProps) { + const { i18n } = useLingui(); const IconComponent = CLIENT_ICONS[card.icon]; const Visual = HELPED_VISUALS[card.illustration]; const logoWidth = 104; @@ -102,23 +147,21 @@ export function Card({ card, renderText }: CardProps) {