Skip to content

Commit 65129f8

Browse files
committed
feat(shop): 3D rotating t-shirt hero on shop landing
Adds a Three.js hero section to /shop with a realistic 3D t-shirt model that auto-rotates and cycles product featured images as front- face decals every 3 seconds. Subtle mouse-follow on the rotation gives it an interactive feel without requiring explicit drag. Model: shirt.glb (MIT-licensed, from the threejs-t-shirt project) stored in public/shop/. Loaded via useGLTF, textured via drei's <Decal>, lit with Environment preset + AccumulativeShadows. The component is lazy-loaded (React.lazy + Suspense) so Three.js doesn't block the initial product grid SSR/hydration. Added maath for smooth easing (dampC, dampE) — same lib the /explore game scene already uses transitively.
1 parent d49bed8 commit 65129f8

5 files changed

Lines changed: 161 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"jszip": "^3.10.1",
8383
"lru-cache": "^11.2.7",
8484
"lucide-react": "^1.7.0",
85+
"maath": "^0.10.8",
8586
"match-sorter": "^8.2.0",
8687
"mermaid": "^11.14.0",
8788
"postgres": "^3.4.8",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/shop/shirt.glb

1020 KB
Binary file not shown.

src/components/shop/ShopHero3D.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as React from 'react'
2+
import { Canvas, useFrame } from '@react-three/fiber'
3+
import {
4+
Center,
5+
Decal,
6+
Environment,
7+
AccumulativeShadows,
8+
RandomizedLight,
9+
useGLTF,
10+
useTexture,
11+
} from '@react-three/drei'
12+
import { easing } from 'maath'
13+
import type { ProductListItem } from '~/utils/shopify-queries'
14+
import { shopifyImageUrl } from '~/utils/shopify-format'
15+
16+
type ShopHero3DProps = {
17+
products: Array<ProductListItem>
18+
}
19+
20+
/**
21+
* 3D rotating t-shirt hero for the shop landing.
22+
*
23+
* Model: `public/shop/shirt.glb` (MIT-licensed, from the threejs-t-shirt
24+
* project). Product featured images cycle as front-face decals.
25+
*
26+
* Lazy-loaded so Three.js doesn't block the initial product grid render.
27+
*/
28+
export function ShopHero3D({ products }: ShopHero3DProps) {
29+
const images = products
30+
.map((p) =>
31+
p.featuredImage
32+
? shopifyImageUrl(p.featuredImage.url, { width: 512, format: 'webp' })
33+
: null,
34+
)
35+
.filter(Boolean) as Array<string>
36+
37+
if (images.length === 0) return null
38+
39+
return (
40+
<div className="w-full h-[400px] md:h-[500px] relative">
41+
<Canvas
42+
shadows
43+
camera={{ position: [0, 0, 2.5], fov: 25 }}
44+
gl={{ preserveDrawingBuffer: true, antialias: true }}
45+
className="!absolute inset-0"
46+
>
47+
<ambientLight intensity={0.5} />
48+
<Environment preset="city" />
49+
<Backdrop />
50+
<Center>
51+
<RotatingShirt images={images} />
52+
</Center>
53+
</Canvas>
54+
</div>
55+
)
56+
}
57+
58+
function Backdrop() {
59+
return (
60+
<AccumulativeShadows
61+
temporal
62+
frames={60}
63+
alphaTest={0.85}
64+
scale={10}
65+
rotation={[Math.PI / 2, 0, 0]}
66+
position={[0, 0, -0.14]}
67+
>
68+
<RandomizedLight
69+
amount={4}
70+
radius={9}
71+
intensity={0.55}
72+
ambient={0.25}
73+
position={[5, 5, -10]}
74+
/>
75+
<RandomizedLight
76+
amount={4}
77+
radius={5}
78+
intensity={0.25}
79+
ambient={0.55}
80+
position={[-5, 5, -9]}
81+
/>
82+
</AccumulativeShadows>
83+
)
84+
}
85+
86+
function RotatingShirt({ images }: { images: Array<string> }) {
87+
const { nodes, materials } = useGLTF('/shop/shirt.glb') as any
88+
const groupRef = React.useRef<any>(null)
89+
90+
// Cycle through product images
91+
const [imageIndex, setImageIndex] = React.useState(0)
92+
React.useEffect(() => {
93+
if (images.length <= 1) return
94+
const id = setInterval(() => {
95+
setImageIndex((prev) => (prev + 1) % images.length)
96+
}, 3000)
97+
return () => clearInterval(id)
98+
}, [images.length])
99+
100+
const decalTexture = useTexture(images[imageIndex]!)
101+
102+
// Gentle auto-rotation + subtle mouse follow
103+
useFrame((state, delta) => {
104+
if (!groupRef.current) return
105+
106+
// Auto-rotate slowly
107+
groupRef.current.rotation.y += delta * 0.3
108+
109+
// Subtle mouse follow (additive on top of rotation)
110+
easing.dampE(
111+
groupRef.current.rotation,
112+
[
113+
state.pointer.y * 0.08,
114+
groupRef.current.rotation.y - state.pointer.x * 0.15,
115+
0,
116+
],
117+
0.25,
118+
delta,
119+
)
120+
121+
// Smooth color to white
122+
easing.dampC(materials.lambert1.color, '#ffffff', 0.25, delta)
123+
})
124+
125+
return (
126+
<group ref={groupRef}>
127+
<mesh
128+
castShadow
129+
geometry={nodes.T_Shirt_male.geometry}
130+
material={materials.lambert1}
131+
material-roughness={1}
132+
dispose={null}
133+
>
134+
<Decal
135+
position={[0, 0.04, 0.15]}
136+
rotation={[0, 0, 0]}
137+
scale={0.17}
138+
map={decalTexture}
139+
{...({ depthTest: false, depthWrite: true } as any)}
140+
/>
141+
</mesh>
142+
</group>
143+
)
144+
}
145+
146+
// Preload the model so it's ready when the component mounts
147+
useGLTF.preload('/shop/shirt.glb')

src/routes/shop.index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
33
import { useMutation } from '@tanstack/react-query'
44
import * as v from 'valibot'
55
import { ProductCard } from '~/components/shop/ProductCard'
6+
7+
const LazyShopHero = React.lazy(() =>
8+
import('~/components/shop/ShopHero3D').then((m) => ({
9+
default: m.ShopHero3D,
10+
})),
11+
)
612
import { getProducts } from '~/utils/shop.functions'
713
import {
814
SORT_OPTIONS,
@@ -93,6 +99,10 @@ function ShopIndex() {
9399

94100
return (
95101
<div className="flex flex-col max-w-6xl mx-auto gap-8 p-4 md:p-8">
102+
<React.Suspense fallback={null}>
103+
<LazyShopHero products={products} />
104+
</React.Suspense>
105+
96106
<header className="flex flex-wrap items-end justify-between gap-4">
97107
<div>
98108
<h1 className="text-3xl font-black">All Products</h1>

0 commit comments

Comments
 (0)