arrenge the home page order
This commit is contained in:
@@ -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] }}
|
||||
|
||||
@@ -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 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 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,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user