From 0ed36b3c32c931aa9c3e230fd6206cb9a6c69d33 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 7 Sep 2026 22:27:44 +0330 Subject: [PATCH] feat(footer): add isometric Three.js contact scene --- src/components/footerContacts.ts | 40 ++ src/components/scenes/FooterScene.tsx | 600 ++++++++++++++++++++++++ src/components/scenes/Scenes.module.css | 20 +- tests/footer-scene.test.mjs | 15 + 4 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 src/components/footerContacts.ts create mode 100644 src/components/scenes/FooterScene.tsx create mode 100644 tests/footer-scene.test.mjs diff --git a/src/components/footerContacts.ts b/src/components/footerContacts.ts new file mode 100644 index 0000000..b78adef --- /dev/null +++ b/src/components/footerContacts.ts @@ -0,0 +1,40 @@ +export type FooterContactId = 'phone' | 'linkedin' | 'gitea' | 'telegram'; + +export interface FooterContact { + id: FooterContactId; + value: string; + href: string; + ariaLabel: string; + external: boolean; +} + +export const footerContacts: readonly FooterContact[] = [ + { + id: 'phone', + value: '09388263077', + href: 'tel:+989388263077', + ariaLabel: 'Call 09388263077', + external: false, + }, + { + id: 'linkedin', + value: '@amiirkhl', + href: 'https://linkedin.com/in/amiirkhl', + ariaLabel: 'LinkedIn profile', + external: true, + }, + { + id: 'gitea', + value: 'git.amiirkhl.ir', + href: 'https://git.amiirkhl.ir', + ariaLabel: 'Personal Git server', + external: true, + }, + { + id: 'telegram', + value: '@amiirkhl', + href: 'https://t.me/amiirkhl', + ariaLabel: 'Telegram profile', + external: true, + }, +] as const; diff --git a/src/components/scenes/FooterScene.tsx b/src/components/scenes/FooterScene.tsx new file mode 100644 index 0000000..85f59e1 --- /dev/null +++ b/src/components/scenes/FooterScene.tsx @@ -0,0 +1,600 @@ +'use client'; + +import * as THREE from 'three'; +import { useTheme } from '@/context/ThemeContext'; +import { + footerContacts, + type FooterContact, + type FooterContactId, +} from '../footerContacts'; +import { ThreeCanvas, type SceneFactory } from './ThreeCanvas'; +import { + addSoftLights, + createMesa, + createTree, + fitOrthographicCamera, + palettes, + type ScenePalette, +} from './scenePrimitives'; +import styles from './Scenes.module.css'; + +const DESKTOP_MARKERS: Record = { + phone: [-7.2, 0.4], + linkedin: [-3.8, -0.45], + gitea: [3.8, -0.45], + telegram: [7.2, 0.4], +}; + +const MOBILE_MARKERS: Record = { + phone: [-4.25, 0.15], + linkedin: [-1.55, -0.85], + gitea: [1.55, -0.85], + telegram: [4.25, 0.15], +}; + +const colorToCss = (color: number) => `#${color.toString(16).padStart(6, '0')}`; + +function roundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + context.beginPath(); + context.moveTo(x + radius, y); + context.lineTo(x + width - radius, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius); + context.lineTo(x + width, y + height - radius); + context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + context.lineTo(x + radius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius); + context.lineTo(x, y + radius); + context.quadraticCurveTo(x, y, x + radius, y); + context.closePath(); +} + +function drawMarkerIcon( + context: CanvasRenderingContext2D, + id: FooterContactId, + color: string, +) { + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineCap = 'round'; + context.lineJoin = 'round'; + + if (id === 'linkedin') { + context.font = '700 74px Arial, sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('in', 96, 103); + } else if (id === 'telegram') { + context.beginPath(); + context.moveTo(48, 93); + context.lineTo(145, 50); + context.lineTo(124, 143); + context.lineTo(91, 112); + context.lineTo(72, 132); + context.lineTo(76, 103); + context.closePath(); + context.fill(); + context.strokeStyle = '#f8faff'; + context.lineWidth = 5; + context.beginPath(); + context.moveTo(78, 103); + context.lineTo(124, 72); + context.stroke(); + } else if (id === 'gitea') { + context.lineWidth = 10; + context.beginPath(); + context.arc(96, 94, 39, Math.PI * 0.1, Math.PI * 1.9); + context.stroke(); + context.beginPath(); + context.moveTo(75, 94); + context.lineTo(96, 73); + context.lineTo(119, 98); + context.stroke(); + [[75, 94], [96, 73], [119, 98]].forEach(([nodeX, nodeY]) => { + context.beginPath(); + context.arc(nodeX, nodeY, 8, 0, Math.PI * 2); + context.fill(); + }); + } else { + context.lineWidth = 16; + context.beginPath(); + context.moveTo(64, 57); + context.bezierCurveTo(55, 76, 67, 111, 89, 128); + context.bezierCurveTo(105, 140, 128, 143, 140, 128); + context.stroke(); + context.lineWidth = 20; + context.beginPath(); + context.moveTo(63, 58); + context.lineTo(77, 78); + context.moveTo(123, 115); + context.lineTo(141, 128); + context.stroke(); + } + context.restore(); +} + +function createMarkerTexture(contact: FooterContact, palette: ScenePalette) { + const canvas = document.createElement('canvas'); + canvas.width = 192; + canvas.height = 192; + const context = canvas.getContext('2d'); + if (!context) return new THREE.CanvasTexture(canvas); + + roundedRect(context, 14, 14, 164, 164, 28); + context.fillStyle = colorToCss(palette.ground); + context.fill(); + context.lineWidth = 6; + context.strokeStyle = colorToCss(palette.accent); + context.stroke(); + drawMarkerIcon(context, contact.id, colorToCss(palette.edge)); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + return texture; +} + +function createCelestialTexture(theme: 'light' | 'dark') { + const canvas = document.createElement('canvas'); + canvas.width = 192; + canvas.height = 192; + const context = canvas.getContext('2d'); + if (!context) return new THREE.CanvasTexture(canvas); + + context.translate(96, 96); + context.fillStyle = theme === 'light' ? '#ffe66d' : '#fffdf0'; + if (theme === 'light') { + for (let index = 0; index < 12; index += 1) { + context.save(); + context.rotate((index / 12) * Math.PI * 2); + context.fillRect(-4, -78, 8, 24); + context.restore(); + } + context.beginPath(); + context.arc(0, 0, 41, 0, Math.PI * 2); + context.fill(); + } else { + context.beginPath(); + context.arc(0, 0, 51, 0, Math.PI * 2); + context.fill(); + context.globalCompositeOperation = 'destination-out'; + context.beginPath(); + context.arc(25, -17, 51, 0, Math.PI * 2); + context.fill(); + } + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +const createFooterScene: SceneFactory = ({ renderer, pointer, theme, reduceMotion }) => { + const palette = palettes[theme]; + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-16, 16, 8, -8, 0.1, 80); + const cameraBase = new THREE.Vector3(15, 15, 18); + camera.position.copy(cameraBase); + camera.lookAt(0, 0.5, 0); + addSoftLights(scene, theme, 22); + + const world = new THREE.Group(); + world.position.y = -1.55; + scene.add(world); + + const edgeMaterial = new THREE.LineBasicMaterial({ + color: palette.edge, + transparent: true, + opacity: theme === 'dark' ? 0.58 : 0.5, + }); + const groundMaterial = new THREE.MeshStandardMaterial({ + color: palette.ground, + roughness: 0.95, + }); + const softGroundMaterial = new THREE.MeshStandardMaterial({ + color: theme === 'dark' ? 0x061943 : 0xffffff, + roughness: 0.95, + }); + const accentMaterial = new THREE.MeshStandardMaterial({ + color: palette.accent, + emissive: palette.accent, + emissiveIntensity: theme === 'dark' ? 0.24 : 0.08, + roughness: 0.72, + }); + + const gridVertices: number[] = []; + for (let value = -24; value <= 24; value += 1) { + gridVertices.push(-24, -0.34, value, 24, -0.34, value); + gridVertices.push(value, -0.34, -24, value, -0.34, 24); + } + const gridGeometry = new THREE.BufferGeometry(); + gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3)); + const grid = new THREE.LineSegments( + gridGeometry, + new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + uniforms: { + gridColor: { value: new THREE.Color(palette.edge) }, + gridOpacity: { value: theme === 'dark' ? 0.2 : 0.17 }, + }, + vertexShader: ` + varying vec3 gridPosition; + void main() { + gridPosition = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform vec3 gridColor; + uniform float gridOpacity; + varying vec3 gridPosition; + void main() { + float distanceFromCenter = length(gridPosition.xz); + float fade = 1.0 - smoothstep(7.0, 24.0, distanceFromCenter); + gl_FragColor = vec4(gridColor, gridOpacity * fade); + } + `, + }), + ); + world.add(grid); + + const shadowGround = new THREE.Mesh( + new THREE.PlaneGeometry(48, 30), + new THREE.ShadowMaterial({ + color: 0x000000, + opacity: theme === 'dark' ? 0.22 : 0.07, + transparent: true, + }), + ); + shadowGround.rotation.x = -Math.PI / 2; + shadowGround.position.y = -0.36; + shadowGround.receiveShadow = true; + world.add(shadowGround); + + const addPlate = ( + parent: THREE.Group, + x: number, + z: number, + width: number, + depth: number, + height = 0.42, + material: THREE.Material = groundMaterial, + ) => { + const geometry = new THREE.BoxGeometry(width, height, depth); + const plate = new THREE.Mesh(geometry, material); + plate.position.set(x, height / 2 - 0.28, z); + plate.castShadow = true; + plate.receiveShadow = true; + plate.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 24), edgeMaterial)); + parent.add(plate); + return plate; + }; + + const plates = new THREE.Group(); + world.add(plates); + [ + [-10.2, 1.2, 7.6, 6.2], + [-5.2, 0.5, 6.5, 5.6], + [0, 0.85, 7.2, 5.8], + [5.2, 0.5, 6.5, 5.6], + [10.2, 1.2, 7.6, 6.2], + ].forEach(([x, z, width, depth]) => addPlate(plates, x, z, width, depth)); + addPlate(plates, 0, 0.75, 5.2, 4.1, 0.68, softGroundMaterial); + addPlate(plates, 0, 0.75, 4.25, 3.2, 0.96, groundMaterial); + + const outerLandscape = new THREE.Group(); + world.add(outerLandscape); + [ + [-13.2, 2.2, 3.7, 4.8, 3.6], + [-10.7, 3, 2.7, 3.4, 2.4], + [-14.6, -0.6, 2.8, 3.2, 2.2], + [13.2, 2.2, 3.7, 4.8, 3.6], + [10.7, 3, 2.7, 3.4, 2.4], + [14.6, -0.6, 2.8, 3.2, 2.2], + ].forEach(([x, z, width, height, depth]) => { + const mesa = createMesa(width, height, depth, palette); + mesa.position.set(x, 0, z); + outerLandscape.add(mesa); + }); + + const trees = new THREE.Group(); + const secondaryTrees = new THREE.Group(); + world.add(trees, secondaryTrees); + [ + [-11.3, -1.2, 0.72], [-9.1, 2.8, 0.62], [-7.9, -1.5, 0.6], + [11.3, -1.2, 0.72], [9.1, 2.8, 0.62], [7.9, -1.5, 0.6], + ].forEach(([x, z, scale]) => { + const tree = createTree(palette, scale); + tree.position.set(x, 0.02, z); + trees.add(tree); + }); + [ + [-13.8, 4.4, 0.55], [-6.5, 2.9, 0.5], + [13.8, 4.4, 0.55], [6.5, 2.9, 0.5], + ].forEach(([x, z, scale]) => { + const tree = createTree(palette, scale); + tree.position.set(x, 0.02, z); + secondaryTrees.add(tree); + }); + + const workstation = new THREE.Group(); + workstation.position.set(0, 0.42, 0.75); + world.add(workstation); + + const monitor = new THREE.Group(); + monitor.rotation.y = Math.PI / 4; + monitor.position.set(-0.65, 0.75, 0.1); + const screenGeometry = new THREE.BoxGeometry(2.05, 1.25, 0.12); + const screen = new THREE.Mesh(screenGeometry, accentMaterial); + screen.castShadow = true; + screen.add(new THREE.LineSegments(new THREE.EdgesGeometry(screenGeometry), edgeMaterial)); + screen.position.y = 1.04; + const monitorStand = new THREE.Mesh( + new THREE.CylinderGeometry(0.1, 0.14, 0.8, 8), + new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.84 }), + ); + monitorStand.position.y = 0.4; + const monitorFoot = new THREE.Mesh( + new THREE.BoxGeometry(0.95, 0.1, 0.55), + new THREE.MeshStandardMaterial({ color: palette.mountainLight, roughness: 0.86 }), + ); + monitor.add(screen, monitorStand, monitorFoot); + workstation.add(monitor); + + const pencil = new THREE.Group(); + const pencilBody = new THREE.Mesh( + new THREE.CylinderGeometry(0.16, 0.16, 2.6, 6), + new THREE.MeshStandardMaterial({ + color: palette.mountain, + flatShading: true, + roughness: 0.86, + }), + ); + pencilBody.castShadow = true; + const pencilTip = new THREE.Mesh( + new THREE.ConeGeometry(0.17, 0.48, 6), + new THREE.MeshStandardMaterial({ color: palette.snow, flatShading: true, roughness: 0.92 }), + ); + pencilTip.position.y = 1.54; + pencil.add(pencilBody, pencilTip); + pencil.position.set(0.65, 1.3, 0.15); + workstation.add(pencil); + + const productGeometry = new THREE.BoxGeometry(1.15, 1.15, 1.15); + const product = new THREE.Mesh( + productGeometry, + new THREE.MeshStandardMaterial({ color: palette.mountain, roughness: 0.86 }), + ); + product.position.set(1.65, 0.58, 0.25); + product.castShadow = true; + product.add(new THREE.LineSegments(new THREE.EdgesGeometry(productGeometry), edgeMaterial)); + workstation.add(product); + + const connectorMaterial = new THREE.MeshBasicMaterial({ color: palette.accent }); + const desktopConnectors = new THREE.Group(); + const mobileConnectors = new THREE.Group(); + world.add(desktopConnectors, mobileConnectors); + + const addConnectorSegment = ( + parent: THREE.Group, + from: THREE.Vector3, + to: THREE.Vector3, + ) => { + const direction = to.clone().sub(from); + const geometry = new THREE.CylinderGeometry(0.045, 0.045, direction.length(), 6); + const segment = new THREE.Mesh(geometry, connectorMaterial); + segment.position.copy(from).add(to).multiplyScalar(0.5); + segment.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize()); + parent.add(segment); + }; + + const addConnectors = ( + parent: THREE.Group, + positions: Record, + ) => { + footerContacts.forEach(({ id }) => { + const [x, z] = positions[id]; + const elbowX = x * 0.52; + addConnectorSegment( + parent, + new THREE.Vector3(Math.sign(x) * 2.1, 0.28, 0.72), + new THREE.Vector3(elbowX, 0.28, z - 0.85), + ); + addConnectorSegment( + parent, + new THREE.Vector3(elbowX, 0.28, z - 0.85), + new THREE.Vector3(x, 0.28, z), + ); + }); + }; + addConnectors(desktopConnectors, DESKTOP_MARKERS); + addConnectors(mobileConnectors, MOBILE_MARKERS); + mobileConnectors.visible = false; + + const markerGroups = new Map(); + const markerTargets: THREE.Sprite[] = []; + footerContacts.forEach((contact) => { + const group = new THREE.Group(); + const [x, z] = DESKTOP_MARKERS[contact.id]; + group.position.set(x, 0.18, z); + group.userData.baseY = group.position.y; + group.userData.hovered = false; + + const pole = new THREE.Mesh( + new THREE.CylinderGeometry(0.065, 0.085, 1.55, 8), + new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.86 }), + ); + pole.position.y = 0.77; + pole.castShadow = true; + const foot = new THREE.Mesh( + new THREE.CylinderGeometry(0.32, 0.4, 0.12, 16), + new THREE.MeshStandardMaterial({ color: palette.accentSoft, roughness: 0.8 }), + ); + foot.position.y = 0.06; + const marker = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: createMarkerTexture(contact, palette), + transparent: true, + depthWrite: false, + }), + ); + marker.position.y = 1.82; + marker.scale.setScalar(1.55); + marker.userData.contactId = contact.id; + group.add(pole, foot, marker); + world.add(group); + markerGroups.set(contact.id, group); + markerTargets.push(marker); + }); + + const celestial = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: createCelestialTexture(theme), + transparent: true, + depthWrite: false, + }), + ); + celestial.position.set(9.8, 7.2, 0); + celestial.scale.setScalar(2.3); + scene.add(celestial); + + const raycaster = new THREE.Raycaster(); + const hitPointer = new THREE.Vector2(); + let hoveredMarker: THREE.Sprite | null = null; + let pointerDown: { id: FooterContactId; x: number; y: number } | null = null; + + const getMarkerAt = (event: PointerEvent) => { + const bounds = renderer.domElement.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return null; + hitPointer.set( + ((event.clientX - bounds.left) / bounds.width) * 2 - 1, + -(((event.clientY - bounds.top) / bounds.height) * 2 - 1), + ); + raycaster.setFromCamera(hitPointer, camera); + return (raycaster.intersectObjects(markerTargets, false)[0]?.object as THREE.Sprite | undefined) ?? null; + }; + + const renderNow = () => renderer.render(scene, camera); + const setHoveredMarker = (next: THREE.Sprite | null) => { + if (hoveredMarker === next) return; + if (hoveredMarker) { + const previousId = hoveredMarker.userData.contactId as FooterContactId; + const previousGroup = markerGroups.get(previousId); + if (previousGroup) previousGroup.userData.hovered = false; + } + hoveredMarker = next; + if (hoveredMarker) { + const nextId = hoveredMarker.userData.contactId as FooterContactId; + const nextGroup = markerGroups.get(nextId); + if (nextGroup) nextGroup.userData.hovered = true; + } + renderer.domElement.style.cursor = hoveredMarker ? 'pointer' : ''; + if (reduceMotion) renderNow(); + }; + + const onPointerMove = (event: PointerEvent) => setHoveredMarker(getMarkerAt(event)); + const onPointerLeave = () => { + pointerDown = null; + setHoveredMarker(null); + }; + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + const marker = getMarkerAt(event); + if (!marker) return; + pointerDown = { + id: marker.userData.contactId as FooterContactId, + x: event.clientX, + y: event.clientY, + }; + }; + const onPointerUp = (event: PointerEvent) => { + const marker = getMarkerAt(event); + const activePointer = pointerDown; + pointerDown = null; + if (!marker || !activePointer) return; + const markerId = marker.userData.contactId as FooterContactId; + const movement = Math.hypot(event.clientX - activePointer.x, event.clientY - activePointer.y); + if (markerId !== activePointer.id || movement > 8) return; + const contact = footerContacts.find((item) => item.id === markerId); + if (!contact) return; + if (contact.external) { + window.open(contact.href, '_blank', 'noopener,noreferrer'); + } else { + window.location.href = contact.href; + } + }; + const onPointerCancel = () => { + pointerDown = null; + }; + + const canvas = renderer.domElement; + canvas.addEventListener('pointermove', onPointerMove, { passive: true }); + canvas.addEventListener('pointerleave', onPointerLeave); + canvas.addEventListener('pointerdown', onPointerDown, { passive: true }); + canvas.addEventListener('pointerup', onPointerUp, { passive: true }); + canvas.addEventListener('pointercancel', onPointerCancel); + + return { + scene, + camera, + resize: (width, height) => { + const mobile = width < 640; + const tablet = width < 1024; + outerLandscape.visible = !mobile; + secondaryTrees.visible = !tablet; + trees.visible = !mobile; + celestial.visible = !mobile; + desktopConnectors.visible = !mobile; + mobileConnectors.visible = mobile; + grid.scale.setScalar(mobile ? 0.62 : tablet ? 0.84 : 1); + world.scale.setScalar(mobile ? 0.82 : tablet ? 0.92 : 1); + world.position.y = mobile ? -1.05 : tablet ? -1.35 : -1.55; + const positions = mobile ? MOBILE_MARKERS : DESKTOP_MARKERS; + markerGroups.forEach((group, id) => { + const [x, z] = positions[id]; + group.position.x = x; + group.position.z = z; + }); + fitOrthographicCamera(camera, width, height, mobile ? 14.5 : tablet ? 15.5 : 16.5); + }, + update: (elapsed) => { + markerGroups.forEach((group, id) => { + const hovered = Boolean(group.userData.hovered); + const desiredScale = hovered ? 1.1 : 1; + group.scale.setScalar(group.scale.x + (desiredScale - group.scale.x) * 0.13); + const baseY = Number(group.userData.baseY); + const idleOffset = reduceMotion ? 0 : Math.sin(elapsed * 0.75 + id.length) * 0.015; + const desiredY = baseY + (hovered ? 0.22 : 0) + idleOffset; + group.position.y += (desiredY - group.position.y) * 0.14; + }); + if (!reduceMotion) { + pencil.rotation.y = Math.sin(elapsed * 0.45) * 0.035; + celestial.material.rotation = elapsed * 0.025; + camera.position.x += (cameraBase.x + pointer.x * 0.18 - camera.position.x) * 0.025; + camera.position.z += (cameraBase.z - pointer.x * 0.14 - camera.position.z) * 0.025; + camera.lookAt(0, 0.5, 0); + } + }, + dispose: () => { + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerleave', onPointerLeave); + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + canvas.style.cursor = ''; + }, + }; +}; + +export function FooterScene() { + const { theme } = useTheme(); + return ; +} diff --git a/src/components/scenes/Scenes.module.css b/src/components/scenes/Scenes.module.css index 58f4c46..3e9a072 100644 --- a/src/components/scenes/Scenes.module.css +++ b/src/components/scenes/Scenes.module.css @@ -1,9 +1,14 @@ .heroScene, -.aboutScene { +.aboutScene, +.footerScene { position: absolute; inset: 0; overflow: hidden; background: var(--bg-page); +} + +.heroScene, +.aboutScene { pointer-events: none; } @@ -26,16 +31,27 @@ } .heroScene canvas, -.aboutScene canvas { +.aboutScene canvas, +.footerScene canvas { display: block; width: 100%; height: 100%; } +.footerScene canvas { + touch-action: manipulation; +} + .aboutScene { z-index: 0; } +.footerScene { + z-index: 1; + background: transparent; + pointer-events: auto; +} + .aboutScene::after { content: ''; position: absolute; diff --git a/tests/footer-scene.test.mjs b/tests/footer-scene.test.mjs new file mode 100644 index 0000000..464a7bb --- /dev/null +++ b/tests/footer-scene.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +test('the Three.js footer scene preserves the real contact destinations', async () => { + const contacts = await readFile('src/components/footerContacts.ts', 'utf8'); + const scene = await readFile('src/components/scenes/FooterScene.tsx', 'utf8'); + + assert.match(contacts, /tel:\+989388263077/); + assert.match(contacts, /linkedin\.com\/in\/amiirkhl/); + assert.match(contacts, /https:\/\/git\.amiirkhl\.ir/); + assert.match(contacts, /https:\/\/t\.me\/amiirkhl/); + assert.match(scene, /Raycaster/); + assert.match(scene, /FooterContactId/); +});