Spaces:
Sleeping
Sleeping
eeshaAI commited on
Commit ·
2490428
1
Parent(s): c92b696
fix: iOS nav stack — complete swipe-back + skeleton loader + nav fix
Browse files- Swipe-back now completes fully (manual touch handlers, no framer-motion drag)
- No more black screen on back navigation (delayed stack cleanup)
- Skeleton loading screen (shimmer animation)
- Mobile nav: no duplicate home button, Categories replaces Chat, Discover → /videos
- package.json +1 -1
- public/public/logo.svg +29 -0
- public/public/robots.txt +14 -0
- src/app/globals.css +7 -0
- src/app/loading.tsx +59 -91
- src/components/ios-stack.tsx +135 -65
- src/components/mobile-nav.tsx +19 -4
- src/components/screen.tsx +138 -80
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "nextjs_tailwind_shadcn_ts",
|
| 3 |
-
"version": "0.2.
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "nextjs_tailwind_shadcn_ts",
|
| 3 |
+
"version": "0.2.9",
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
public/public/logo.svg
ADDED
|
|
public/public/robots.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: Googlebot
|
| 2 |
+
Allow: /
|
| 3 |
+
|
| 4 |
+
User-agent: Bingbot
|
| 5 |
+
Allow: /
|
| 6 |
+
|
| 7 |
+
User-agent: Twitterbot
|
| 8 |
+
Allow: /
|
| 9 |
+
|
| 10 |
+
User-agent: facebookexternalhit
|
| 11 |
+
Allow: /
|
| 12 |
+
|
| 13 |
+
User-agent: *
|
| 14 |
+
Allow: /
|
src/app/globals.css
CHANGED
|
@@ -286,6 +286,13 @@ button {
|
|
| 286 |
animation: shimmer 1.5s infinite;
|
| 287 |
}
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
/* Hover lift effect */
|
| 290 |
.hover-lift {
|
| 291 |
transition: all 0.2s ease;
|
|
|
|
| 286 |
animation: shimmer 1.5s infinite;
|
| 287 |
}
|
| 288 |
|
| 289 |
+
/* Shimmer utility — used by loading.tsx skeleton */
|
| 290 |
+
.shimmer {
|
| 291 |
+
background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 50%, #f0f0f0 100%);
|
| 292 |
+
background-size: 200% 100%;
|
| 293 |
+
animation: shimmer 1.5s infinite;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
/* Hover lift effect */
|
| 297 |
.hover-lift {
|
| 298 |
transition: all 0.2s ease;
|
src/app/loading.tsx
CHANGED
|
@@ -1,103 +1,71 @@
|
|
| 1 |
-
'use client';
|
| 2 |
-
|
| 3 |
-
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
| 4 |
-
import { useRef } from 'react';
|
| 5 |
-
|
| 6 |
/**
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 12 |
-
*
|
| 13 |
-
*
|
| 14 |
-
*
|
|
|
|
|
|
|
| 15 |
*/
|
| 16 |
export default function Loading() {
|
| 17 |
-
const x = useMotionValue(0);
|
| 18 |
-
const y = useMotionValue(0);
|
| 19 |
-
const containerRef = useRef<HTMLDivElement>(null);
|
| 20 |
-
|
| 21 |
return (
|
| 22 |
-
<div
|
| 23 |
-
{/*
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
>
|
| 32 |
-
{/*
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
style={{ borderTopColor: '#000' }}
|
| 38 |
-
/>
|
| 39 |
-
{/* Inner C (pulsing) */}
|
| 40 |
-
<motion.div
|
| 41 |
-
animate={{ scale: [1, 1.1, 1] }}
|
| 42 |
-
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
|
| 43 |
-
className="absolute inset-0 flex items-center justify-center"
|
| 44 |
-
>
|
| 45 |
-
<span className="text-3xl font-extrabold text-black" style={{ fontFamily: 'var(--font-geist-mono)' }}>C</span>
|
| 46 |
-
</motion.div>
|
| 47 |
-
</motion.div>
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
dragElastic={0.4}
|
| 57 |
-
dragTransition={{ bounceStiffness: 300, bounceDamping: 20 }}
|
| 58 |
-
initial={{ opacity: 0, y: 20 }}
|
| 59 |
-
animate={{ opacity: 1, y: 0 }}
|
| 60 |
-
transition={{ delay: i * 0.1 }}
|
| 61 |
-
className="w-24 h-32 rounded-xl bg-neutral-100 overflow-hidden cursor-grab active:cursor-grabbing shadow-sm"
|
| 62 |
-
>
|
| 63 |
-
<div className="w-full h-16 skeleton" />
|
| 64 |
-
<div className="p-2 space-y-1.5">
|
| 65 |
-
<div className="skeleton h-2 rounded w-full" />
|
| 66 |
-
<div className="skeleton h-2 rounded w-2/3" />
|
| 67 |
-
<div className="skeleton h-3 rounded w-1/2" />
|
| 68 |
</div>
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
</div>
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
key={i}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
</div>
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
</motion.p>
|
| 101 |
</div>
|
| 102 |
);
|
| 103 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Loading — skeleton view shown while a page is being loaded.
|
| 3 |
+
*
|
| 4 |
+
* This is Next.js's built-in loading.tsx convention. It automatically
|
| 5 |
+
* wraps every route segment in a Suspense boundary and shows this
|
| 6 |
+
* skeleton while the page's data is being fetched.
|
| 7 |
+
*
|
| 8 |
+
* The skeleton mimics the general layout of a Cellex page:
|
| 9 |
+
* - Top bar (back button + title placeholder)
|
| 10 |
+
* - Content blocks (shimmer cards)
|
| 11 |
+
* - Bottom nav placeholder
|
| 12 |
*/
|
| 13 |
export default function Loading() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return (
|
| 15 |
+
<div className="min-h-screen bg-white flex flex-col">
|
| 16 |
+
{/* Top bar skeleton */}
|
| 17 |
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100">
|
| 18 |
+
<div className="w-8 h-8 rounded-full bg-slate-100 shimmer" />
|
| 19 |
+
<div className="flex-1 h-5 rounded-full bg-slate-100 shimmer" />
|
| 20 |
+
<div className="w-8 h-8 rounded-full bg-slate-100 shimmer" />
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
{/* Content skeleton — mimics a product grid */}
|
| 24 |
+
<div className="flex-1 p-4 space-y-4">
|
| 25 |
+
{/* Hero banner placeholder */}
|
| 26 |
+
<div className="w-full h-40 rounded-2xl bg-slate-100 shimmer" />
|
| 27 |
+
|
| 28 |
+
{/* Section title */}
|
| 29 |
+
<div className="w-32 h-4 rounded-full bg-slate-100 shimmer" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
{/* Product grid 2x3 */}
|
| 32 |
+
<div className="grid grid-cols-2 gap-3">
|
| 33 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 34 |
+
<div key={i} className="space-y-2">
|
| 35 |
+
<div className="w-full aspect-square rounded-xl bg-slate-100 shimmer" />
|
| 36 |
+
<div className="w-3/4 h-3 rounded-full bg-slate-100 shimmer" />
|
| 37 |
+
<div className="w-1/2 h-3 rounded-full bg-slate-100 shimmer" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
+
))}
|
| 40 |
+
</div>
|
|
|
|
| 41 |
|
| 42 |
+
{/* Another section */}
|
| 43 |
+
<div className="w-40 h-4 rounded-full bg-slate-100 shimmer" />
|
| 44 |
+
<div className="flex gap-3 overflow-hidden">
|
| 45 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 46 |
+
<div key={i} className="flex-shrink-0 w-32 space-y-2">
|
| 47 |
+
<div className="w-full h-32 rounded-xl bg-slate-100 shimmer" />
|
| 48 |
+
<div className="w-3/4 h-3 rounded-full bg-slate-100 shimmer" />
|
| 49 |
+
</div>
|
| 50 |
+
))}
|
| 51 |
+
</div>
|
| 52 |
</div>
|
| 53 |
|
| 54 |
+
{/* Bottom nav skeleton */}
|
| 55 |
+
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-100 px-4 py-2">
|
| 56 |
+
<div className="flex items-center justify-around max-w-lg mx-auto">
|
| 57 |
+
{Array.from({ length: 5 }).map((_, i) => (
|
| 58 |
+
<div key={i} className="flex flex-col items-center gap-1">
|
| 59 |
+
<div
|
| 60 |
+
className={`rounded-full bg-slate-100 shimmer ${
|
| 61 |
+
i === 2 ? 'w-10 h-10' : 'w-5 h-5'
|
| 62 |
+
}`}
|
| 63 |
+
/>
|
| 64 |
+
<div className="w-10 h-2 rounded-full bg-slate-100 shimmer" />
|
| 65 |
+
</div>
|
| 66 |
+
))}
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
|
|
|
| 69 |
</div>
|
| 70 |
);
|
| 71 |
}
|
src/components/ios-stack.tsx
CHANGED
|
@@ -4,89 +4,144 @@
|
|
| 4 |
* IOSStack — true iOS-style navigation stack for Next.js App Router.
|
| 5 |
*
|
| 6 |
* Architecture:
|
| 7 |
-
* 1.
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
* 3.
|
|
|
|
|
|
|
|
|
|
| 12 |
*
|
| 13 |
-
*
|
| 14 |
-
*
|
| 15 |
-
*
|
| 16 |
-
*
|
| 17 |
-
*
|
| 18 |
-
*
|
| 19 |
-
*
|
| 20 |
-
* update the top item's component instead of pushing a duplicate.
|
| 21 |
-
* - Guarded against SSR (stack starts empty, fills on first client render).
|
| 22 |
*/
|
| 23 |
|
| 24 |
import { usePathname, useRouter } from 'next/navigation';
|
| 25 |
-
import { useMotionValue,
|
| 26 |
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 27 |
import { Screen } from '@/components/screen';
|
| 28 |
|
| 29 |
interface StackItem {
|
| 30 |
key: string;
|
| 31 |
component: React.ReactNode;
|
|
|
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
export function IOSStack({ children }: { children: React.ReactNode }) {
|
| 35 |
const pathname = usePathname();
|
| 36 |
const router = useRouter();
|
| 37 |
|
| 38 |
-
// 1. The History Stack
|
| 39 |
const [stack, setStack] = useState<StackItem[]>([]);
|
| 40 |
const isBack = useRef(false);
|
| 41 |
-
const
|
|
|
|
| 42 |
|
| 43 |
-
//
|
| 44 |
-
|
| 45 |
-
// We pass it down so the page BEHIND can "watch" the drag and move in parallax.
|
| 46 |
-
const activeDragX = useMotionValue(0);
|
| 47 |
|
| 48 |
-
//
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
setStack((prev) => {
|
| 53 |
-
// First render — just add the initial page
|
| 54 |
if (prev.length === 0) {
|
| 55 |
isBack.current = false;
|
| 56 |
-
return [{ key:
|
| 57 |
}
|
| 58 |
|
| 59 |
-
// Back detection
|
| 60 |
-
if (prev.length > 1 && prev[prev.length - 2].key ===
|
| 61 |
isBack.current = true;
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
-
// Same path
|
| 66 |
-
if (prev[prev.length - 1].key ===
|
| 67 |
isBack.current = false;
|
| 68 |
-
return [...prev.slice(0, -1), { key:
|
| 69 |
}
|
| 70 |
|
| 71 |
-
//
|
| 72 |
isBack.current = false;
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
});
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 80 |
}, [pathname]);
|
| 81 |
|
| 82 |
-
// Update
|
| 83 |
-
// This ensures context updates (cart count, auth, etc.) propagate to the visible page.
|
| 84 |
const updateTopComponent = useCallback(() => {
|
| 85 |
setStack((prev) => {
|
| 86 |
if (prev.length === 0) return prev;
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
return prev;
|
| 92 |
});
|
|
@@ -97,11 +152,20 @@ export function IOSStack({ children }: { children: React.ReactNode }) {
|
|
| 97 |
updateTopComponent();
|
| 98 |
}, [children, updateTopComponent]);
|
| 99 |
|
| 100 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if (stack.length === 0) {
|
| 102 |
return null;
|
| 103 |
}
|
| 104 |
|
|
|
|
|
|
|
| 105 |
return (
|
| 106 |
<div
|
| 107 |
style={{
|
|
@@ -111,28 +175,34 @@ export function IOSStack({ children }: { children: React.ReactNode }) {
|
|
| 111 |
background: '#000',
|
| 112 |
}}
|
| 113 |
>
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
);
|
| 138 |
}
|
|
|
|
| 4 |
* IOSStack — true iOS-style navigation stack for Next.js App Router.
|
| 5 |
*
|
| 6 |
* Architecture:
|
| 7 |
+
* 1. Maintains a history array of pages so the "previous" page stays alive.
|
| 8 |
+
* 2. A shared MotionValue (`dragX`) is the SINGLE source of truth for the
|
| 9 |
+
* horizontal position of the TOP page. The BEHIND page's x is derived
|
| 10 |
+
* from it via `useTransform` (parallax).
|
| 11 |
+
* 3. ALL animations are driven by `animate(dragX, ...)` calls — no
|
| 12 |
+
* framer-motion variants, no `drag` prop, no AnimatePresence. This
|
| 13 |
+
* avoids the MotionValue-vs-variant conflict that caused the exit
|
| 14 |
+
* animation to freeze.
|
| 15 |
*
|
| 16 |
+
* Navigation flow:
|
| 17 |
+
* PUSH: dragX set to W → animated to 0. New page slides in from right,
|
| 18 |
+
* old page shifts to parallax (-30%).
|
| 19 |
+
* BACK (drag): Screen animates dragX to W (page slides off), THEN calls
|
| 20 |
+
* onBack. Stack removes the exiting page, dragX reset to 0.
|
| 21 |
+
* BACK (button): IOSStack marks top as exiting, animates dragX to W,
|
| 22 |
+
* then removes it and resets dragX.
|
|
|
|
|
|
|
| 23 |
*/
|
| 24 |
|
| 25 |
import { usePathname, useRouter } from 'next/navigation';
|
| 26 |
+
import { useMotionValue, useTransform, animate } from 'framer-motion';
|
| 27 |
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 28 |
import { Screen } from '@/components/screen';
|
| 29 |
|
| 30 |
interface StackItem {
|
| 31 |
key: string;
|
| 32 |
component: React.ReactNode;
|
| 33 |
+
isExiting?: boolean;
|
| 34 |
}
|
| 35 |
|
| 36 |
+
const IOS_SPRING = { type: 'spring' as const, stiffness: 350, damping: 40, mass: 1 };
|
| 37 |
+
const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
| 38 |
+
|
| 39 |
export function IOSStack({ children }: { children: React.ReactNode }) {
|
| 40 |
const pathname = usePathname();
|
| 41 |
const router = useRouter();
|
| 42 |
|
|
|
|
| 43 |
const [stack, setStack] = useState<StackItem[]>([]);
|
| 44 |
const isBack = useRef(false);
|
| 45 |
+
const isDragBack = useRef(false); // true when back was triggered by a drag (animation already done)
|
| 46 |
+
const isAnimating = useRef(false); // prevent double-animation
|
| 47 |
|
| 48 |
+
// Single shared MotionValue — the x position of the TOP page
|
| 49 |
+
const dragX = useMotionValue(0);
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
// Parallax transform for the BEHIND page
|
| 52 |
+
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375;
|
| 53 |
+
const parallaxX = useTransform(dragX, [0, windowWidth], [-windowWidth * 0.3, 0]);
|
| 54 |
+
const overlayOpacity = useTransform(dragX, [0, windowWidth], [0.35, 0]);
|
| 55 |
|
| 56 |
+
// ---- Navigation handler — fires on every pathname change ----
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
const ww = typeof window !== 'undefined' ? window.innerWidth : 375;
|
| 59 |
+
|
| 60 |
+
if (isDragBack.current) {
|
| 61 |
+
// Back was triggered by a drag — the Screen already animated dragX to W.
|
| 62 |
+
// Just clean up the stack and reset.
|
| 63 |
+
isDragBack.current = false;
|
| 64 |
+
isBack.current = true;
|
| 65 |
+
setStack((prev) => {
|
| 66 |
+
const cleaned = prev.filter((item) => !item.isExiting);
|
| 67 |
+
return cleaned.slice(0, -1); // remove the top (exiting) page
|
| 68 |
+
});
|
| 69 |
+
// Reset dragX to 0 — the remaining page was at parallaxX(0)=0 when dragX was W,
|
| 70 |
+
// and now it becomes the top with x=dragX=0. No visual jump.
|
| 71 |
+
dragX.set(0);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Normal navigation (push or browser-back)
|
| 76 |
setStack((prev) => {
|
|
|
|
| 77 |
if (prev.length === 0) {
|
| 78 |
isBack.current = false;
|
| 79 |
+
return [{ key: pathname, component: children }];
|
| 80 |
}
|
| 81 |
|
| 82 |
+
// Back detection
|
| 83 |
+
if (prev.length > 1 && prev[prev.length - 2].key === pathname) {
|
| 84 |
isBack.current = true;
|
| 85 |
+
// Mark the top as exiting — DON'T remove it yet (need it for the animation)
|
| 86 |
+
return prev.map((item, i) =>
|
| 87 |
+
i === prev.length - 1 ? { ...item, isExiting: true } : item
|
| 88 |
+
);
|
| 89 |
}
|
| 90 |
|
| 91 |
+
// Same path — just update the top's component
|
| 92 |
+
if (prev[prev.length - 1].key === pathname) {
|
| 93 |
isBack.current = false;
|
| 94 |
+
return [...prev.slice(0, -1), { key: pathname, component: children }];
|
| 95 |
}
|
| 96 |
|
| 97 |
+
// Push
|
| 98 |
isBack.current = false;
|
| 99 |
+
// Clean up any stale exiting items
|
| 100 |
+
const clean = prev.filter((item) => !item.isExiting);
|
| 101 |
+
return [...clean, { key: pathname, component: children }];
|
| 102 |
});
|
| 103 |
|
| 104 |
+
if (isBack.current) {
|
| 105 |
+
// Button back (not drag) — need to animate dragX from 0 to W
|
| 106 |
+
isAnimating.current = true;
|
| 107 |
+
animate(dragX, ww, {
|
| 108 |
+
duration: 0.3,
|
| 109 |
+
ease: SLIDE_EASE,
|
| 110 |
+
onComplete: () => {
|
| 111 |
+
isAnimating.current = false;
|
| 112 |
+
// Now remove the exiting page and reset
|
| 113 |
+
setStack((prev) => prev.filter((item) => !item.isExiting).slice(0, -1));
|
| 114 |
+
dragX.set(0);
|
| 115 |
+
},
|
| 116 |
+
});
|
| 117 |
+
} else {
|
| 118 |
+
// Push — set dragX to W, then animate to 0
|
| 119 |
+
isAnimating.current = true;
|
| 120 |
+
dragX.set(ww);
|
| 121 |
+
animate(dragX, 0, {
|
| 122 |
+
...IOS_SPRING,
|
| 123 |
+
onComplete: () => {
|
| 124 |
+
isAnimating.current = false;
|
| 125 |
+
},
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 129 |
}, [pathname]);
|
| 130 |
|
| 131 |
+
// ---- Update top component when children changes (context propagation) ----
|
|
|
|
| 132 |
const updateTopComponent = useCallback(() => {
|
| 133 |
setStack((prev) => {
|
| 134 |
if (prev.length === 0) return prev;
|
| 135 |
+
// Find the top non-exiting item
|
| 136 |
+
for (let i = prev.length - 1; i >= 0; i--) {
|
| 137 |
+
if (!prev[i].isExiting) {
|
| 138 |
+
if (prev[i].key === pathname) {
|
| 139 |
+
const updated = [...prev];
|
| 140 |
+
updated[i] = { ...updated[i], component: children };
|
| 141 |
+
return updated;
|
| 142 |
+
}
|
| 143 |
+
break;
|
| 144 |
+
}
|
| 145 |
}
|
| 146 |
return prev;
|
| 147 |
});
|
|
|
|
| 152 |
updateTopComponent();
|
| 153 |
}, [children, updateTopComponent]);
|
| 154 |
|
| 155 |
+
// ---- Handle drag-triggered back ----
|
| 156 |
+
const handleDragBack = useCallback(() => {
|
| 157 |
+
// The Screen already animated dragX to W. We just need to trigger the route change.
|
| 158 |
+
isDragBack.current = true;
|
| 159 |
+
router.back();
|
| 160 |
+
}, [router]);
|
| 161 |
+
|
| 162 |
+
// ---- Render ----
|
| 163 |
if (stack.length === 0) {
|
| 164 |
return null;
|
| 165 |
}
|
| 166 |
|
| 167 |
+
const hasExiting = stack.some((item) => item.isExiting);
|
| 168 |
+
|
| 169 |
return (
|
| 170 |
<div
|
| 171 |
style={{
|
|
|
|
| 175 |
background: '#000',
|
| 176 |
}}
|
| 177 |
>
|
| 178 |
+
{stack.map((item, index) => {
|
| 179 |
+
const isExiting = !!item.isExiting;
|
| 180 |
+
const isLast = index === stack.length - 1;
|
| 181 |
+
const isSecondLast = index === stack.length - 2;
|
| 182 |
+
|
| 183 |
+
// Only render the top 2 non-exit-hidden items + any exiting item
|
| 184 |
+
if (!isLast && !isSecondLast && !isExiting) return null;
|
| 185 |
+
|
| 186 |
+
// Determine role:
|
| 187 |
+
// - EXITING page: uses dragX (sliding off-screen)
|
| 188 |
+
// - TOP page (no exiting): uses dragX (draggable)
|
| 189 |
+
// - BEHIND page OR new top (when exiting exists): uses parallaxX
|
| 190 |
+
const useDragX = isExiting || (isLast && !hasExiting);
|
| 191 |
+
const useParallaxX = !useDragX;
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<Screen
|
| 195 |
+
key={item.key}
|
| 196 |
+
isTop={isLast && !isExiting}
|
| 197 |
+
isExiting={isExiting}
|
| 198 |
+
xValue={useDragX ? dragX : parallaxX}
|
| 199 |
+
overlayOpacity={useParallaxX ? overlayOpacity : undefined}
|
| 200 |
+
onBack={handleDragBack}
|
| 201 |
+
>
|
| 202 |
+
{item.component}
|
| 203 |
+
</Screen>
|
| 204 |
+
);
|
| 205 |
+
})}
|
| 206 |
</div>
|
| 207 |
);
|
| 208 |
}
|
src/components/mobile-nav.tsx
CHANGED
|
@@ -2,13 +2,25 @@
|
|
| 2 |
|
| 3 |
import Link from 'next/link';
|
| 4 |
import { usePathname } from 'next/navigation';
|
| 5 |
-
import { Home,
|
| 6 |
import { useAuth } from '@/components/auth-provider';
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const navItems = [
|
| 9 |
{ href: '/', label: 'Home', icon: Home },
|
| 10 |
-
{ href: '/
|
| 11 |
-
{ href: '/', label: 'Discover', icon: Compass, center: true },
|
| 12 |
{ href: '/cart', label: 'Cart', icon: ShoppingCart, showBadge: true },
|
| 13 |
{ href: '/profile', label: 'Account', icon: User, showDot: true },
|
| 14 |
];
|
|
@@ -18,7 +30,10 @@ export function MobileNav() {
|
|
| 18 |
const { cartCount, user } = useAuth();
|
| 19 |
|
| 20 |
return (
|
| 21 |
-
<nav
|
|
|
|
|
|
|
|
|
|
| 22 |
<div className="flex items-center justify-around max-w-lg mx-auto py-2 relative">
|
| 23 |
{navItems.map((item) => {
|
| 24 |
const isActive = pathname === item.href;
|
|
|
|
| 2 |
|
| 3 |
import Link from 'next/link';
|
| 4 |
import { usePathname } from 'next/navigation';
|
| 5 |
+
import { Home, Search, Compass, ShoppingCart, User } from 'lucide-react';
|
| 6 |
import { useAuth } from '@/components/auth-provider';
|
| 7 |
|
| 8 |
+
/**
|
| 9 |
+
* MobileNav — bottom navigation bar.
|
| 10 |
+
*
|
| 11 |
+
* Architecture (per user's "Hidden Engine" vision):
|
| 12 |
+
* - Home: the immersive Instagram-style feed (/)
|
| 13 |
+
* - Categories: the "hidden engine" — lightning-fast search & category tab
|
| 14 |
+
* - [center] Discover: immersive video/content discovery (/videos)
|
| 15 |
+
* - Cart: shopping cart
|
| 16 |
+
* - Account: user profile
|
| 17 |
+
*
|
| 18 |
+
* No two buttons point to the same route.
|
| 19 |
+
*/
|
| 20 |
const navItems = [
|
| 21 |
{ href: '/', label: 'Home', icon: Home },
|
| 22 |
+
{ href: '/categories', label: 'Categories', icon: Search },
|
| 23 |
+
{ href: '/videos', label: 'Discover', icon: Compass, center: true },
|
| 24 |
{ href: '/cart', label: 'Cart', icon: ShoppingCart, showBadge: true },
|
| 25 |
{ href: '/profile', label: 'Account', icon: User, showDot: true },
|
| 26 |
];
|
|
|
|
| 30 |
const { cartCount, user } = useAuth();
|
| 31 |
|
| 32 |
return (
|
| 33 |
+
<nav
|
| 34 |
+
className="fixed bottom-0 left-0 right-0 z-[60] bg-white border-t border-slate-200 md:hidden"
|
| 35 |
+
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
| 36 |
+
>
|
| 37 |
<div className="flex items-center justify-around max-w-lg mx-auto py-2 relative">
|
| 38 |
{navItems.map((item) => {
|
| 39 |
const isActive = pathname === item.href;
|
src/components/screen.tsx
CHANGED
|
@@ -3,110 +3,168 @@
|
|
| 3 |
/**
|
| 4 |
* Screen — the interactive view layer for the IOSStack.
|
| 5 |
*
|
| 6 |
-
* Each Screen
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
*
|
| 8 |
-
*
|
| 9 |
-
* -
|
| 10 |
-
* -
|
| 11 |
-
* -
|
|
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
-
import { motion,
|
| 15 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
const
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
mass: 1,
|
| 23 |
-
};
|
| 24 |
|
| 25 |
interface ScreenProps {
|
| 26 |
children: ReactNode;
|
| 27 |
-
index: number;
|
| 28 |
isTop: boolean;
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
onBack: () => void;
|
| 32 |
}
|
| 33 |
|
| 34 |
-
export function Screen({ children,
|
| 35 |
-
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
//
|
| 44 |
-
const
|
| 45 |
-
|
| 46 |
-
//
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
const
|
| 74 |
-
const
|
| 75 |
-
|
| 76 |
-
//
|
| 77 |
-
if (
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
-
}
|
| 81 |
|
|
|
|
| 82 |
return (
|
| 83 |
<motion.div
|
| 84 |
style={{
|
| 85 |
position: 'absolute',
|
| 86 |
inset: 0,
|
|
|
|
| 87 |
background: '#fff',
|
| 88 |
-
boxShadow: isTop ? '-10px 0 25px rgba(0,0,0,0.2)' : 'none',
|
| 89 |
-
|
| 90 |
-
x: isTop ? dragX : parallaxX,
|
| 91 |
}}
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
custom={isBack}
|
| 97 |
-
// Only enable gestures if this is the active top page
|
| 98 |
-
drag={isTop ? 'x' : false}
|
| 99 |
-
dragConstraints={{ left: 0, right: 0 }}
|
| 100 |
-
dragElastic={{ left: 0, right: 1 }}
|
| 101 |
-
onDragEnd={handleDragEnd}
|
| 102 |
>
|
| 103 |
-
{/* Page
|
| 104 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
{children}
|
| 106 |
</div>
|
| 107 |
|
| 108 |
-
{/* Dimming
|
| 109 |
-
{
|
| 110 |
<motion.div
|
| 111 |
style={{
|
| 112 |
position: 'absolute',
|
|
|
|
| 3 |
/**
|
| 4 |
* Screen — the interactive view layer for the IOSStack.
|
| 5 |
*
|
| 6 |
+
* Each Screen is an absolutely-positioned layer. Its horizontal position
|
| 7 |
+
* is controlled by a MotionValue passed from IOSStack:
|
| 8 |
+
* - TOP page: x = dragX (draggable via touch)
|
| 9 |
+
* - BEHIND page: x = parallaxX (derived from dragX)
|
| 10 |
+
* - EXITING page: x = dragX (animating to W)
|
| 11 |
*
|
| 12 |
+
* Drag gesture:
|
| 13 |
+
* - Touch must start within EDGE_ZONE_PX of the left edge
|
| 14 |
+
* - Only horizontal-dominant gestures are intercepted (vertical = page scroll)
|
| 15 |
+
* - On release: if past COMMIT_THRESHOLD → animate dragX to W, then onBack()
|
| 16 |
+
* otherwise → animate dragX back to 0 (snap back)
|
| 17 |
*/
|
| 18 |
|
| 19 |
+
import { motion, animate, type MotionValue } from 'framer-motion';
|
| 20 |
+
import {
|
| 21 |
+
type ReactNode,
|
| 22 |
+
useRef,
|
| 23 |
+
useState,
|
| 24 |
+
type TouchEvent as ReactTouchEvent,
|
| 25 |
+
useEffect,
|
| 26 |
+
} from 'react';
|
| 27 |
|
| 28 |
+
const EDGE_ZONE_PX = 28;
|
| 29 |
+
const COMMIT_THRESHOLD_PX = 120;
|
| 30 |
+
const DRAG_MAX_PX = 320;
|
| 31 |
+
const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
| 32 |
+
const SNAP_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
|
|
|
|
|
|
| 33 |
|
| 34 |
interface ScreenProps {
|
| 35 |
children: ReactNode;
|
|
|
|
| 36 |
isTop: boolean;
|
| 37 |
+
isExiting: boolean;
|
| 38 |
+
xValue: MotionValue<number>;
|
| 39 |
+
overlayOpacity?: MotionValue<number>;
|
| 40 |
onBack: () => void;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
export function Screen({ children, isTop, isExiting, xValue, overlayOpacity, onBack }: ScreenProps) {
|
| 44 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 45 |
+
const touchState = useRef<{
|
| 46 |
+
startX: number;
|
| 47 |
+
startY: number;
|
| 48 |
+
active: boolean;
|
| 49 |
+
horizontal: boolean | null;
|
| 50 |
+
} | null>(null);
|
| 51 |
+
|
| 52 |
+
// Get window width (cached)
|
| 53 |
+
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375;
|
| 54 |
+
|
| 55 |
+
// ---- Touch handlers (only for the TOP, non-exiting page) ----
|
| 56 |
+
function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
|
| 57 |
+
if (!isTop || isExiting) return;
|
| 58 |
+
const t = e.touches[0];
|
| 59 |
+
if (!t) return;
|
| 60 |
+
|
| 61 |
+
// Only start tracking if touch is in the left edge zone
|
| 62 |
+
if (t.clientX > EDGE_ZONE_PX) {
|
| 63 |
+
touchState.current = null;
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
touchState.current = {
|
| 68 |
+
startX: t.clientX,
|
| 69 |
+
startY: t.clientY,
|
| 70 |
+
active: true,
|
| 71 |
+
horizontal: null,
|
| 72 |
+
};
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
|
| 76 |
+
if (!isTop || isExiting) return;
|
| 77 |
+
const state = touchState.current;
|
| 78 |
+
if (!state || !state.active) return;
|
| 79 |
+
|
| 80 |
+
const t = e.touches[0];
|
| 81 |
+
if (!t) return;
|
| 82 |
+
const dx = t.clientX - state.startX;
|
| 83 |
+
const dy = t.clientY - state.startY;
|
| 84 |
+
|
| 85 |
+
// Decide gesture direction on first significant move
|
| 86 |
+
if (state.horizontal === null) {
|
| 87 |
+
const absDx = Math.abs(dx);
|
| 88 |
+
const absDy = Math.abs(dy);
|
| 89 |
+
if (absDx < 8 && absDy < 8) return;
|
| 90 |
+
// Vertical-dominant → let the browser handle it (page scroll)
|
| 91 |
+
if (absDy > absDx) {
|
| 92 |
+
state.active = false;
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
state.horizontal = true;
|
| 96 |
+
setIsDragging(true);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if (!state.horizontal) return;
|
| 100 |
+
|
| 101 |
+
// Prevent vertical scroll while dragging horizontally
|
| 102 |
+
if (e.cancelable) e.preventDefault();
|
| 103 |
+
|
| 104 |
+
// Follow the finger, capped at DRAG_MAX_PX
|
| 105 |
+
const clamped = Math.max(0, Math.min(dx, DRAG_MAX_PX));
|
| 106 |
+
xValue.set(clamped);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function onTouchEnd() {
|
| 110 |
+
if (!isTop || isExiting) return;
|
| 111 |
+
const state = touchState.current;
|
| 112 |
+
touchState.current = null;
|
| 113 |
+
if (!state || !state.active || !state.horizontal) {
|
| 114 |
+
setIsDragging(false);
|
| 115 |
+
return;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const current = xValue.get();
|
| 119 |
+
if (current >= COMMIT_THRESHOLD_PX) {
|
| 120 |
+
// COMMIT: animate the page all the way off-screen, THEN navigate back
|
| 121 |
+
animate(xValue, windowWidth, {
|
| 122 |
+
duration: 0.3,
|
| 123 |
+
ease: SLIDE_EASE,
|
| 124 |
+
onComplete: () => {
|
| 125 |
+
onBack();
|
| 126 |
+
},
|
| 127 |
+
});
|
| 128 |
+
} else {
|
| 129 |
+
// CANCEL: snap back to 0
|
| 130 |
+
animate(xValue, 0, {
|
| 131 |
+
duration: 0.24,
|
| 132 |
+
ease: SNAP_EASE,
|
| 133 |
+
onComplete: () => setIsDragging(false),
|
| 134 |
+
});
|
| 135 |
}
|
| 136 |
+
}
|
| 137 |
|
| 138 |
+
// ---- Render ----
|
| 139 |
return (
|
| 140 |
<motion.div
|
| 141 |
style={{
|
| 142 |
position: 'absolute',
|
| 143 |
inset: 0,
|
| 144 |
+
x: xValue,
|
| 145 |
background: '#fff',
|
| 146 |
+
boxShadow: isTop && !isExiting ? '-10px 0 25px rgba(0,0,0,0.2)' : 'none',
|
| 147 |
+
zIndex: isExiting ? 2 : isTop ? 2 : 1,
|
|
|
|
| 148 |
}}
|
| 149 |
+
onTouchStart={onTouchStart}
|
| 150 |
+
onTouchMove={onTouchMove}
|
| 151 |
+
onTouchEnd={onTouchEnd}
|
| 152 |
+
onTouchCancel={onTouchEnd}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
>
|
| 154 |
+
{/* Page content — each screen has its own scroll container */}
|
| 155 |
+
<div
|
| 156 |
+
style={{
|
| 157 |
+
height: '100%',
|
| 158 |
+
overflowY: 'auto',
|
| 159 |
+
WebkitOverflowScrolling: 'touch',
|
| 160 |
+
background: '#fff',
|
| 161 |
+
}}
|
| 162 |
+
>
|
| 163 |
{children}
|
| 164 |
</div>
|
| 165 |
|
| 166 |
+
{/* Dimming overlay for the BEHIND page */}
|
| 167 |
+
{overlayOpacity && (
|
| 168 |
<motion.div
|
| 169 |
style={{
|
| 170 |
position: 'absolute',
|