diff --git a/packages/twenty-website-new/public/images/partner/hero/partners-hero.webp b/packages/twenty-website-new/public/images/partner/hero/partners-hero.webp new file mode 100644 index 0000000000..b6a63ba6c9 Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/hero/partners-hero.webp differ diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx index 532ef0f04d..96616f3316 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx @@ -1645,9 +1645,9 @@ export function HalftoneCanvas({ } if (!isImageMode) { - let baseRotationX = 0; - let baseRotationY = 0; - let baseRotationZ = 0; + let baseRotationX = initialPoseReference.current?.rotationX ?? 0; + let baseRotationY = initialPoseReference.current?.rotationY ?? 0; + let baseRotationZ = initialPoseReference.current?.rotationZ ?? 0; let meshOffsetY = 0; let meshScale = 1; let lightAngle = activeSettings.lighting.angleDegrees; diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts index 244214c380..26f1cbfb79 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts @@ -54,8 +54,6 @@ describe('halftone export naming', () => { expect( normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor, - ).toBe( - DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor, - ); + ).toBe(DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor); }); }); diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts index 8d0800d1c6..a18ec2fad9 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts @@ -2930,8 +2930,7 @@ export function generateReactComponent( modelFilenameOverride, ); const pose = normalizeExportPose(initialPose); - const normalizedComponentName = - normalizeExportComponentName(componentName); + const normalizedComponentName = normalizeExportComponentName(componentName); const defaultModelUrl = modelFilenameOverride ?? shape.filename ?? 'model.glb'; const defaultImageUrl = imageFilename ?? 'image.png'; @@ -3064,8 +3063,7 @@ export async function generateStandaloneHtml( modelFilenameOverride, ); const pose = normalizeExportPose(initialPose); - const normalizedComponentName = - normalizeExportComponentName(componentName); + const normalizedComponentName = normalizeExportComponentName(componentName); const defaultImageUrl = imageFilename ?? 'image.png'; const embeddedImportedModelUrl = !isImageMode && shape.kind === 'imported' && importedFile diff --git a/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts b/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts index e414ada9b5..7ea7f644e9 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts @@ -273,6 +273,26 @@ async function loadImportedGeometry( return parseGlbGeometry(buffer, '', label); } +export async function loadImportedGeometryFromUrl( + loader: HalftoneModelLoader, + modelUrl: string, + label: string, +) { + 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); + } + + return parseGlbGeometry(buffer, '', label); +} + function makePolarShape( radiusFunction: (angle: number) => number, segments = 320, diff --git a/packages/twenty-website-new/src/app/halftone/_lib/state.ts b/packages/twenty-website-new/src/app/halftone/_lib/state.ts index 6c37b00766..dbd4cfaf6c 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/state.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/state.ts @@ -405,9 +405,10 @@ export const LEGACY_HALFTONE_SETTING_KEYS = [ 'shadowCrush', ] as const; -export function isRoundedBandHalftoneSettings( - value: unknown, -): value is Omit & { +export function isRoundedBandHalftoneSettings(value: unknown): value is Omit< + HalftoneEffectSettings, + 'hoverDashColor' +> & { hoverDashColor?: string; } { if (!value || typeof value !== 'object') { diff --git a/packages/twenty-website-new/src/app/partner/page.tsx b/packages/twenty-website-new/src/app/partner/page.tsx index 73c58c6e6d..9278a1825f 100644 --- a/packages/twenty-website-new/src/app/partner/page.tsx +++ b/packages/twenty-website-new/src/app/partner/page.tsx @@ -111,15 +111,17 @@ export default async function PartnerPage() { - - + { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - sceneObject.geometry?.dispose(); - - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } - }); -} - -type MeshRestPose = { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - wobblePhase: number; + color: 'transparent', + }, + animation: { + autoRotateEnabled: false, + breatheEnabled: true, + cameraParallaxEnabled: false, + followHoverEnabled: true, + followDragEnabled: false, + floatEnabled: false, + hoverHalftoneEnabled: false, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.2, + autoWobble: 0.3, + breatheAmount: 0.02, + breatheSpeed: 0.2, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 8, + hoverEase: 0.02, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: '-y', + rotatePreset: 'axis', + rotateSpeed: 0.2, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.25, + lightSweepRange: 5, + lightSweepSpeed: 0.55, + springDamping: 0.72, + springReturnEnabled: true, + springStrength: 0.06, + hoverHalftonePowerShift: 0.42, + hoverHalftoneRadius: 0.2, + hoverHalftoneWidthShift: -0.18, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, }; -function applyScanlineMaterials( - modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, -) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } +const PARTNER_QUOTE_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 126.7357000006041, + rotateElapsed: 10.021299999999943, + rotationX: deg(-80), + rotationY: deg(10), + rotationZ: deg(350), + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 289.53700000066755, +}; - sceneObject.material = createScanlineMaterial(lightDirection); - - const mesh = sceneObject; - const rest: MeshRestPose = { - position: mesh.position.clone(), - quaternion: mesh.quaternion.clone(), - wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7, - }; - mesh.userData.partnerTestimonialsVisualRest = rest; - }); -} +const noopFirstInteraction = () => {}; +const noopPoseChange = (_pose: HalftoneExportPose) => {}; const VisualFrame = styled.div` background-color: transparent; @@ -139,214 +130,56 @@ const VisualFrame = styled.div` } `; -const CanvasMount = styled.div` +const VisualPlaceholder = styled.div` display: block; height: 100%; - inset: 0; min-width: 0; - position: absolute; width: 100%; `; export function Partner() { - const mountReference = useRef(null); - - useLayoutEffect(() => { - const container = mountReference.current; - if (!container) { - return; - } + const [geometry, setGeometry] = useState(null); + useEffect(() => { let cancelled = false; - let animationFrameId = 0; + let loadedGeometry: THREE.BufferGeometry | null = null; - const pointer = { x: 0, y: 0, inside: false }; - const targetRotation = { x: 0, y: 0 }; - const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize(); - - const scene = new THREE.Scene(); - const width = container.clientWidth; - const height = container.clientHeight; - - const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); - camera.position.set(0, 0, 5.05); - - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); - renderer.outputColorSpace = THREE.SRGBColorSpace; - const canvas = renderer.domElement; - canvas.style.cursor = 'pointer'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const pivot = new THREE.Group(); - scene.add(pivot); - - const clock = new THREE.Clock(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath( - 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/', - ); - - const loader = new GLTFLoader(); - loader.setDRACOLoader(dracoLoader); - - loader.load( - GLB_URL, - (gltf) => { + void loadImportedGeometryFromUrl('glb', GLB_URL, PARTNER_QUOTE_LABEL).then( + (nextGeometry) => { if (cancelled) { - disposeObjectSubtree(gltf.scene); + nextGeometry.dispose(); return; } - const modelRoot = gltf.scene; - const bounds = new THREE.Box3().setFromObject(modelRoot); - const center = bounds.getCenter(new THREE.Vector3()); - const size = bounds.getSize(new THREE.Vector3()); - const maxAxis = Math.max(size.x, size.y, size.z, 0.001); - const scale = 2.85 / maxAxis; - - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - - applyScanlineMaterials(modelRoot, lightDirectionWorld); - pivot.add(modelRoot); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - - const rotationDamp = 6.8; - const influence = pointer.inside ? 1 : 0.38; - targetRotation.y = pointer.x * 0.78 * influence; - targetRotation.x = pointer.y * 0.62 * influence; - - pivot.rotation.y = THREE.MathUtils.damp( - pivot.rotation.y, - targetRotation.y, - rotationDamp, - delta, - ); - pivot.rotation.x = THREE.MathUtils.damp( - pivot.rotation.x, - targetRotation.x, - rotationDamp, - delta, - ); - - const hoverLift = pointer.inside ? 1 : 0; - pivot.scale.setScalar( - THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta), - ); - - const mx = pointer.x * (pointer.inside ? 1 : 0.32); - const my = pointer.y * (pointer.inside ? 1 : 0.32); - - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const rest = sceneObject.userData.partnerTestimonialsVisualRest as - | MeshRestPose - | undefined; - if (!rest) { - return; - } - - const phase = rest.wobblePhase; - const wobble = pointer.inside ? 1 : 0.36; - sceneObject.position.x = - rest.position.x + mx * 0.22 * Math.sin(phase * 1.8); - sceneObject.position.z = - rest.position.z + my * 0.19 * Math.cos(phase * 1.4); - sceneObject.position.y = - rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5); - - const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase); - sceneObject.quaternion.copy(rest.quaternion); - sceneObject.rotateY(twist); - }); - - renderer.render(scene, camera); - }; - - renderFrame(); + loadedGeometry = nextGeometry; + setGeometry(nextGeometry); + }, + (error) => { + console.error(error); }, - undefined, - undefined, ); - const setPointerFromEvent = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1); - pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1); - pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1); - }; - - const handlePointerEnter = () => { - pointer.inside = true; - }; - - const handlePointerLeave = () => { - pointer.inside = false; - pointer.x = 0; - pointer.y = 0; - }; - - const handlePointerMove = (event: PointerEvent) => { - setPointerFromEvent(event); - }; - - canvas.addEventListener('pointerenter', handlePointerEnter); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointermove', handlePointerMove); - - const handleResize = () => { - if (!mountReference.current || cancelled) { - return; - } - - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); - }; - - window.addEventListener('resize', handleResize); - return () => { cancelled = true; - window.removeEventListener('resize', handleResize); - canvas.removeEventListener('pointerenter', handlePointerEnter); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointermove', handlePointerMove); - window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); - renderer.dispose(); - dracoLoader.dispose(); - - if (canvas.parentNode === container) { - container.removeChild(canvas); - } + loadedGeometry?.dispose(); }; }, []); return ( - + {geometry ? ( + + ) : ( + + )} ); } diff --git a/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx new file mode 100644 index 0000000000..e379cde88f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerHalftoneOverlay.tsx @@ -0,0 +1,762 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +const PREVIEW_DISTANCE = 3.2; +const SOURCE_PREVIEW_DISTANCE = 6.1; +const REFERENCE_PREVIEW_DISTANCE = 4; +const VIRTUAL_RENDER_HEIGHT = 768; + +const HALFTONE_TILE_SIZE = 10; +const HALFTONE_POWER = -0.1; +const HALFTONE_WIDTH = 0.45; +const HALFTONE_CONTRAST = 1; +const HALFTONE_DASH_COLOR = '#959595'; +const HALFTONE_HOVER_COLOR = '#4A38F5'; +const HALFTONE_HOVER_RADIUS = 0.6; +const HALFTONE_HOVER_POWER_SHIFT = 0.2; +const HALFTONE_HOVER_WIDTH_SHIFT = -0.18; +const HALFTONE_HOVER_LIGHT_INTENSITY = 0; +const HALFTONE_HOVER_LIGHT_RADIUS = 0.2; + +const IMAGE_POINTER_FOLLOW = 0.38; +const IMAGE_POINTER_VELOCITY_DAMPING = 0.82; +const MIN_FOOTPRINT_SCALE = 0.001; + +const passThroughVertexShader = ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +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 vec2 effectResolution; + uniform vec2 logicalResolution; + uniform float tile; + uniform float s_3; + uniform float s_4; + 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); + } + + 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 bandRadius = clamp( + ( + ( + sceneSample.r + + sceneSample.g + + sceneSample.b + + localPower * length(vec2(0.5)) + ) * + (1.0 / 3.0) + ) + 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 OverlayMount = styled.div` + inset: 0; + position: absolute; + z-index: 1; +`; + +type PointerState = { + mouseX: number; + mouseY: number; + pointerInside: boolean; + pointerVelocityX: number; + pointerVelocityY: number; + smoothedMouseX: number; + smoothedMouseY: number; +}; + +function clampRectToViewport( + rect: { height: number; width: number; x: number; y: number }, + viewportWidth: number, + viewportHeight: number, +) { + 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 { + height: maxY - minY, + width: maxX - minX, + x: minX, + y: minY, + }; +} + +function getRectArea(rect: { height: number; width: number } | null) { + if (!rect) { + return 0; + } + + return Math.max(rect.width, 0) * Math.max(rect.height, 0); +} + +function getImagePreviewZoom(previewDistance: number) { + return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); +} + +function getContainedImageRect({ + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom, +}: { + imageHeight: number; + imageWidth: number; + viewportHeight: number; + viewportWidth: number; + zoom: number; +}) { + 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) { + fittedHeight = viewportWidth / imageAspect; + } else { + fittedWidth = viewportHeight * imageAspect; + } + + const scaledWidth = fittedWidth * zoom; + const scaledHeight = fittedHeight * zoom; + + return clampRectToViewport( + { + height: scaledHeight, + width: scaledWidth, + x: (viewportWidth - scaledWidth) * 0.5, + y: (viewportHeight - scaledHeight) * 0.5, + }, + viewportWidth, + viewportHeight, + ); +} + +function getFootprintScaleFromRects( + currentRect: { height: number; width: number } | null, + referenceRect: { height: number; width: number } | null, +) { + 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({ + imageHeight, + imageWidth, + previewDistance, + viewportHeight, + viewportWidth, +}: { + imageHeight: number; + imageWidth: number; + previewDistance: number; + viewportHeight: number; + viewportWidth: number; +}) { + const currentRect = getContainedImageRect({ + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: getImagePreviewZoom(previewDistance), + }); + const referenceRect = getContainedImageRect({ + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: 1, + }); + + return getFootprintScaleFromRects(currentRect, referenceRect); +} + +function getRelativeImageScale({ + imageHeight, + imageWidth, + previewDistance, + referencePreviewDistance, + viewportHeight, + viewportWidth, +}: { + imageHeight: number; + imageWidth: number; + previewDistance: number; + referencePreviewDistance: number; + viewportHeight: number; + viewportWidth: number; +}) { + const currentRect = getContainedImageRect({ + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: getImagePreviewZoom(previewDistance), + }); + const referenceRect = getContainedImageRect({ + imageHeight, + imageWidth, + viewportHeight, + viewportWidth, + zoom: getImagePreviewZoom(referencePreviewDistance), + }); + + return getFootprintScaleFromRects(currentRect, referenceRect); +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + format: THREE.RGBAFormat, + magFilter: THREE.LinearFilter, + minFilter: THREE.LinearFilter, + }); +} + +function loadImage(imageUrl: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.decoding = 'async'; + image.onload = () => resolve(image); + image.onerror = () => + reject(new Error(`Failed to load hero image: ${imageUrl}`)); + image.src = imageUrl; + }); +} + +async function mountHalftoneOverlay({ + container, + imageUrl, +}: { + container: HTMLDivElement; + imageUrl: string; +}): Promise<() => void> { + const image = await loadImage(imageUrl); + + 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 = new THREE.WebGLRenderer({ + alpha: true, + antialias: false, + powerPreference: 'high-performance', + }); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + + const canvas = renderer.domElement; + canvas.setAttribute('aria-hidden', 'true'); + canvas.style.cursor = 'crosshair'; + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.width = '100%'; + container.appendChild(canvas); + + const imageTexture = new THREE.Texture(image); + imageTexture.colorSpace = THREE.SRGBColorSpace; + imageTexture.generateMipmaps = false; + imageTexture.magFilter = THREE.LinearFilter; + imageTexture.minFilter = THREE.LinearFilter; + imageTexture.needsUpdate = true; + + const sceneTarget = 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({ + fragmentShader: imagePassthroughFragmentShader, + uniforms: { + contrast: { value: HALFTONE_CONTRAST }, + imageSize: { value: new THREE.Vector2(image.width, image.height) }, + tImage: { value: imageTexture }, + viewportSize: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + zoom: { value: getImagePreviewZoom(PREVIEW_DISTANCE) }, + }, + vertexShader: passThroughVertexShader, + }); + + const imageScene = new THREE.Scene(); + imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); + + const halftoneMaterial = new THREE.ShaderMaterial({ + fragmentShader: halftoneFragmentShader, + transparent: true, + uniforms: { + cropToBounds: { value: 1 }, + dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, + dragFlowStrength: { value: 0 }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + effectResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + footprintScale: { value: 1 }, + hoverDashColor: { value: new THREE.Color(HALFTONE_HOVER_COLOR) }, + hoverFlowRadius: { value: 0.18 }, + hoverFlowStrength: { value: 0 }, + hoverHalftoneActive: { value: 0 }, + hoverHalftonePowerShift: { value: 0 }, + hoverHalftoneRadius: { value: HALFTONE_HOVER_RADIUS }, + hoverHalftoneWidthShift: { value: 0 }, + hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS }, + hoverLightStrength: { value: 0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + s_3: { value: HALFTONE_POWER }, + s_4: { value: HALFTONE_WIDTH }, + tScene: { value: sceneTarget.texture }, + tile: { value: HALFTONE_TILE_SIZE }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + }, + vertexShader: passThroughVertexShader, + }); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const updateViewportUniforms = ({ + effectHeight, + effectWidth, + logicalHeight, + logicalWidth, + }: { + effectHeight: number; + effectWidth: number; + logicalHeight: number; + logicalWidth: number; + }) => { + 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: PREVIEW_DISTANCE, + viewportHeight: getVirtualHeight(), + viewportWidth: getVirtualWidth(), + }); + + const getHoverScale = () => + getRelativeImageScale({ + imageHeight: image.height, + imageWidth: image.width, + previewDistance: PREVIEW_DISTANCE, + referencePreviewDistance: SOURCE_PREVIEW_DISTANCE, + viewportHeight: getVirtualHeight(), + viewportWidth: getVirtualWidth(), + }); + + const pointer: PointerState = { + mouseX: 0.5, + mouseY: 0.5, + pointerInside: false, + pointerVelocityX: 0, + pointerVelocityY: 0, + smoothedMouseX: 0.5, + smoothedMouseY: 0.5, + }; + + const syncSize = () => { + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + sceneTarget.setSize(virtualWidth, virtualHeight); + updateViewportUniforms({ + effectHeight: virtualHeight, + effectWidth: virtualWidth, + logicalHeight: virtualHeight, + logicalWidth: virtualWidth, + }); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + const updatePointerPosition = ( + event: PointerEvent, + options?: { resetVelocity?: boolean }, + ) => { + 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 - pointer.mouseX; + const deltaY = nextMouseY - pointer.mouseY; + + pointer.mouseX = nextMouseX; + pointer.mouseY = nextMouseY; + pointer.pointerInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (options?.resetVelocity) { + pointer.pointerVelocityX = 0; + pointer.pointerVelocityY = 0; + pointer.smoothedMouseX = nextMouseX; + pointer.smoothedMouseY = nextMouseY; + return; + } + + pointer.pointerVelocityX = deltaX; + pointer.pointerVelocityY = deltaY; + }; + + const handlePointerMove = (event: PointerEvent) => { + const resetVelocity = !pointer.pointerInside; + updatePointerPosition( + event, + resetVelocity ? { resetVelocity: true } : undefined, + ); + }; + + const handlePointerLeave = () => { + pointer.pointerInside = false; + pointer.pointerVelocityX = 0; + pointer.pointerVelocityY = 0; + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + + let animationFrameId = 0; + + const renderFrame = (timestamp: number) => { + animationFrameId = window.requestAnimationFrame(renderFrame); + halftoneMaterial.uniforms.time.value = timestamp / 1000; + const hoverScale = getHoverScale(); + + pointer.smoothedMouseX += + (pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW; + pointer.smoothedMouseY += + (pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW; + pointer.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING; + pointer.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING; + + halftoneMaterial.uniforms.interactionUv.value.set( + pointer.smoothedMouseX, + 1 - pointer.smoothedMouseY, + ); + halftoneMaterial.uniforms.interactionVelocity.value.set( + pointer.pointerVelocityX * getVirtualWidth(), + -pointer.pointerVelocityY * getVirtualHeight(), + ); + halftoneMaterial.uniforms.hoverHalftoneActive.value = pointer.pointerInside + ? 1 + : 0; + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = + pointer.pointerInside ? HALFTONE_HOVER_POWER_SHIFT : 0; + halftoneMaterial.uniforms.hoverHalftoneRadius.value = + HALFTONE_HOVER_RADIUS * hoverScale; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = + pointer.pointerInside ? HALFTONE_HOVER_WIDTH_SHIFT : 0; + halftoneMaterial.uniforms.hoverLightStrength.value = pointer.pointerInside + ? HALFTONE_HOVER_LIGHT_INTENSITY + : 0; + halftoneMaterial.uniforms.hoverLightRadius.value = + HALFTONE_HOVER_LIGHT_RADIUS * hoverScale; + halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); + imageMaterial.uniforms.zoom.value = getImagePreviewZoom(PREVIEW_DISTANCE); + + renderer.setRenderTarget(sceneTarget); + renderer.render(imageScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + }; + + renderFrame(0); + + return () => { + window.cancelAnimationFrame(animationFrameId); + resizeObserver.disconnect(); + canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + halftoneMaterial.dispose(); + imageMaterial.dispose(); + imageTexture.dispose(); + fullScreenGeometry.dispose(); + sceneTarget.dispose(); + renderer.dispose(); + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; +} + +type PartnerHalftoneOverlayProps = { + imageUrl: string; +}; + +export function PartnerHalftoneOverlay({ + imageUrl, +}: PartnerHalftoneOverlayProps) { + const mountRef = useRef(null); + + useEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + const container = mountRef.current; + + if (!container) { + return; + } + + let disposed = false; + const disposePromise = mountHalftoneOverlay({ container, imageUrl }).catch( + (error) => { + console.error(error); + return undefined; + }, + ); + + return () => { + disposed = true; + + void disposePromise.then((dispose) => { + if (!disposed) { + return; + } + + dispose?.(); + }); + }; + }, [imageUrl]); + + return ; +} diff --git a/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerVisual.tsx b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerVisual.tsx index 55e3160187..164664bc98 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerVisual.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/PartnerVisual/PartnerVisual.tsx @@ -1,33 +1,28 @@ import { theme } from '@/theme'; -import { css } from '@linaria/core'; import { styled } from '@linaria/react'; -import NextImage from 'next/image'; + +import { PartnerHalftoneOverlay } from './PartnerHalftoneOverlay'; + +const HERO_IMAGE_URL = '/images/partner/hero/partners-hero.webp'; const StyledContainer = styled.div` background-color: ${theme.colors.secondary.background[100]}; + border-radius: ${theme.radius(1)}; height: 462px; margin-top: ${theme.spacing(6)}; overflow: hidden; position: relative; width: 100%; -`; -const partnerHeroImageClassName = css` - object-fit: cover; - object-position: center; + @media (max-width: ${theme.breakpoints.md - 1}px) { + height: 360px; + } `; export function PartnerVisual() { return ( - + ); } diff --git a/packages/twenty-website-new/src/sections/Testimonials/TestimonialsShape.tsx b/packages/twenty-website-new/src/sections/Testimonials/TestimonialsShape.tsx index 3386ea152b..6f9dd71911 100644 --- a/packages/twenty-website-new/src/sections/Testimonials/TestimonialsShape.tsx +++ b/packages/twenty-website-new/src/sections/Testimonials/TestimonialsShape.tsx @@ -1,11 +1,15 @@ -const TESTIMONIALS_SHAPE_PATH = - 'M0 4a4 4 0 0 1 4-4h344.32c4.197 0 8.369.66 12.361 1.958l49.5 16.084A40 40 0 0 0 422.542 20h517.7c4.293 0 8.559-.691 12.633-2.047l47.785-15.906A40 40 0 0 1 1013.29 0H1356a4 4 0 0 1 4 4v16H0z'; +const TESTIMONIALS_SHAPE_CLIP_PATH = + 'polygon(0% 20%, 0.294% 0%, 25.318% 0%, 31.069% 100%, 69.135% 100%, 74.507% 0%, 99.706% 0%, 100% 20%, 100% 100%, 0% 100%)'; interface TestimonialsShapeProps { + bodyFillColor?: string; fillColor: string; } -export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) { +export function TestimonialsShape({ + bodyFillColor, + fillColor, +}: TestimonialsShapeProps) { return (
- - - +
0; + const hasNext = index < total - 1; + const current = testimonials[index]; + + const goToPrevious = () => { + if (hasPrevious) setIndex(index - 1); + }; + + const goToNext = () => { + if (hasNext) setIndex(index + 1); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + {index + 1}/{total} + + + + + + + + + + + {testimonials.map((testimonial, testimonialIndex) => ( + + + + ))} + + + {children ? ( + + {children} + + ) : null} + + + + + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/sections/Testimonials/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/Testimonials/components/Root/Root.tsx index e0e2e2678d..a21edd5331 100644 --- a/packages/twenty-website-new/src/sections/Testimonials/components/Root/Root.tsx +++ b/packages/twenty-website-new/src/sections/Testimonials/components/Root/Root.tsx @@ -29,6 +29,7 @@ type RootProps = { backgroundColor: string; children: ReactNode; color: string; + shapeBodyFillColor?: string; shapeFillColor?: string; }; @@ -36,11 +37,17 @@ export function Root({ backgroundColor, children, color, + shapeBodyFillColor, shapeFillColor, }: RootProps) { return ( - {shapeFillColor && } + {shapeFillColor && ( + + )} {children} ); diff --git a/packages/twenty-website-new/src/sections/Testimonials/components/Separator/Separator.tsx b/packages/twenty-website-new/src/sections/Testimonials/components/Separator/Separator.tsx index 0fd92f3f1f..b040e3b9d6 100644 --- a/packages/twenty-website-new/src/sections/Testimonials/components/Separator/Separator.tsx +++ b/packages/twenty-website-new/src/sections/Testimonials/components/Separator/Separator.tsx @@ -18,8 +18,8 @@ const StyledSeparator = styled.div` } `; -const SeparatorLine = styled.div` - background-color: ${theme.colors.primary.border[20]}; +const SeparatorLine = styled.div<{ color: string }>` + background-color: ${(props) => props.color}; height: 1px; min-width: 0; width: 100%; @@ -47,13 +47,22 @@ const SeparatorIcon = styled.div` } `; -export function Separator() { +type SeparatorProps = { + colorScheme?: 'primary' | 'secondary'; +}; + +export function Separator({ colorScheme = 'primary' }: SeparatorProps) { + const lineColor = + colorScheme === 'secondary' + ? theme.colors.secondary.border[20] + : theme.colors.primary.border[20]; + return ( - + diff --git a/packages/twenty-website-new/src/sections/Testimonials/components/index.ts b/packages/twenty-website-new/src/sections/Testimonials/components/index.ts index c7984d2f32..5844256b1a 100644 --- a/packages/twenty-website-new/src/sections/Testimonials/components/index.ts +++ b/packages/twenty-website-new/src/sections/Testimonials/components/index.ts @@ -1,12 +1,14 @@ import { Hourglass } from '@/illustrations/Testimonials/Hourglass'; import { Partner as PartnerVisual } from '@/illustrations/Testimonials/Partner'; import { Carousel } from './Carousel/Carousel'; +import { PartnerCarousel } from './PartnerCarousel/PartnerCarousel'; import { Root } from './Root/Root'; import { Separator } from './Separator/Separator'; export const Testimonials = { Carousel, HomeVisual: Hourglass, + PartnerCarousel, PartnerVisual, Root, Separator,