Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/caramel-app/src/components/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,9 +493,19 @@ export default function HeroSection() {
reduce={!!reduceMotion}
/>
</div>
{/* The canvas raster BLEEDS 48px/40px past the layout
box (must match CANVAS_BLEED_X/Y_PX in
HeroTicketScene, which subtracts the bleed from its
fit math) so a tilted coupon or drifting droplet
never touches the raster edge — the section's
overflow-hidden still crops safely far away at the
SECTION edge. 48px of left bleed = exactly the
column gap (gap-12), so the pointer-capturing
canvas never covers the copy column's links. The
anchor box keeps its reserved h-[34rem] — zero CLS. */}
{showCanvas && (
<div
className="absolute inset-0 transition-opacity duration-700 ease-out"
className="absolute -inset-x-[48px] -inset-y-[40px] transition-opacity duration-700 ease-out"
style={{ opacity: canvasReady ? 1 : 0 }}
>
<HeroTicketScene
Expand Down
80 changes: 60 additions & 20 deletions apps/caramel-app/src/components/HeroTicketScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,26 @@ const STAT_X = [-2.15, 0, 2.15]
const MAIN_Y = 1.4

// Content bounds of the main + stat group (world units, pre-fit) used to scale
// the group so nothing clips the narrow hero column. Kept as consts so the fit
// math stays readable.
// the group so it sizes itself to the LAYOUT box. Derivation against the
// actual layout:
// half-W = max(CARD.w/2 = 1.55, STAT_X[2] + STAT.w/2 = 2.15 + 0.85) = 3.0
// half-H = max(MAIN_Y + CARD.h/2 = 1.4 + 1.0 = 2.4,
// |STAT_Y| + STAT.h/2 = 1.55 + 0.575 = 2.125) = 2.4
// Float amplitude + parallax tilt (rotY 0.5 main / 0.22 row + Float
// rotationIntensity 0.2–0.25) swing a few % PAST these rest bounds — that
// spill lands in the canvas bleed below, never at the raster edge.
const CONTENT_HALF_W = 3.0
const CONTENT_HALF_H = 2.4

// The DOM shell (HeroSection) bleeds the canvas raster past the reserved
// layout box by these px per side (the -inset-x-[48px] -inset-y-[40px]
// wrapper — keep the two in sync) so tilted coupons and drifting droplets
// never get guillotined at the raster edge. The fit math subtracts the bleed,
// so the composition still sizes itself to the LAYOUT box: the bleed is pure
// clip headroom, not extra content room.
const CANVAS_BLEED_X_PX = 48
const CANVAS_BLEED_Y_PX = 40

// A small drifting caramel field — one instanced mesh, ≤10 spheres.
const DROPLET_COUNT = 10

Expand All @@ -92,13 +107,18 @@ interface DropletDatum {
// Randomized droplet layout is computed ONCE at module load (client-only — the
// scene is dynamically imported ssr:false, single instance), never during
// render, so Math.random() stays out of the render phase (react-hooks/purity)
// and the field is stable across re-renders.
// and the field is stable across re-renders. The y-spread is capped at 5 (not
// the previous 6) because the field now lives inside the fit-scaled group:
// worst case |y| = 2.5 origin + 0.9 drift + 0.17 radius = 3.57, times the
// largest realistic fit (~0.66) = 2.36 world — safely inside the frustum
// half-height (2.687), so a floating sphere never gets sliced at the top or
// bottom raster edge.
const DROPLET_DATA: DropletDatum[] = Array.from(
{ length: DROPLET_COUNT },
() => ({
origin: new THREE.Vector3(
(Math.random() - 0.5) * 7,
(Math.random() - 0.5) * 6,
(Math.random() - 0.5) * 5,
(Math.random() - 0.5) * 4 - 1,
),
speed: 0.2 + Math.random() * 0.5,
Expand Down Expand Up @@ -242,34 +262,40 @@ function StatCoupon3D({
color={isDark ? CARAMEL_LIGHT : '#fff2e6'}
/>
{/* Soft dark outline-blur = printed-ink shadow, so the type
reads as pressed into the caramel glass. */}
reads as pressed into the caramel glass (deliberately a
touch stronger on the number so it pops off the caramel). */}
<Text
font={STAT_FONT}
fontSize={0.3}
fontSize={0.34}
position={[0, 0.13, STAT_FRONT + 0.012]}
anchorX="center"
anchorY="middle"
color="#ffffff"
outlineWidth={0.012}
outlineBlur={0.02}
outlineWidth={0.016}
outlineBlur={0.024}
outlineColor="#7a2f00"
outlineOpacity={0.35}
outlineOpacity={0.5}
>
{shown}
</Text>
{/* Label fit math (longest label = "SUPPORTED STORES", 16
glyphs): Poppins-Bold uppercase averages ~0.62em advance,
so width ≈ 16 × 0.11 × (0.62 + 0.08 letterSpacing) ≈ 1.23 —
inside the usable face width of STAT.w 1.7 − 2 × notch r
0.14 = 1.42, with ~0.095 margin per side. That's why the
letterSpacing dropped 0.12 → 0.08 alongside the size bump. */}
<Text
font={STAT_FONT}
fontSize={0.088}
letterSpacing={0.12}
fontSize={0.11}
letterSpacing={0.08}
position={[0, -0.22, STAT_FRONT + 0.012]}
anchorX="center"
anchorY="middle"
color="#ffffff"
fillOpacity={0.92}
outlineWidth={0.006}
outlineBlur={0.012}
outlineColor="#7a2f00"
outlineOpacity={0.3}
outlineOpacity={0.35}
>
{stat.label.toUpperCase()}
</Text>
Expand Down Expand Up @@ -384,16 +410,26 @@ function Droplets({ isDark }: { isDark: boolean }): React.JSX.Element {
function SceneContents({ isDark }: { isDark: boolean }): React.JSX.Element {
// The vertical FOV keeps world height constant, so on the narrow hero column
// the main ticket + the stat-coupon row can overflow the frustum — scale the
// whole ticket group down to fit both dimensions (with a small margin).
// Reactive to resize via R3F size state; no per-frame camera work.
const aspect = useThree(state => state.size.width / state.size.height)
// whole ticket group down to fit both dimensions. The canvas raster is BLED
// past the layout box (CANVAS_BLEED_*_PX), so the fit is computed against
// the layout-box slice of the frustum, not the whole canvas: the composition
// keeps the size it had when canvas == layout box, and the bleed becomes
// pure headroom that tilted geometry can swing into without clipping. The
// 0.9 margin (was 0.94) leaves ~10% of the layout box as rest-pose slack
// before the bleed even starts. Reactive to resize via R3F size state; no
// per-frame camera work.
const { width, height } = useThree(state => state.size)
const dist = 7
const halfH = Math.tan((42 * Math.PI) / 180 / 2) * dist
const halfW = halfH * aspect
const worldPerPx = (halfH * 2) / height
const layoutHalfW =
(Math.max(1, width - CANVAS_BLEED_X_PX * 2) / 2) * worldPerPx
const layoutHalfH =
(Math.max(1, height - CANVAS_BLEED_Y_PX * 2) / 2) * worldPerPx
const fit = Math.min(
1,
(halfW * 0.94) / CONTENT_HALF_W,
(halfH * 0.94) / CONTENT_HALF_H,
(layoutHalfW * 0.9) / CONTENT_HALF_W,
(layoutHalfH * 0.9) / CONTENT_HALF_H,
)

return (
Expand All @@ -416,8 +452,12 @@ function SceneContents({ isDark }: { isDark: boolean }): React.JSX.Element {
<HeroCard isDark={isDark} />
</group>
<StatCoupons isDark={isDark} />
{/* Inside the fit group ON PURPOSE: the droplet field is wider
than the layout box in raw world units, so scaling it with
the content keeps every sphere inside the (bled) frustum
instead of slicing them mid-sphere at the raster edge. */}
<Droplets isDark={isDark} />
</group>
<Droplets isDark={isDark} />

<Sparkles
count={16}
Expand Down
6 changes: 3 additions & 3 deletions apps/caramel-app/src/components/coupons/coupon-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface CouponCardProps {
const TIER_CLS: Record<CouponStatusTier, string> = {
green: 'bg-green-100 text-green-700 ring-green-200 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-900/50',
amber: 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-900/50',
grey: 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-700',
grey: 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/20',
red: 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-900/50',
}

Expand Down Expand Up @@ -55,7 +55,7 @@ export default function CouponCard({ coupon, index }: CouponCardProps) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="group relative overflow-hidden rounded-3xl border border-orange-100 bg-gradient-to-br from-orange-50/50 via-white to-orange-50/40 p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:border-orange-200 hover:shadow-lg dark:border-orange-900/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 dark:hover:border-orange-800/70"
className="group relative overflow-hidden rounded-3xl border border-orange-100 bg-gradient-to-br from-orange-50/50 via-white to-orange-50/40 p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:border-orange-200 hover:shadow-lg dark:border-orange-900/50 dark:from-darkSurface dark:via-darkSurface dark:to-darkSurface dark:hover:border-orange-800/70"
>
<div className="flex items-center gap-5 md:flex-col md:items-start">
{/* Left: Discount Badge */}
Expand Down Expand Up @@ -111,7 +111,7 @@ export default function CouponCard({ coupon, index }: CouponCardProps) {
<button
type="button"
onClick={handleCopyCode}
className="whitespace-nowrap rounded-2xl bg-gradient-to-r from-caramel to-orange-600 px-6 py-3 font-semibold text-white shadow-md transition-all hover:scale-105 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 md:w-full"
className="whitespace-nowrap rounded-2xl bg-gradient-to-r from-caramel to-orange-600 px-6 py-3 font-semibold text-white shadow-md transition-all hover:scale-105 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:focus-visible:ring-offset-darkSurface md:w-full"
>
Get Coupon Code
</button>
Expand Down
15 changes: 9 additions & 6 deletions apps/caramel-app/src/components/coupons/coupon-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,25 @@ export default function CouponFilters({
paddingLeft: '4px',
paddingRight: '4px',
minHeight: '44px',
backgroundColor: isDarkMode ? '#111827' : '#ffffff',
// react-select can't take Tailwind classes, so these hexes must
// track the brand surfaces: darkSurface #1E1916 (warm charcoal,
// tailwind.config.ts) instead of the blue-hued gray-900/800.
backgroundColor: isDarkMode ? '#1E1916' : '#ffffff',
width: '100%',
}),
menu: base => ({
...base,
zIndex: 20,
backgroundColor: isDarkMode ? '#111827' : '#ffffff',
backgroundColor: isDarkMode ? '#1E1916' : '#ffffff',
}),
option: (base, state) => ({
...base,
backgroundColor: state.isFocused
? isDarkMode
? '#1f2937'
? '#2B241F' // warm hover step above darkSurface
: '#f3f4f6'
: isDarkMode
? '#111827'
? '#1E1916'
: '#ffffff',
color: isDarkMode ? '#ffffff' : '#111827',
cursor: 'pointer',
Expand Down Expand Up @@ -155,7 +158,7 @@ export default function CouponFilters({
value={localSearch}
onChange={e => handleSearchChange(e.target.value)}
placeholder="Search coupons by site, title, or description..."
className="w-full rounded-md border-2 border-caramel/30 bg-white px-3 py-[11px] pl-10 text-sm outline-none transition-all focus:border-caramel dark:bg-gray-900 dark:text-white dark:placeholder-gray-500 dark:focus:border-orange-400"
className="w-full rounded-md border-2 border-caramel/30 bg-white px-3 py-[11px] pl-10 text-sm outline-none transition-all focus:border-caramel dark:bg-darkSurface dark:text-white dark:placeholder-gray-500 dark:focus:border-orange-400"
/>
</div>
</div>
Expand Down Expand Up @@ -234,7 +237,7 @@ export default function CouponFilters({
type="button"
aria-expanded={showFilters}
onClick={() => setShowFilters(!showFilters)}
className="mt-2 hidden w-full items-center justify-center gap-2 rounded-full bg-white px-6 py-3 text-caramel shadow-md transition-all hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:bg-gray-900 dark:text-orange-400 dark:focus-visible:ring-offset-darkBg md:flex"
className="mt-2 hidden w-full items-center justify-center gap-2 rounded-full bg-white px-6 py-3 text-caramel shadow-md transition-all hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:bg-darkSurface dark:text-orange-400 dark:focus-visible:ring-offset-darkBg md:flex"
>
<FaFilter className="h-4 w-4" />
<span className="font-semibold">Filters</span>
Expand Down
12 changes: 6 additions & 6 deletions apps/caramel-app/src/components/coupons/coupons-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export default function CouponsSection({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex min-h-[400px] flex-col items-center justify-center rounded-3xl border-2 border-dashed border-caramel/25 bg-white p-12 text-center shadow-sm dark:border-caramel/30 dark:bg-gray-900"
className="flex min-h-[400px] flex-col items-center justify-center rounded-3xl border-2 border-dashed border-caramel/25 bg-white p-12 text-center shadow-sm dark:border-caramel/30 dark:bg-darkSurface"
>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-caramel/10 text-3xl dark:bg-caramel/20">
<MagnifyingGlassIcon className="h-8 w-8 text-caramel" />
Expand Down Expand Up @@ -354,7 +354,7 @@ export default function CouponsSection({

{/* Sidebar */}
<aside className="w-80 space-y-4 md:w-full">
<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-gray-800 dark:bg-gray-900">
<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-white/10 dark:bg-darkSurface">
<div className="mb-4 flex items-center justify-center">
<Image
src={storeLogo}
Expand All @@ -379,7 +379,7 @@ export default function CouponsSection({
</p>
</div>

<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-gray-800 dark:bg-gray-900">
<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-white/10 dark:bg-darkSurface">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">
Available on Your Favorite Browser
</p>
Expand Down Expand Up @@ -418,7 +418,7 @@ export default function CouponsSection({
href={browser.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-gray-50 px-6 py-3 text-center shadow-sm transition hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus-visible:ring-offset-gray-900"
className="inline-flex items-center gap-2 rounded-full bg-gray-50 px-6 py-3 text-center shadow-sm transition hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:bg-white/5 dark:hover:bg-white/10 dark:focus-visible:ring-offset-darkSurface"
>
{browser.icon}
{browser.name}
Expand All @@ -427,7 +427,7 @@ export default function CouponsSection({
</div>
</div>

<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-gray-800 dark:bg-gray-900">
<div className="rounded-2xl border border-gray-100 bg-white p-6 shadow-lg dark:border-white/10 dark:bg-darkSurface">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">
5,000+ Supported Stores
</p>
Expand All @@ -437,7 +437,7 @@ export default function CouponsSection({
</p>
<Link
href="/supported-stores"
className="mt-4 inline-flex items-center justify-center rounded-full bg-gradient-to-r from-caramel to-orange-600 px-4 py-2 text-xs font-semibold text-white shadow-md transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
className="mt-4 inline-flex items-center justify-center rounded-full bg-gradient-to-r from-caramel to-orange-600 px-4 py-2 text-xs font-semibold text-white shadow-md transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-caramel focus-visible:ring-offset-2 dark:focus-visible:ring-offset-darkSurface"
>
View All Supported Stores
</Link>
Expand Down
Loading
Loading