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 CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "nextjs_tailwind_shadcn_ts",
3
- "version": "0.2.8",
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
- * Playable Skeleton Loader — engagement over waiting.
8
- *
9
- * Instead of a boring spinner, the user sees:
10
- * 1. Draggable "C" logo with 2D physics (bounces off edges)
11
- * 2. Playable skeleton cards they can swipe around
12
- * 3. Smooth spring animations everywhere
13
- *
14
- * Turns waiting into a moment of play.
 
 
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 ref={containerRef} className="fixed inset-0 z-[9999] bg-white flex flex-col items-center justify-center overflow-hidden">
23
- {/* Draggable C logo with physics */}
24
- <motion.div
25
- drag
26
- dragConstraints={containerRef}
27
- dragElastic={0.6}
28
- dragTransition={{ bounceStiffness: 400, bounceDamping: 15 }}
29
- style={{ x, y }}
30
- className="cursor-grab active:cursor-grabbing mb-10 relative"
31
- >
32
- {/* Outer rotating ring */}
33
- <motion.div
34
- animate={{ rotate: 360 }}
35
- transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
36
- className="w-20 h-20 rounded-full border-[3px] border-neutral-200"
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
- {/* Playable skeleton cards (draggable) */}
50
- <div className="flex gap-3 mb-8 px-8">
51
- {[0, 1, 2].map(i => (
52
- <motion.div
53
- key={i}
54
- drag
55
- dragConstraints={containerRef}
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
- </motion.div>
70
- ))}
71
- </div>
72
 
73
- {/* Bouncing dots */}
74
- <div className="flex gap-2 mb-3">
75
- {[0, 1, 2].map(i => (
76
- <motion.span
77
- key={i}
78
- animate={{ scale: [0.5, 1, 0.5], opacity: [0.3, 1, 0.3] }}
79
- transition={{ duration: 1.4, repeat: Infinity, delay: i * 0.16, ease: 'easeInOut' }}
80
- className="w-2.5 h-2.5 rounded-full bg-black"
81
- />
82
- ))}
83
  </div>
84
 
85
- <motion.p
86
- animate={{ opacity: [0.3, 0.7, 0.3] }}
87
- transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
88
- className="text-xs text-neutral-400 font-medium tracking-wide"
89
- >
90
- Loading
91
- </motion.p>
92
-
93
- <motion.p
94
- initial={{ opacity: 0 }}
95
- animate={{ opacity: 0.4 }}
96
- transition={{ delay: 1.5 }}
97
- className="absolute bottom-8 text-[10px] text-neutral-300"
98
- >
99
- Drag the logo or cards to play ↑
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. Keeps a history array of pages so the "previous" page stays alive
8
- * in the DOM (unlike default Next.js which unmounts it).
9
- * 2. A shared MotionValue (`activeDragX`) connects the drag of the top
10
- * page to the parallax movement of the page behind it.
11
- * 3. Only the top 2 pages are rendered at any time (memory optimization).
 
 
 
12
  *
13
- * Bug fixes vs. the original spec:
14
- * - Removed `useSearchParams` from the dependency array — it would cause
15
- * the effect to fire on every render (since `children` changes each time)
16
- * and also requires a Suspense boundary which breaks static export.
17
- * We now key the stack on `pathname` only; search-param changes update
18
- * the top page in-place (no stack push).
19
- * - Added a "same path" check so re-renders that don't change the pathname
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, AnimatePresence } from 'framer-motion';
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 isReady = useRef(false);
 
42
 
43
- // 2. Shared Physics State
44
- // This value tracks the drag position of the TOP page.
45
- // We pass it down so the page BEHIND can "watch" the drag and move in parallax.
46
- const activeDragX = useMotionValue(0);
47
 
48
- // 3. Navigation detection — only fires on pathname change, NOT on every children update
49
- useEffect(() => {
50
- const fullPath = pathname;
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  setStack((prev) => {
53
- // First render — just add the initial page
54
  if (prev.length === 0) {
55
  isBack.current = false;
56
- return [{ key: fullPath, component: children }];
57
  }
58
 
59
- // Back detection: if the new path matches the item below the top, it's a "Back" action
60
- if (prev.length > 1 && prev[prev.length - 2].key === fullPath) {
61
  isBack.current = true;
62
- return prev.slice(0, -1); // remove the top item
 
 
 
63
  }
64
 
65
- // Same path (e.g. search params changed) — just update the top item's component
66
- if (prev[prev.length - 1].key === fullPath) {
67
  isBack.current = false;
68
- return [...prev.slice(0, -1), { key: fullPath, component: children }];
69
  }
70
 
71
- // Otherwise, it's a "Push" action
72
  isBack.current = false;
73
- return [...prev, { key: fullPath, component: children }];
 
 
74
  });
75
 
76
- // Reset drag value on navigation
77
- activeDragX.set(0);
78
- isReady.current = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  // eslint-disable-next-line react-hooks/exhaustive-deps
80
  }, [pathname]);
81
 
