arrenge the home page order

This commit is contained in:
2026-07-24 15:28:46 +05:30
parent a4328a791a
commit 4a0adea793
37 changed files with 2532 additions and 1564 deletions

View File

@@ -5,45 +5,50 @@ import { motion, useReducedMotion } from "framer-motion";
// The kinds of things people actually say to Nearle — products *and* local
// services.
//
// Positions are percentages of the phone box, and every card is placed so it
// never covers the live screen — the demo always stays readable:
// · xl+ : three cards drift in the wide gutter left of the phone, two sit
// clear above and below it.
// · below xl there is no gutter, so the visible cards sit above and below
// the device instead of beside it.
// Placement is anchored off the phone box's own edges rather than guessed
// percentages: `right-full` puts a card's right edge exactly on the phone's
// left edge, `left-full` the mirror. Plus a margin, that means a card can never
// cover the live screen at any width — the demo always stays readable.
// · lg+ : three cards drift in the left gutter at staggered heights, two in
// the right gutter between them.
// · below lg there is no gutter, so the two shortest cards sit clear above
// and below the device instead of beside it.
//
// The longer labels are kept on the left, where the grid gap and the text
// column's own slack leave more room to bleed into than the page margin does.
const CARDS = [
{
emoji: "🥛",
text: "Order two litres of milk",
pos: "left-[-4%] top-[-7%] xl:left-[-50%] xl:top-[12%]",
drift: { y: [-9, 9, -9], x: [0, 5, 0] },
pos: "left-[-4%] top-[-8%] lg:left-auto lg:right-full lg:top-[7%] lg:mr-4",
drift: { y: [-9, 9, -9], x: [0, 5, 0], rotate: [-1, 1, -1] },
duration: 15,
delay: 0,
always: true,
},
{
emoji: "💊",
text: "Order medicine for headache",
pos: "right-[-4%] bottom-[-6%] xl:right-[-6%] xl:bottom-[-7%]",
drift: { y: [-10, 7, -10], x: [0, -5, 0] },
text: "Order medicine",
pos: "right-[-4%] bottom-[-7%] lg:bottom-auto lg:right-auto lg:left-full lg:top-[62%] lg:ml-4",
drift: { y: [-10, 7, -10], x: [0, -5, 0], rotate: [1, -1, 1] },
duration: 16,
delay: 2.1,
always: true,
},
{
emoji: "🍅",
text: "Need ingredients for biryani",
pos: "right-[-6%] top-[-9%]",
drift: { y: [8, -8, 8], x: [0, -6, 0] },
emoji: "🔧",
text: "Book an electrician",
pos: "left-full top-[17%] ml-4",
drift: { y: [8, -8, 8], x: [0, -6, 0], rotate: [-1.2, 0.8, -1.2] },
duration: 18,
delay: 1.4,
always: false,
},
{
emoji: "🔧",
text: "Book an electrician tomorrow",
pos: "left-[-56%] top-[45%]",
drift: { y: [7, -10, 7], x: [0, 6, 0] },
emoji: "🍅",
text: "Need ingredients for biryani",
pos: "right-full top-[42%] mr-4",
drift: { y: [7, -10, 7], x: [0, 6, 0], rotate: [0.9, -1.1, 0.9] },
duration: 20,
delay: 0.7,
always: false,
@@ -51,8 +56,8 @@ const CARDS = [
{
emoji: "🥣",
text: "Need breakfast for four",
pos: "left-[-52%] top-[77%]",
drift: { y: [9, -7, 9], x: [0, 4, 0] },
pos: "right-full top-[77%] mr-4",
drift: { y: [9, -7, 9], x: [0, 4, 0], rotate: [-0.8, 1.2, -0.8] },
duration: 19,
delay: 1,
always: false,
@@ -69,7 +74,7 @@ export function FloatingRequestCards() {
{CARDS.map((card, i) => (
<motion.div
key={card.text}
className={`absolute ${card.pos} ${card.always ? "" : "hidden xl:block"}`}
className={`absolute ${card.pos} ${card.always ? "" : "hidden lg:block"}`}
initial={{ opacity: 0, y: 14, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.7, delay: 0.9 + i * 0.12, ease: [0.22, 1, 0.36, 1] }}

View File

@@ -5,6 +5,7 @@ import Image from "next/image";
import { motion, useInView } from "framer-motion";
import { VoiceScreen, SCREEN_W, SCREEN_H } from "./VoiceScreen";
import { useVoiceJourney } from "@/hooks/useVoiceJourney";
import { useRealTimeClock } from "@/hooks/useRealTimeClock";
// Geometry read straight off the provided SVG (viewBox 0 0 476 941):
// screen rect x42 y8 w391 h826 rx57. The frame artwork never changes — only
@@ -21,6 +22,7 @@ const SCREEN = {
export function PhoneStage({ className = "" }: { className?: string }) {
const journey = useVoiceJourney();
const { start, stop, reduced } = journey;
const liveTime = useRealTimeClock();
const rootRef = useRef<HTMLDivElement>(null);
const screenRef = useRef<HTMLDivElement>(null);
@@ -57,72 +59,91 @@ export function PhoneStage({ className = "" }: { className?: string }) {
animates box-shadow (which repaints every frame). */}
<motion.div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 bottom-[3%] h-[9%] w-[78%] -translate-x-1/2 rounded-[50%] bg-[#3B0764]/25 blur-2xl"
animate={reduced ? undefined : { scaleX: [1, 0.9, 1], opacity: [0.5, 0.34, 0.5] }}
className="pointer-events-none absolute left-1/2 bottom-[3%] h-[9%] w-[82%] -translate-x-1/2 rounded-[50%] bg-[#3B0764]/25 blur-2xl"
// Shares the float's 7s period: the shadow tightens as the device
// lifts, which is what sells the float as real height.
animate={reduced ? undefined : { scaleX: [1, 0.88, 1], opacity: [0.5, 0.3, 0.5] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Float — vertical drift */}
<motion.div
animate={reduced ? undefined : { y: [-8, 8, -8] }}
animate={reduced ? undefined : { y: [-10, 10, -10] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
style={{ transformStyle: "preserve-3d" }}
>
{/* Sway — slow rotateY on a deliberately different period, so the two
never sync into one obvious loop */}
{/* Pitch — a very slow rotateX on a third, co-prime period (13s vs the
float's 7s and the sway's 11s), so the three never resolve into one
visible loop. */}
<motion.div
className="relative w-full"
style={{ aspectRatio: "476 / 941", transformStyle: "preserve-3d" }}
animate={reduced ? undefined : { rotateY: [-1.2, 1.2, -1.2] }}
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
animate={reduced ? undefined : { rotateX: [1.2, -1.2, 1.2] }}
transition={{ duration: 13, repeat: Infinity, ease: "easeInOut" }}
style={{ transformStyle: "preserve-3d" }}
>
{/* Layer 0 — the provided iPhone SVG, verbatim and permanent */}
<Image
src="/images/hero/iphone-frame.svg"
alt=""
fill
unoptimized
priority
aria-hidden="true"
className="select-none object-contain"
/>
{/* Layer 1 — the live app, clipped exactly to the screen rect */}
<div
ref={screenRef}
className="absolute overflow-hidden"
style={{
left: SCREEN.left,
top: SCREEN.top,
width: SCREEN.width,
height: SCREEN.height,
borderRadius: SCREEN.borderRadius,
}}
{/* Sway — the device rests at rotateY -4°, so its left edge sits
slightly forward under the root's 1600px perspective. The sway
oscillates around that rest angle rather than around flat-on. */}
<motion.div
className="relative w-full"
style={{ aspectRatio: "476 / 941", transformStyle: "preserve-3d" }}
animate={reduced ? { rotateY: -4 } : { rotateY: [-5.2, -2.8, -5.2] }}
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
>
{scale > 0 && (
<div
style={{
width: SCREEN_W,
height: SCREEN_H,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<VoiceScreen journey={journey} />
</div>
)}
</div>
{/* Layer 0 — the provided iPhone SVG, verbatim and permanent */}
<Image
src="/images/hero/iphone-frame.svg"
alt=""
fill
unoptimized
priority
aria-hidden="true"
className="select-none object-contain"
/>
{/* Layer 2notch + status bar, extracted from the same SVG, so the
app sits *under* the notch exactly like real iOS */}
<Image
src="/images/hero/iphone-chrome.svg"
alt=""
fill
unoptimized
aria-hidden="true"
className="pointer-events-none select-none object-contain"
/>
{/* Layer 1the live app, clipped exactly to the screen rect */}
<div
ref={screenRef}
className="absolute overflow-hidden"
style={{
left: SCREEN.left,
top: SCREEN.top,
width: SCREEN.width,
height: SCREEN.height,
borderRadius: SCREEN.borderRadius,
}}
>
{scale > 0 && (
<div
style={{
width: SCREEN_W,
height: SCREEN_H,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<VoiceScreen journey={journey} />
</div>
)}
</div>
{/* Layer 2 — notch + status bar, extracted from the same SVG, so the
app sits *under* the notch exactly like real iOS */}
<Image
src="/images/hero/iphone-chrome.svg"
alt=""
fill
unoptimized
aria-hidden="true"
className="pointer-events-none select-none object-contain"
/>
{/* Layer 3 — Real-Time Live Clock Overlay */}
<div
className="pointer-events-none absolute left-[18%] top-[2.85%] z-30 select-none text-[11px] font-black tracking-tight text-white sm:text-[12px]"
>
{liveTime || "9:41"}
</div>
</motion.div>
</motion.div>
</motion.div>
</div>

View File

@@ -58,6 +58,7 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
const showRequest =
stage !== "launch" && !onHomeScreen && stage !== "listening" && stage !== "speaking";
const fulfilmentSteps = isBooking ? SERVICE_FULFILMENT_STEPS : FULFILMENT_STEPS;
const itemsSubtotal = request.items.reduce((sum, item) => sum + item.price, 0);
// On the final beat every fulfilment step is done — nothing left pulsing.
const fulfilDone = stage === "delivered" ? fulfilmentSteps.length : fulfilStep;
@@ -228,6 +229,20 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
</p>
<AIThinking steps={AI_STEPS} activeStep={thinkingStep} tone="dark" />
</div>
{/* Shops resolving underneath the reasoning. Deliberately
contentless — they say "nearby stores are being checked"
without ever claiming a name, and they set up the merchant
card that lands on the next beat. */}
<p className="mb-3 mt-6 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-white/35">
<MaterialIcon icon="travel_explore" size={13} />
Scanning nearby
</p>
<div className="space-y-2.5">
{[0, 1, 2].map((i) => (
<MerchantSkeleton key={i} index={i} reduced={reduced} />
))}
</div>
</motion.div>
)}
@@ -235,7 +250,7 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
<motion.div
key="merchant"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
className="absolute inset-0 flex h-full flex-col justify-center px-6 pb-8"
>
<p className="mb-3 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
<MaterialIcon icon="near_me" size={13} />
@@ -274,6 +289,33 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
</span>
</div>
</motion.div>
{/* Why the AI settled on this one. Every figure is read
straight off the request data — nothing is invented. */}
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ icon: "near_me", label: "Nearest", value: request.merchant.distance },
{ icon: "star", label: "Rated", value: `${request.merchant.rating}` },
{
icon: "schedule",
label: isBooking ? "Slot" : "Delivery",
value: request.eta,
},
].map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.05] px-2 py-3 text-center"
>
<MaterialIcon icon={fact.icon} size={15} className="text-[#C9A2FF]" />
<p className="mt-1.5 truncate text-[11px] font-black leading-none">
{fact.value}
</p>
<p className="mt-1 text-[8.5px] font-bold uppercase tracking-wider text-white/40">
{fact.label}
</p>
</div>
))}
</div>
</motion.div>
)}
@@ -288,9 +330,22 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
{isBooking ? "Booking created" : "Order created"}
</p>
<div className="flex items-center gap-2 text-[11px] font-bold text-white/70">
<MaterialIcon icon={request.merchant.icon} size={13} />
<span className="truncate">{request.merchant.name}</span>
<div className="flex items-center gap-2.5 rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[#A855F7]/20 text-[#C9A2FF]">
<MaterialIcon icon={request.merchant.icon} size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-black leading-tight">
{request.merchant.name}
</span>
<span className="mt-0.5 block truncate text-[10px] font-semibold text-white/45">
{request.merchant.area} · {request.merchant.distance}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-white/10 px-2 py-1 text-[9.5px] font-black text-white/80">
<MaterialIcon icon="star" size={10} fill />
{request.merchant.rating}
</span>
</div>
<div className="mt-3 space-y-2">
@@ -324,8 +379,49 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
))}
</div>
{/* Where it lands. Matches the location already shown in the
app header, so the demo stays internally consistent.
Short orders are the ones that used to leave the screen
half empty; a four-item basket already fills it, and this
row would push the confirm button off the bottom. */}
{request.items.length < 3 && (
<div className="mt-3 flex shrink-0 items-center gap-2.5 rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/[0.07] text-white/70">
<MaterialIcon icon={isBooking ? "home_work" : "location_on"} size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[9px] font-bold uppercase tracking-wider text-white/40">
{isBooking ? "Service at" : "Deliver to"}
</span>
<span className="mt-0.5 block truncate text-[11.5px] font-black leading-tight">
Home · R.S. Puram
</span>
</span>
</div>
)}
{/* Itemised, so a one-item request still fills the bill card.
Items always sum to the request total — delivery is free,
it is the merchant's own shop round the corner. */}
<div className="mt-3 shrink-0 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3">
<div className="flex items-end justify-between">
<div className="flex items-center justify-between text-[10.5px] font-bold text-white/45">
<span>
{request.items.length}{" "}
{isBooking
? request.items.length === 1
? "service"
: "services"
: request.items.length === 1
? "item"
: "items"}
</span>
<span>{itemsSubtotal}</span>
</div>
<div className="mt-1.5 flex items-center justify-between text-[10.5px] font-bold text-white/45">
<span>{isBooking ? "Booking fee" : "Delivery"}</span>
<span className="font-black text-emerald-300">Free</span>
</div>
<div className="mt-2.5 flex items-end justify-between border-t border-white/10 pt-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-white/50">
Total
</span>
@@ -339,11 +435,13 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
</div>
</div>
{/* Confirm auto-taps at the end of this beat */}
{/* Confirm auto-taps at the end of this beat. mt-auto pins it
to the foot of the screen the way a real checkout does, so
a short order never leaves a hole under the bill. */}
<motion.div
animate={reduced ? undefined : { scale: [1, 1, 0.95, 1] }}
transition={{ duration: 2.6, times: [0, 0.76, 0.84, 0.92], ease: EASE }}
className="mt-3 flex shrink-0 items-center justify-center gap-1.5 rounded-full bg-white py-3.5 text-[13px] font-black text-[#2A0846]"
className="mb-1 mt-auto flex shrink-0 items-center justify-center gap-1.5 rounded-full bg-white py-3.5 text-[13px] font-black text-[#2A0846]"
>
<MaterialIcon icon="bolt" size={15} />
{request.cta}
@@ -467,6 +565,28 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
);
}
// Placeholder shop rows that pulse while the AI searches. Intentionally blank —
// they read as "nearby stores are resolving" without ever asserting a name or a
// figure the demo hasn't earned yet.
function MerchantSkeleton({ index, reduced }: { index: number; reduced: boolean }) {
return (
<motion.div
className="flex items-center gap-3 rounded-2xl border border-white/[0.07] bg-white/[0.04] px-3 py-2.5"
animate={reduced ? undefined : { opacity: [0.4, 0.75, 0.4] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut", delay: index * 0.22 }}
>
<span className="h-9 w-9 shrink-0 rounded-xl bg-white/[0.07]" />
<span className="min-w-0 flex-1">
<span
className="block h-2.5 rounded-full bg-white/[0.09]"
style={{ width: `${72 - index * 14}%` }}
/>
<span className="mt-1.5 block h-2 w-[38%] rounded-full bg-white/[0.06]" />
</span>
</motion.div>
);
}
// The listening orb: a soft purple core with breathing rings.
function MicOrb({ reduced, active }: { reduced: boolean; active: boolean }) {
return (

View File

@@ -7,10 +7,13 @@ import { motion, useReducedMotion } from "framer-motion";
const WAVE =
"M0 150 Q100 80 200 150 T400 150 T600 150 T800 150 T1000 150 T1200 150 T1400 150 T1600 150 T1800 150 T2000 150 T2200 150 T2400 150";
// Opacities are tuned for a pure white canvas. On the old lavender background
// these read as texture; on white anything stronger reads as clutter and pulls
// attention off the phone.
const LAYERS = [
{ y: 0, scaleY: 1, opacity: 0.18, duration: 12, width: 2 },
{ y: 26, scaleY: 0.62, opacity: 0.13, duration: 16, width: 1.5 },
{ y: -22, scaleY: 1.35, opacity: 0.1, duration: 21, width: 1.5 },
{ y: 0, scaleY: 1, opacity: 0.1, duration: 12, width: 2 },
{ y: 26, scaleY: 0.62, opacity: 0.07, duration: 16, width: 1.5 },
{ y: -22, scaleY: 1.35, opacity: 0.055, duration: 21, width: 1.5 },
];
// Elegant voice waves behind the phone. Purely decorative, low opacity, and