82
- // Update the top item's component whenever children changes (without pushing)
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
- const top = prev[prev.length - 1];
88
- if (top.key === pathname) {
89
- return [...prev.slice(0, -1), { key: top.key, component: children }];
 
 
 
 
 
 
 
90
  }
91
  return prev;
92
  });
@@ -97,11 +152,20 @@ export function IOSStack({ children }: { children: React.ReactNode }) {
97
  updateTopComponent();
98
  }, [children, updateTopComponent]);
99
 
100
- // Hydration safety — don't render anything until the first effect runs
 
 
 
 
 
 
 
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
- <AnimatePresence initial={false} custom={isBack.current}>
115
- {stack.map((item, index) => {
116
- const isTop = index === stack.length - 1;
117
- const isBehind = index === stack.length - 2;
118
-
119
- // Optimization: Only render the top 2 pages to save memory
120
- if (!isTop && !isBehind) return null;
121
-
122
- return (
123
- <Screen
124
- key={item.key}
125
- index={index}
126
- isTop={isTop}
127
- isBack={isBack.current}
128
- dragX={activeDragX}
129
- onBack={() => router.back()}
130
- >
131
- {item.component}
132
- </Screen>
133
- );
134
- })}
135
- </AnimatePresence>
 
 
 
 
 
 
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, MessageCircle, Compass, ShoppingCart, User } from 'lucide-react';
6
  import { useAuth } from '@/components/auth-provider';
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  const navItems = [
9
  { href: '/', label: 'Home', icon: Home },
10
- { href: '/ai-chat', label: 'Chat', icon: MessageCircle },
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 className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-slate-200 md:hidden">
 
 
 
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 represents one page in the navigation stack.
 
 
 
 
7
  *
8
- * - The TOP page is draggable (swipe right to go back).
9
- * - The BEHIND page follows the top page's drag in parallax (0 → -30%).
10
- * - A dark overlay dims the behind page; it fades as the top page is dragged away.
11
- * - Uses framer-motion spring physics tuned to feel like iOS.
 
12
  */
13
 
14
- import { motion, useTransform, type MotionValue, type PanInfo } from 'framer-motion';
15
- import { type ReactNode, useMemo } from 'react';
 
 
 
 
 
 
16
 
17
- // Tuned Physics: High stiffness/damping for that "Apple" snap
18
- const IOS_SPRING = {
19
- type: 'spring' as const,
20
- stiffness: 350,
21
- damping: 40,
22
- mass: 1,
23
- };
24
 
25
  interface ScreenProps {
26
  children: ReactNode;
27
- index: number;
28
  isTop: boolean;
29
- isBack: boolean;
30
- dragX: MotionValue<number>;
 
31
  onBack: () => void;
32
  }
33
 
34
- export function Screen({ children, index, isTop, isBack, dragX, onBack }: ScreenProps) {
35
- // Calculate window width once (client-side only — this component is 'use client')
36
- const windowWidth = useMemo(() => {
37
- if (typeof window === 'undefined') return 375;
38
- return window.innerWidth;
39
- }, []);
40
-
41
- // -- PARALLAX LOGIC --
42
- // If we are the page BEHIND, we map the Top Page's drag (0px -> ScreenWidth)
43
- // to our parallax movement (-30% -> 0%).
44
- const parallaxX = useTransform(dragX, [0, windowWidth], [-windowWidth * 0.3, 0]);
45
-
46
- // We also fade the black dimming overlay as the top page is dragged away
47
- const overlayOpacity = useTransform(dragX, [0, windowWidth], [0.3, 0]);
48
-
49
- // -- ANIMATION VARIANTS --
50
- const variants = {
51
- initial: (back: boolean) => ({
52
- // If pushing: enter from right (100%)
53
- // If popping: start at parallax position (-30%)
54
- x: back ? '-30%' : '100%',
55
- zIndex: index,
56
- }),
57
- animate: {
58
- x: 0,
59
- zIndex: index,
60
- transition: IOS_SPRING,
61
- },
62
- exit: (back: boolean) => ({
63
- // If pushing: move to parallax position (-30%)
64
- // If popping: slide out to right (100%)
65
- x: back ? '100%' : '-30%',
66
- zIndex: index,
67
- transition: IOS_SPRING,
68
- }),
69
- };
70
-
71
- const handleDragEnd = (_: unknown, info: PanInfo) => {
72
- const velocity = info.velocity.x;
73
- const offset = info.offset.x;
74
- const threshold = windowWidth * 0.35;
75
-
76
- // Trigger 'Back' if dragged 35% of screen OR flicked fast
77
- if (offset > threshold || velocity > 400) {
78
- onBack();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // CRITICAL: If Top, use the raw drag value. If Behind, use the calculated parallax.
90
- x: isTop ? dragX : parallaxX,
91
  }}
92
- initial="initial"
93
- animate="animate"
94
- exit="exit"
95
- variants={variants}
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 Content — each screen has its own scroll container */}
104
- <div style={{ height: '100%', overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
 
 
 
 
 
 
 
105
  {children}
106
  </div>
107
 
108
- {/* Dimming Overlay (Only for the page sitting behind) */}
109
- {!isTop && (
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',