From 8c02322ed10f26de4684b67717b9c69a3790b476 Mon Sep 17 00:00:00 2001 From: saman Date: Wed, 6 May 2026 12:43:50 -0700 Subject: [PATCH 1/6] feat: update landing page and web home header --- frontend/app/(tabs)/_layout.tsx | 159 +----- frontend/app/(tabs)/home.tsx | 471 +++++++++++------- frontend/app/_layout.tsx | 1 + frontend/app/landing.tsx | 5 + frontend/assets/images/PolyBuysLogo.png | Bin 0 -> 18312 bytes .../assets/images/landing-calculus-book.jpg | Bin 0 -> 42479 bytes .../assets/images/landing-kallax-shelf.jpg | Bin 0 -> 43994 bytes frontend/components/AppNav.tsx | 8 + frontend/components/AppNav.web.tsx | 371 ++++++++++++++ frontend/components/CategoryRail.tsx | 351 +++++++++++++ frontend/components/FilterBar.tsx | 47 +- frontend/components/HomeBg.tsx | 8 + frontend/components/HomeBg.web.tsx | 316 ++++++++++++ frontend/components/LandingScreen.web.tsx | 11 +- frontend/components/ListingCard.tsx | 457 +++++++++++------ frontend/components/landing/Brand.tsx | 17 +- frontend/components/landing/Hero.tsx | 8 +- frontend/components/landing/ListingCard.tsx | 13 +- frontend/components/landing/Nav.tsx | 21 +- frontend/components/landing/assetSource.ts | 14 + frontend/components/landing/data.ts | 22 +- frontend/components/landing/productImages.ts | 8 + .../landing/scrollToLandingSection.ts | 57 +++ frontend/components/landing/styles.ts | 226 +++++++-- frontend/contexts/SearchContext.tsx | 44 ++ frontend/tsconfig.json | 2 +- frontend/types/assets.d.ts | 6 + tsconfig.json | 17 +- 28 files changed, 2099 insertions(+), 561 deletions(-) create mode 100644 frontend/app/landing.tsx create mode 100644 frontend/assets/images/PolyBuysLogo.png create mode 100644 frontend/assets/images/landing-calculus-book.jpg create mode 100644 frontend/assets/images/landing-kallax-shelf.jpg create mode 100644 frontend/components/AppNav.tsx create mode 100644 frontend/components/AppNav.web.tsx create mode 100644 frontend/components/CategoryRail.tsx create mode 100644 frontend/components/HomeBg.tsx create mode 100644 frontend/components/HomeBg.web.tsx create mode 100644 frontend/components/landing/assetSource.ts create mode 100644 frontend/components/landing/productImages.ts create mode 100644 frontend/components/landing/scrollToLandingSection.ts create mode 100644 frontend/contexts/SearchContext.tsx diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index b14fd518..09e9ddc5 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -1,97 +1,27 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Link, Slot, useLocalSearchParams, usePathname, useRouter } from 'expo-router'; +import { useEffect } from 'react'; +import { Slot, usePathname, useRouter } from 'expo-router'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; -import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { TextInput } from 'react-native'; +import { AppNavContainer } from '../../components/AppNav'; import { useAuth } from '../../hooks/useAuth'; import { getSignedOutFallback, getVisibleNativeTabs } from '../../lib/navigation/guestAccess'; -import { borderRadius, colors, spacing, typography } from '../../theme/tokens'; +import { SearchProvider } from '../../contexts/SearchContext'; +import { colors } from '../../theme/tokens'; import { nativeChrome } from '../../theme/nativeChrome'; -function WebHeaderLayout() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useLocalSearchParams() as Record; - const q = searchParams.q; - const currentQuery = useMemo(() => { - if (Array.isArray(q)) { - return q[0] ?? ''; - } - return q ?? ''; - }, [q]); - const [searchInput, setSearchInput] = useState(currentQuery); - const searchActive = - pathname === '/home' - ? searchInput.trim().length > 0 - : pathname === '/search' || pathname.startsWith('/search/'); - const searchControlStyle = StyleSheet.flatten([ - styles.webSearchControl, - searchActive && styles.webSearchControlActive, - ]); - const mergedParams = useMemo(() => { - const nextParams: Record = {}; - - for (const [key, value] of Object.entries(searchParams)) { - if (key === 'q' || value === undefined) { - continue; - } - nextParams[key] = value; - } - - return nextParams; - }, [searchParams]); - - useEffect(() => { - setSearchInput(currentQuery); - }, [currentQuery]); - - useEffect(() => { - const timeout = setTimeout(() => { - const trimmed = searchInput.trim(); - if (trimmed === currentQuery) { - return; - } - - router.replace({ - pathname: '/home' as never, - params: trimmed.length > 0 ? { ...mergedParams, q: trimmed } : mergedParams, - }); - }, 250); - - return () => clearTimeout(timeout); - }, [currentQuery, mergedParams, router, searchInput]); - +function WebLayout() { return ( - - - - - - PolyBuys - - - - - + + + + + - - + ); } @@ -123,7 +53,7 @@ export default function TabsLayout() { }, [isAuthenticated, isSessionLoading, isWeb, pathname, router]); if (isWeb) { - return ; + return ; } const tabTint = nativeChrome.tabIconSelectedColor; @@ -243,62 +173,13 @@ const styles = StyleSheet.create({ }, webRoot: { flex: 1, - backgroundColor: colors.surface, - }, - webHeaderBorder: { - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - backgroundColor: colors.surface, - }, - webHeaderContent: { - width: '100%', - maxWidth: 1240, - alignSelf: 'center', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.md, - borderRadius: borderRadius.lg, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, - backgroundColor: colors.white, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - }, - brand: { - ...typography.title1, - color: colors.textDark, - fontSize: 28, - lineHeight: 34, + flexDirection: 'column', + backgroundColor: colors.background, + overflow: 'hidden', }, - webSearchControl: { - minWidth: 280, - maxWidth: 360, - width: '100%', - minHeight: 44, - borderRadius: borderRadius.full, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, - backgroundColor: colors.surface, - paddingHorizontal: spacing.md, - justifyContent: 'center', - }, - webSearchControlActive: { - backgroundColor: colors.white, - borderColor: colors.primary, - }, - webSearchInput: { - ...typography.subhead, - width: '100%', - paddingHorizontal: 0, - paddingVertical: 0, - borderWidth: 0, - backgroundColor: 'transparent', - fontWeight: '600', - color: colors.textDark, - outlineWidth: 0, - outlineColor: 'transparent', - boxShadow: 'none', + webContent: { + flex: 1, + minHeight: 0, }, nativeTabLabelDefault: { fontSize: 11, diff --git a/frontend/app/(tabs)/home.tsx b/frontend/app/(tabs)/home.tsx index 122f1cbc..9d2d21b9 100644 --- a/frontend/app/(tabs)/home.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -14,11 +14,13 @@ import { } from 'react-native'; import { useMutation, useQuery } from 'convex/react'; import { api } from 'convex/_generated/api'; +import Head from 'expo-router/head'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { FilterBar } from '../../components/FilterBar'; +import { CategoryRail } from '../../components/CategoryRail'; import { CategoryPicker } from '../../components/CategoryPicker'; +import { HomeBg } from '../../components/HomeBg'; import OpenInAppPrompt from '../../components/OpenInAppPrompt'; import { PriceRangePicker } from '../../components/PriceRangePicker'; import { SortPicker } from '../../components/SortPicker'; @@ -26,14 +28,25 @@ import ListingCard from '../../components/ListingCard'; import { ScreenState } from '../../components/ScreenState'; import { ScreenHeader } from '../../components/ui'; import type { Filters, Category, ListingSortBy } from '../../types/filters'; +import { LISTING_SORT_SHORT } from '../../types/filters'; import { useAuth } from '../../hooks/useAuth'; +import { useSearch } from '../../contexts/SearchContext'; import type { Doc, Id } from 'convex/_generated/dataModel'; import { useEntranceAnimation } from '../../hooks/useEntranceAnimation'; import { getUserFlowErrorMessage } from '../../lib/user-flow-errors'; +import { formatPrice } from '../../lib/formatPrice'; import { borderRadius, colors, spacing, typography } from '../../theme/tokens'; const PAGE_SIZE = 20; const FOCUS_REFETCH_STALE_MS = 45_000; +const HOMEPAGE_FONT_HREF = + 'https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,300..900,0..100&family=Inter:wght@400;500;600;700&display=swap'; +const landingTextFont = + Platform.OS === 'web' + ? { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' } + : undefined; +const landingDisplayFont = + Platform.OS === 'web' ? { fontFamily: 'Fraunces, Georgia, serif' } : undefined; type WebHandoffPrompt = { key: 'create-listing' | 'save-listing'; @@ -43,6 +56,20 @@ type WebHandoffPrompt = { buttonLabel: string; }; +function HomeFooter({ isCompact }: { isCompact: boolean }) { + return ( + + + + PolyBuys + + + Built for campus buying and selling. + + + ); +} + function buildWebRows(items: Doc<'listings'>[], size: number) { const rows: Array<{ key: string; @@ -71,12 +98,19 @@ export default function HomeScreen() { const insets = useSafeAreaInsets(); const { isAuthenticated, isSessionLoading } = useAuth(); const { q } = useLocalSearchParams<{ q?: string | string[] }>(); + const { searchQuery: contextSearchQuery, clearSearch } = useSearch(); const { width } = useWindowDimensions(); const entranceStyle = useEntranceAnimation(); const isWeb = Platform.OS === 'web'; const isDesktopWeb = isWeb && width >= 1024; const topSafeSpace = Platform.OS === 'ios' ? Math.max(insets.top - 6, 10) : 0; + // On web: use the shared context search query (instant, no URL round-trip). + // On native: fall back to URL param q (native doesn't use SearchContext). + const searchQuery = isWeb + ? contextSearchQuery.trim() + : (Array.isArray(q) ? (q[0] ?? '') : (q ?? '')).trim(); + const [filters, setFilters] = useState({}); const [sortBy, setSortBy] = useState('newest'); const [showCategoryPicker, setShowCategoryPicker] = useState(false); @@ -99,7 +133,6 @@ export default function HomeScreen() { const lastFirstPageAtRef = useRef(0); const hasLoadedOnceRef = useRef(false); - const searchQuery = Array.isArray(q) ? (q[0] ?? '').trim() : (q ?? '').trim(); const hasSearchQuery = isWeb && searchQuery.length > 0; const activeFilterKey = `${filters.category ?? ''}|${filters.minPrice ?? ''}|${filters.maxPrice ?? ''}|${searchQuery}|${sortBy}`; @@ -249,10 +282,6 @@ export default function HomeScreen() { setFilters((prev) => ({ ...prev, minPrice, maxPrice })); }; - const handleClearCategory = () => { - setFilters((prev) => ({ ...prev, category: undefined })); - }; - const handleClearPrice = () => { setFilters((prev) => ({ ...prev, minPrice: undefined, maxPrice: undefined })); }; @@ -260,7 +289,7 @@ export default function HomeScreen() { const handleClearAll = () => { setFilters({}); setSortBy('newest'); - router.setParams({ q: undefined }); + clearSearch(); }; const handleLoadMore = useCallback(() => { @@ -304,16 +333,26 @@ export default function HomeScreen() { router.replace('/auth/login?returnTo=%2Fhome' as never); }, [router]); + const getPriceLabel = () => { + if (filters.minPrice !== undefined && filters.maxPrice !== undefined) { + return `${formatPrice(filters.minPrice)} – ${formatPrice(filters.maxPrice)}`; + } + if (filters.minPrice !== undefined) return `${formatPrice(filters.minPrice)}+`; + if (filters.maxPrice !== undefined) return `Under ${formatPrice(filters.maxPrice)}`; + return 'Price'; + }; + const isCompactLayout = width < 760; - const webScrollHorizontalPadding = isDesktopWeb ? spacing.xl : width >= 900 ? spacing.lg : 10; + const webScrollHorizontalPadding = + width >= 1280 ? spacing.xl : width >= 900 ? spacing.lg : width >= 480 ? spacing.lg : spacing.md; /** Native: same horizontal inset as My Listings (header + FilterBar). */ const nativeHeaderHorizontalPadding = width >= 900 ? spacing.xxl : isCompactLayout ? spacing.md : spacing.lg; /** Native: original Home card gutters (tighter than header). */ const nativeListHorizontalPadding = width >= 900 ? spacing.xxl : isCompactLayout ? spacing.xs : spacing.sm; - const contentMaxWidth = isWeb ? 1240 : 1120; - const homeColumns = isWeb ? (width >= 1280 ? 3 : width >= 900 ? 2 : 1) : 2; + const contentMaxWidth = isWeb ? 1280 : 1120; + const homeColumns = isWeb ? (width >= 1280 ? 4 : width >= 920 ? 3 : width >= 680 ? 2 : 1) : 2; const listEmptyComponent = listings.length === 0 ? ( @@ -361,153 +400,160 @@ export default function HomeScreen() { ) : null; - const listFooterComponent = isLoadingMore ? ( + const loadingFooterComponent = isLoadingMore ? ( - Loading more... + Loading more... ) : null; const webRows = buildWebRows(listings, homeColumns); if (isWeb) { return ( - - setShowCategoryPicker(false)} - /> - - setShowPricePicker(false)} - /> + <> + + + + + + + + {/* Layered homepage background. */} + + setShowCategoryPicker(false)} + /> - setShowSortPicker(false)} - /> + setShowPricePicker(false)} + /> - - {topSafeSpace > 0 && } - - - - - {hasSearchQuery ? `Results for "${searchQuery}"` : 'Latest listings'} - - - {hasSearchQuery - ? 'Use filters to narrow the results further.' - : 'Fresh campus listings from the PolyBuys marketplace.'} - - - [ - styles.createChip, - styles.webCreateChip, - pressed && styles.createChipPressed, - ]} - onPress={handleCreateListing} - disabled={isSessionLoading} - accessibilityLabel={ - isWeb ? 'Download the Mobile App to Create Listings' : 'Create listing' - } - accessibilityRole="button" - > - - {isWeb - ? 'Download the Mobile App to Create Listings' - : isAuthenticated - ? '+ Create listing' - : 'Sign in to sell'} - - - - - setShowCategoryPicker(true)} - onPricePress={() => setShowPricePicker(true)} - onSortPress={() => setShowSortPicker(true)} - onClearCategory={handleClearCategory} - onClearPrice={handleClearPrice} - onClearAll={handleClearAll} + onSelect={setSortBy} + onClose={() => setShowSortPicker(false)} /> - {webHandoffPrompt ? ( - setWebHandoffPrompt(null)} - cardStyle={styles.webHandoffCard} - /> - ) : null} + + {topSafeSpace > 0 && } + + + + + + {hasSearchQuery ? `Results for "${searchQuery}"` : 'Latest listings'} + + + {hasSearchQuery + ? 'Use filters to narrow the results further.' + : 'Fresh campus listings from the PolyBuys marketplace.'} + + + - {listings.length === 0 ? ( - listEmptyComponent - ) : ( - <> - - {webRows.map((row) => ( - - {row.items.map((item, columnOffset) => ( - - - - ))} - {row.fillerKeys.map((fillerKey) => ( - - ))} + setShowPricePicker(true)} + onClearPrice={handleClearPrice} + sortLabel={LISTING_SORT_SHORT[sortBy]} + hasNonDefaultSort={sortBy !== 'newest'} + onSortPress={() => setShowSortPicker(true)} + hasAnyFilter={hasActiveFilters || sortBy !== 'newest'} + onClearAll={handleClearAll} + /> + + + {webHandoffPrompt ? ( + setWebHandoffPrompt(null)} + cardStyle={styles.webHandoffCard} + /> + ) : null} + + {listings.length === 0 ? ( + listEmptyComponent + ) : ( + <> + + {webRows.map((row) => ( + + {row.items.map((item, columnOffset) => ( + + + + ))} + {row.fillerKeys.map((fillerKey) => ( + + ))} + + ))} + + {loadingFooterComponent} + {!isDone && !isLoadingMore ? ( + [ + styles.webLoadMoreButton, + pressed && styles.createChipPressed, + ]} + onPress={handleLoadMore} + accessibilityLabel="Load more listings" + accessibilityRole="button" + > + + Load more listings + + + ) : null} + {isDone && listings.length > 0 ? ( + + + + {"You're all caught up"} + + - ))} - - {listFooterComponent} - {!isDone && !isLoadingMore ? ( - [ - styles.webLoadMoreButton, - pressed && styles.createChipPressed, - ]} - onPress={handleLoadMore} - accessibilityLabel="Load more listings" - accessibilityRole="button" - > - Load more listings - - ) : null} - - )} - - + ) : null} + + )} + + + + + ); } @@ -543,14 +589,18 @@ export default function HomeScreen() { } /> - setShowCategoryPicker(true)} + setShowPricePicker(true)} - onSortPress={() => setShowSortPicker(true)} - onClearCategory={handleClearCategory} onClearPrice={handleClearPrice} + sortLabel={LISTING_SORT_SHORT[sortBy]} + hasNonDefaultSort={sortBy !== 'newest'} + onSortPress={() => setShowSortPicker(true)} + hasAnyFilter={hasActiveFilters || sortBy !== 'newest'} onClearAll={handleClearAll} /> @@ -615,7 +665,12 @@ export default function HomeScreen() { /> } ListEmptyComponent={listEmptyComponent} - ListFooterComponent={listFooterComponent} + ListFooterComponent={ + <> + {loadingFooterComponent} + + + } /> @@ -625,13 +680,14 @@ export default function HomeScreen() { const styles = StyleSheet.create({ page: { flex: 1, - backgroundColor: colors.background, + backgroundColor: '#F7F5EF', }, pageMobile: { backgroundColor: colors.surface, }, pageWeb: { minHeight: '100%', + position: 'relative', }, content: { flex: 1, @@ -646,32 +702,40 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, webScrollContent: { - paddingTop: spacing.lg, + paddingTop: spacing.sm, gap: spacing.lg, }, + webHeaderSection: { + gap: spacing.xs, + paddingBottom: 0, + }, webToolbarRow: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', gap: spacing.lg, + paddingBottom: spacing.xs, }, webToolbarCopy: { flex: 1, minWidth: 0, - gap: spacing.xs, + gap: 2, }, webToolbarTitle: { ...typography.title1, color: colors.textDark, - fontSize: 24, - lineHeight: 30, + fontSize: 26, + lineHeight: 32, + fontWeight: '500', + letterSpacing: 0, }, webToolbarBody: { ...typography.subhead, - color: colors.text, + color: colors.muted, + fontSize: 13, }, webGrid: { - gap: spacing.lg, + gap: spacing.md, }, webHandoffCard: { maxWidth: '100%', @@ -683,25 +747,21 @@ const styles = StyleSheet.create({ webLoadMoreButton: { alignSelf: 'center', minHeight: 44, - borderRadius: borderRadius.md, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - paddingHorizontal: spacing.xl, + borderRadius: borderRadius.full, + borderWidth: 1.5, + borderColor: 'rgba(21, 71, 52, 0.18)', + backgroundColor: colors.white, + paddingHorizontal: spacing.xxl, justifyContent: 'center', alignItems: 'center', - }, + boxShadow: '0 1px 4px rgba(21, 71, 52, 0.06)', + } as never, webLoadMoreButtonText: { ...typography.subhead, color: colors.primary, fontWeight: '600', - }, - webCreateChip: { - minWidth: 280, - alignItems: 'center', - }, - webCreateChipText: { - textAlign: 'center', + fontSize: 14, + letterSpacing: 0, }, homeTopBlock: { gap: spacing.md, @@ -725,10 +785,10 @@ const styles = StyleSheet.create({ }, columnWrapper: { flexDirection: 'row', - gap: spacing.sm, + gap: spacing.md, }, columnWrapperCompact: { - gap: spacing.xs, + gap: spacing.sm, }, stateContainer: { flex: 1, @@ -762,5 +822,72 @@ const styles = StyleSheet.create({ marginTop: 8, fontSize: 14, color: colors.text, + letterSpacing: 0, + }, + // ── "All caught up" end-of-feed indicator ── + allCaughtUp: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.xl, + paddingHorizontal: spacing.lg, + }, + allCaughtUpLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(21, 71, 52, 0.10)', + }, + allCaughtUpText: { + ...typography.footnote, + color: colors.muted, + fontSize: 12, + fontWeight: '500', + letterSpacing: 0, + flexShrink: 0, + }, + homeFooter: { + marginTop: spacing.md, + paddingTop: spacing.lg, + paddingBottom: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(21, 71, 52, 0.12)', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + }, + homeFooterCompact: { + alignItems: 'flex-start', + flexDirection: 'column', + gap: 6, + }, + homeFooterBrandRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 0, + }, + homeFooterDot: { + width: 7, + height: 7, + borderRadius: 4, + backgroundColor: colors.accent, + boxShadow: '0 0 0 3px rgba(226, 168, 74, 0.18)', + } as never, + homeFooterBrand: { + ...typography.footnoteMed, + color: colors.primary, + fontSize: 13, + lineHeight: 18, + fontWeight: '700', + letterSpacing: 0, + }, + homeFooterCopy: { + ...typography.footnote, + color: colors.muted, + fontSize: 13, + lineHeight: 18, + letterSpacing: 0, + flexShrink: 1, }, }); diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index aeb0ff20..9242fd53 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -138,6 +138,7 @@ function RootLayout() { /> + diff --git a/frontend/app/landing.tsx b/frontend/app/landing.tsx new file mode 100644 index 00000000..68b964dd --- /dev/null +++ b/frontend/app/landing.tsx @@ -0,0 +1,5 @@ +import LandingScreen from '../components/LandingScreen'; + +export default function LandingPreviewRoute() { + return ; +} diff --git a/frontend/assets/images/PolyBuysLogo.png b/frontend/assets/images/PolyBuysLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..5b6a86ab24c98218f66d1821f0490c9bf9c16a95 GIT binary patch literal 18312 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 FV1iGoO3wfQ literal 0 HcmV?d00001 diff --git a/frontend/assets/images/landing-calculus-book.jpg b/frontend/assets/images/landing-calculus-book.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c893c678791e16a377810ac516b14d4406d761af GIT binary patch literal 42479 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 literal 0 HcmV?d00001 diff --git a/frontend/components/AppNav.tsx b/frontend/components/AppNav.tsx new file mode 100644 index 00000000..ac980de3 --- /dev/null +++ b/frontend/components/AppNav.tsx @@ -0,0 +1,8 @@ +/** + * AppNav.tsx — native stub. + * On iOS/Android the tab bar is handled by NativeTabs in (tabs)/_layout.tsx. + * This file exists so the import resolves on all platforms. + */ +export function AppNavContainer() { + return null; +} diff --git a/frontend/components/AppNav.web.tsx b/frontend/components/AppNav.web.tsx new file mode 100644 index 00000000..96720b9f --- /dev/null +++ b/frontend/components/AppNav.web.tsx @@ -0,0 +1,371 @@ +/** + * AppNav.web.tsx — web-only app navbar for /home. + * + * Rendered by (tabs)/_layout.tsx → WebLayout → . + * The outer shell uses React Native View + StyleSheet so it participates + * correctly in the RN flex column layout (navbar above, Slot below). + * Inner elements use real HTML for CSS hover/focus/transition states. + * + * Layout: LEFT (logo) | CENTER (search) | RIGHT (Download app) + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; +import { useSearch } from '../contexts/SearchContext'; +import polybuysLogo from '../assets/images/PolyBuysLogo.png'; +import { AppleIcon } from './landing/AppleIcon'; +import { toWebImageSrc } from './landing/assetSource'; + +const DOWNLOAD_APP_LABEL = 'Download the app'; + +// ─── CSS for inner HTML elements ───────────────────────────────────────────── +// Plain string constant — avoids react-native/no-raw-text ESLint crash. +// Only covers elements that need CSS hover/focus/transition — the outer +// shell dimensions and background come from React Native StyleSheet below. + +const NAV_CSS = + /* reset */ + '.pbn*,.pbn*::before,.pbn*::after{box-sizing:border-box}' + + '.pbn a{text-decoration:none;color:inherit}' + + /* reset form controls inside the web nav */ + '.pbn button{font-family:inherit;cursor:pointer;border:none;padding:0;background:none}' + + '.pbn input{font-family:inherit;outline:none}' + + /* ── inner row: 3-column grid ── */ + '.pbn__row{' + + 'max-width:1280px;margin:0 auto;' + + 'padding:0 28px;height:72px;' + + 'display:grid;' + + 'grid-template-columns:auto 1fr auto;' + + 'align-items:center;gap:20px;' + + 'font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' + + 'color:#14130F;' + + '-webkit-font-smoothing:antialiased' + + '}' + + /* ── LEFT: brand ── */ + '.pbn__brand{' + + 'display:inline-flex;align-items:center;' + + 'flex-shrink:0;text-decoration:none;cursor:pointer;' + + 'transition:opacity 150ms ease' + + '}' + + '.pbn__brand:hover{opacity:0.78}' + + '.pbn__brand:focus-visible{outline:3px solid rgba(226,168,74,0.65);outline-offset:4px;border-radius:8px}' + + '.pbn__logo{display:block;width:148px;height:auto;aspect-ratio:716/158;object-fit:contain}' + + /* ── CENTER: search — fully opaque white, strong border ── */ + '.pbn__search{' + + 'position:relative;display:flex;align-items:center;' + + 'max-width:520px;width:100%;justify-self:center' + + '}' + + '.pbn__search-icon{' + + 'position:absolute;left:16px;top:50%;transform:translateY(-50%);' + + 'pointer-events:none;color:#6EA48E;display:flex;align-items:center;' + + 'transition:color 180ms ease' + + '}' + + '.pbn__search:focus-within .pbn__search-icon{color:#154734}' + + '.pbn__search-input{' + + 'width:100%;height:46px;padding:0 44px 0 46px;' + + 'border-radius:999px;' + + /* Fully opaque white — clearly distinct from the cream navbar */ + 'border:1.5px solid rgba(21,71,52,0.22);' + + 'background:#FFFFFF;' + + 'font-size:14px;font-weight:500;color:#14130F;letter-spacing:0;' + + 'box-shadow:0 1px 3px rgba(21,71,52,0.08) inset;' + + 'transition:border-color 180ms ease,box-shadow 180ms ease;' + + '-webkit-appearance:none;appearance:none' + + '}' + + '.pbn__search-input::placeholder{color:#7A7A74;font-weight:400}' + + '.pbn__search-input:hover{border-color:rgba(21,71,52,0.36)}' + + '.pbn__search-input:focus{' + + 'border-color:#154734;' + + 'box-shadow:0 0 0 3px rgba(21,71,52,0.12),0 1px 3px rgba(21,71,52,0.08) inset' + + '}' + + '.pbn__search-input::-webkit-search-decoration,' + + '.pbn__search-input::-webkit-search-cancel-button{display:none}' + + '.pbn__search-clear{' + + 'position:absolute;right:13px;top:50%;transform:translateY(-50%);' + + 'width:24px;height:24px;border-radius:999px;' + + 'background:rgba(21,71,52,0.12);' + + 'display:flex;align-items:center;justify-content:center;' + + 'color:#154734;cursor:pointer;' + + 'transition:background 140ms ease,color 140ms ease,transform 140ms ease' + + '}' + + '.pbn__search-clear:hover{background:#154734;color:#fff;transform:translateY(-50%) scale(1.10)}' + + /* ── RIGHT: actions ── */ + '.pbn__actions{display:flex;align-items:center;justify-self:end;flex-shrink:0}' + + '.pbn__download{' + + 'appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:10px;' + + 'height:36px;padding:0 16px;border-radius:999px;border:0;' + + 'background:#154734;color:#FFF8E8;' + + 'font-size:14px;font-weight:600;letter-spacing:0;line-height:1;white-space:nowrap;cursor:pointer;' + + 'transition:transform 200ms cubic-bezier(0.2,0.7,0.2,1),background 200ms cubic-bezier(0.2,0.7,0.2,1),color 200ms cubic-bezier(0.2,0.7,0.2,1),border-color 200ms cubic-bezier(0.2,0.7,0.2,1)' + + '}' + + '.pbn__download .pb-apple-icon{display:inline-block;vertical-align:-2px;margin-right:2px;flex-shrink:0}' + + '.pbn__download:hover:not(:active){background:#1E5C44;transform:translateY(-1px)}' + + '.pbn__download:active{transform:scale(0.98)}' + + '.pbn__download:focus-visible{outline:3px solid rgba(226,168,74,0.55);outline-offset:3px}' + + /* ── compact row (mobile < 640px) ── */ + '.pbn__compact{display:none;padding:0 16px 12px;align-items:center}' + + '.pbn__search--full{max-width:none;flex:1}' + + /* ── responsive ── */ + '@media(max-width:479px){' + + '.pbn__row{height:56px;padding:0 12px;gap:8px;grid-template-columns:auto auto;justify-content:space-between}' + + '.pbn__search{display:none}' + + '.pbn__compact{display:flex}' + + '.pbn__logo{width:106px}' + + '.pbn__download{height:34px;padding:0 11px;font-size:13px;gap:7px}' + + '.pbn__download .pb-apple-icon{width:11px;height:auto;margin-right:0}' + + '}' + + '@media(min-width:480px) and (max-width:639px){' + + '.pbn__row{height:60px;padding:0 16px;gap:12px;grid-template-columns:auto auto;justify-content:space-between}' + + '.pbn__search{display:none}' + + '.pbn__compact{display:flex}' + + '.pbn__logo{width:124px}' + + '}' + + '@media(min-width:640px) and (max-width:899px){' + + '.pbn__row{padding:0 20px;gap:14px}' + + '.pbn__search{max-width:260px}' + + '.pbn__download{padding:0 14px}' + + '}' + + /* ── reduced motion ── */ + '@media(prefers-reduced-motion:reduce){' + + '.pbn__brand,.pbn__search-input,.pbn__download,.pbn__search-clear{transition:none!important}' + + '}'; + +// ─── SVG icons ──────────────────────────────────────────────────────────────── + +function SearchSvg() { + return ( + + + + + ); +} + +function CloseSvg() { + return ( + + + + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface AppNavProps { + onSearch: (query: string) => void; + searchValue: string; + onSearchChange: (value: string) => void; + onHomePress: () => void; + onDownloadApp: () => void; + isScrolled: boolean; +} + +export function AppNav({ + onSearch, + searchValue, + onSearchChange, + onHomePress, + onDownloadApp, + isScrolled, +}: AppNavProps) { + const logoSrc = toWebImageSrc(polybuysLogo); + + const searchBar = (extraClass = '') => ( +
+ + onSearchChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onSearch(searchValue)} + placeholder="Search textbooks, furniture, dorm items..." + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + aria-label="Search listings" + /> + {searchValue.length > 0 && ( + + )} +
+ ); + + return ( + // Outer shell: React Native View so it participates in the RN flex column + // layout correctly (navbar above, page content below). + + +
+ {/* Main row: logo | search | actions */} + + + {/* Compact row (mobile) */} +
{searchBar('pbn__search--full')}
+
+
+ ); +} + +// ─── Container ──────────────────────────────────────────────────────────────── + +export function AppNavContainer() { + const router = useRouter(); + const { searchQuery, setSearchQuery } = useSearch(); + + const [isScrolled, setIsScrolled] = useState(false); + const rafRef = useRef(null); + // Debounce timer ref — cancelled immediately when clearSearch fires + const debounceRef = useRef | null>(null); + + // Debounce: push search query to URL so home.tsx can read it + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + const trimmed = searchQuery.trim(); + router.replace({ + pathname: '/home' as never, + params: (trimmed.length > 0 ? { q: trimmed } : {}) as never, + }); + }, 250); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchQuery, router]); + + useEffect(() => { + const onScroll = () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + const y = window.scrollY ?? document.documentElement?.scrollTop ?? 0; + setIsScrolled(y > 8); + }); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', onScroll); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + const handleSearch = useCallback( + (query: string) => { + const trimmed = query.trim(); + // Immediate URL update on Enter — cancel pending debounce first + if (debounceRef.current) clearTimeout(debounceRef.current); + router.replace({ + pathname: '/home' as never, + params: (trimmed.length > 0 ? { q: trimmed } : {}) as never, + }); + }, + [router] + ); + + const handleHomePress = useCallback(() => { + router.replace('/home' as never); + }, [router]); + + const handleDownloadApp = useCallback(() => { + router.push('/landing#get-app' as never); + }, [router]); + + return ( + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── +// The outer shell uses React Native StyleSheet so it participates correctly +// in the flex column layout. All inner element styles are in NAV_CSS above. + +const styles = StyleSheet.create({ + shell: { + minHeight: 72, + backgroundColor: '#FAF6EC', + borderBottomWidth: 1.5, + borderBottomColor: 'rgba(21, 71, 52, 0.12)', + zIndex: 10, + flexShrink: 0, + boxShadow: '0 10px 30px rgba(21, 71, 52, 0.045), 0 1px 0 rgba(255, 255, 255, 0.62) inset', + } as never, + shellScrolled: { + borderBottomColor: 'rgba(21, 71, 52, 0.16)', + boxShadow: '0 14px 38px rgba(21, 71, 52, 0.075), 0 1px 0 rgba(255, 255, 255, 0.68) inset', + } as never, +}); diff --git a/frontend/components/CategoryRail.tsx b/frontend/components/CategoryRail.tsx new file mode 100644 index 00000000..b7f5a826 --- /dev/null +++ b/frontend/components/CategoryRail.tsx @@ -0,0 +1,351 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Animated, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; +import type { Category } from '../types/filters'; +import { CATEGORY_LABELS } from '../types/filters'; +import { useEntranceAnimation } from '../hooks/useEntranceAnimation'; +import { borderRadius, colors, spacing, typography } from '../theme/tokens'; + +const CATEGORY_ITEMS: Array<{ value: Category | undefined; label: string }> = [ + { value: undefined, label: 'All' }, + { value: 'textbooks', label: CATEGORY_LABELS.textbooks }, + { value: 'electronics', label: CATEGORY_LABELS.electronics }, + { value: 'furniture', label: CATEGORY_LABELS.furniture }, + { value: 'tickets', label: CATEGORY_LABELS.tickets }, + { value: 'other', label: CATEGORY_LABELS.other }, +]; + +const landingTextFont = + Platform.OS === 'web' + ? { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' } + : undefined; + +interface CategoryRailProps { + selectedCategory: Category | undefined; + onSelectCategory: (category: Category | undefined) => void; + /** Called when a category chip is pressed — clears the search input. */ + onClearSearch?: () => void; + priceLabel: string; + hasPrice: boolean; + onPricePress: () => void; + onClearPrice: () => void; + sortLabel: string; + hasNonDefaultSort: boolean; + onSortPress: () => void; + hasAnyFilter: boolean; + onClearAll: () => void; +} + +export function CategoryRail({ + selectedCategory, + onSelectCategory, + onClearSearch, + priceLabel, + hasPrice, + onPricePress, + onClearPrice, + sortLabel, + hasNonDefaultSort, + onSortPress, + hasAnyFilter, + onClearAll, +}: CategoryRailProps) { + const entranceStyle = useEntranceAnimation(60, 8); + + return ( + + + + Browse by category + + + + + {CATEGORY_ITEMS.map((item) => ( + { + onClearSearch?.(); + onSelectCategory(item.value); + }} + /> + ))} + + + + + + {hasAnyFilter ? : null} + + + + ); +} + +function CategoryChip({ + label, + isActive, + onPress, +}: { + label: string; + isActive: boolean; + onPress: () => void; +}) { + const [hovered, setHovered] = useState(false); + const isWeb = Platform.OS === 'web'; + + // Web-only hover style — bypasses StyleSheet validation + const webHoverStyle = + isWeb && hovered && !isActive + ? ({ + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: 'rgba(21,71,52,0.22)', + transform: 'translateY(-1px)', + boxShadow: '0 3px 10px rgba(21,71,52,0.10)', + } as never) + : isWeb && hovered && isActive + ? ({ transform: 'translateY(-1px)', boxShadow: '0 4px 14px rgba(21,71,52,0.28)' } as never) + : undefined; + + return ( + setHovered(true)} + onHoverOut={() => setHovered(false)} + accessibilityRole="button" + accessibilityLabel={label === 'All' ? 'Show all categories' : `Filter by ${label}`} + accessibilityState={{ selected: isActive }} + style={({ pressed }) => [ + styles.categoryChip, + isActive && styles.categoryChipActive, + pressed && styles.pressed, + webHoverStyle, + isWeb && + ({ + transition: + 'transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease, border-color 160ms ease', + } as never), + ]} + > + + {label} + + + ); +} + +function FilterControl({ + label, + iconName, + isActive, + onPress, + onClear, + accessibilityLabel, +}: { + label: string; + iconName: keyof typeof Ionicons.glyphMap; + isActive: boolean; + onPress: () => void; + onClear?: () => void; + accessibilityLabel: string; +}) { + return ( + [ + styles.filterControl, + isActive && styles.filterControlActive, + pressed && styles.pressed, + ]} + > + + + {label} + + {onClear ? ( + { + event.stopPropagation?.(); + onClear(); + }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Clear price filter" + style={styles.clearButton} + > + + + ) : null} + + ); +} + +function ClearAllButton({ onPress }: { onPress: () => void }) { + return ( + [styles.clearAll, pressed && styles.pressed]} + > + Clear + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + gap: 6, + }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + labelDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: colors.accent, + }, + sectionLabel: { + ...typography.footnote, + color: colors.muted, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0, + textTransform: 'uppercase', + }, + railBody: { + minWidth: 0, + }, + filterScroll: { + minWidth: 0, + }, + filterContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingTop: 1, + paddingBottom: 1, + paddingRight: spacing.md, + }, + inlineDivider: { + width: 1, + height: 20, + backgroundColor: 'rgba(21, 71, 52, 0.10)', + marginLeft: 2, + marginRight: 2, + }, + categoryChip: { + height: 34, + paddingHorizontal: 13, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.12)', + backgroundColor: 'rgba(255, 255, 255, 0.80)', + justifyContent: 'center', + alignItems: 'center', + }, + categoryChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + categoryText: { + ...typography.footnote, + color: colors.text, + fontSize: 13, + fontWeight: '500', + letterSpacing: 0, + }, + categoryTextActive: { + color: colors.white, + fontWeight: '600', + }, + filterControl: { + height: 34, + maxWidth: 168, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingLeft: 11, + paddingRight: 12, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.12)', + backgroundColor: 'rgba(255, 255, 255, 0.80)', + }, + filterControlActive: { + backgroundColor: 'rgba(21, 71, 52, 0.07)', + borderColor: 'rgba(21, 71, 52, 0.22)', + }, + filterControlText: { + ...typography.footnote, + color: colors.text, + fontSize: 13, + fontWeight: '500', + letterSpacing: 0, + flexShrink: 1, + }, + filterControlTextActive: { + color: colors.primary, + fontWeight: '600', + }, + clearButton: { + width: 17, + height: 17, + borderRadius: 9, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(21, 71, 52, 0.10)', + }, + clearAll: { + height: 34, + paddingHorizontal: 12, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.14)', + justifyContent: 'center', + }, + clearAllText: { + ...typography.footnote, + color: colors.primary, + fontSize: 13, + fontWeight: '600', + letterSpacing: 0, + }, + pressed: { + opacity: 0.8, + transform: [{ scale: 0.97 }], + }, +}); diff --git a/frontend/components/FilterBar.tsx b/frontend/components/FilterBar.tsx index cafa2c12..c46e7ec6 100644 --- a/frontend/components/FilterBar.tsx +++ b/frontend/components/FilterBar.tsx @@ -151,59 +151,66 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 8, + paddingVertical: 2, }, chip: { flexDirection: 'row', alignItems: 'center', borderRadius: borderRadius.full, - backgroundColor: colors.white, + backgroundColor: 'rgba(255,255,255,0.72)', borderWidth: 1, - borderColor: colors.border, - paddingVertical: 8, - paddingLeft: 14, - paddingRight: 10, - minHeight: 44, + borderColor: 'rgba(21, 71, 52, 0.10)', + paddingVertical: 7, + paddingLeft: 13, + paddingRight: 11, + minHeight: 36, gap: 6, }, chipActive: { - backgroundColor: colors.location, - borderColor: colors.locationDark, + backgroundColor: 'rgba(21, 71, 52, 0.07)', + borderColor: 'rgba(21, 71, 52, 0.22)', }, chipPressed: { - opacity: 0.9, + opacity: 0.85, + transform: [{ scale: 0.97 }], }, chipText: { - fontSize: 14, - color: colors.text, + fontSize: 12, + color: colors.muted, fontWeight: '500', + letterSpacing: 0.1, }, chipTextActive: { - color: colors.textDark, + color: colors.primary, + fontWeight: '600', }, clearBtn: { - width: 20, - height: 20, - borderRadius: 10, - backgroundColor: colors.overlayLight, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: 'rgba(21, 71, 52, 0.12)', alignItems: 'center', justifyContent: 'center', }, clearBtnText: { - color: colors.textDark, + color: colors.primary, fontSize: 12, fontWeight: '700', lineHeight: 14, }, clearAll: { - paddingHorizontal: 12, + paddingHorizontal: 14, paddingVertical: 8, borderRadius: borderRadius.full, - minHeight: 44, + minHeight: 38, justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.18)', }, clearAllText: { - fontSize: 14, + fontSize: 13, color: colors.primary, fontWeight: '600', + letterSpacing: 0.1, }, }); diff --git a/frontend/components/HomeBg.tsx b/frontend/components/HomeBg.tsx new file mode 100644 index 00000000..ddb88fe4 --- /dev/null +++ b/frontend/components/HomeBg.tsx @@ -0,0 +1,8 @@ +/** + * HomeBg.tsx — native stub. + * On iOS/Android the background is handled by the page backgroundColor. + * This file exists so the import resolves on all platforms. + */ +export function HomeBg() { + return null; +} diff --git a/frontend/components/HomeBg.web.tsx b/frontend/components/HomeBg.web.tsx new file mode 100644 index 00000000..7dde4d87 --- /dev/null +++ b/frontend/components/HomeBg.web.tsx @@ -0,0 +1,316 @@ +import { StyleSheet, View, useWindowDimensions } from 'react-native'; + +const mapLines = [ + { key: 'v-campus-west', style: { left: '9%', top: 18, height: 540 } }, + { key: 'v-campus-core', style: { left: '31%', top: 0, height: 650 } }, + { key: 'v-market-walk', style: { left: '56%', top: 42, height: 610 } }, + { key: 'v-dorm-edge', style: { left: '82%', top: 8, height: 560 } }, +] as const; + +const mapRows = [ + { key: 'h-upper', style: { top: 104, left: '-6%', width: '112%' } }, + { key: 'h-center', style: { top: 252, left: '-4%', width: '108%' } }, + { key: 'h-lower', style: { top: 420, left: '-8%', width: '116%' } }, +] as const; + +const routeSegments = [ + { + key: 'green-north', + style: { + left: '-8%', + top: 168, + width: 680, + transform: [{ rotate: '8deg' }], + backgroundColor: 'rgba(21, 71, 52, 0.17)', + }, + }, + { + key: 'green-south', + style: { + right: '-10%', + top: 382, + width: 700, + transform: [{ rotate: '10deg' }], + backgroundColor: 'rgba(21, 71, 52, 0.13)', + }, + }, + { + key: 'gold-cross', + style: { + left: '26%', + top: 312, + width: 720, + transform: [{ rotate: '-24deg' }], + backgroundColor: 'rgba(226, 168, 74, 0.17)', + }, + }, +] as const; + +const accentDots = [ + { key: 'dot-market', style: { left: '31%', top: 246 }, color: '#154734', size: 10 }, + { key: 'dot-core', style: { left: '56%', top: 414 }, color: '#E2A84A', size: 12 }, + { key: 'dot-dorm', style: { left: '82%', top: 102 }, color: '#154734', size: 8 }, + { key: 'dot-book', style: { left: '68%', top: 294 }, color: '#E2A84A', size: 7 }, +] as const; + +const ghostCards = [ + { + key: 'card-left', + style: { left: '4%', top: 150, width: 168, height: 124, transform: [{ rotate: '-7deg' }] }, + }, + { + key: 'card-right', + style: { right: '5%', top: 230, width: 196, height: 142, transform: [{ rotate: '5deg' }] }, + }, + { + key: 'card-bottom', + style: { left: '14%', top: 500, width: 210, height: 148, transform: [{ rotate: '4deg' }] }, + }, +] as const; + +export function HomeBg() { + const { width } = useWindowDimensions(); + const isCompact = width < 700; + const focusSize = isCompact ? 780 : Math.min(Math.max(width * 0.82, 980), 1380); + const ghostCardsToRender = isCompact ? ghostCards.slice(0, 1) : ghostCards; + + return ( + + + + + + + + + + + + {mapLines.map((line) => ( + + ))} + {mapRows.map((line) => ( + + ))} + {routeSegments.map((segment) => ( + + ))} + {accentDots.map((dot) => ( + + ))} + + + {ghostCardsToRender.map((card) => ( + + + + + + ))} + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + ...StyleSheet.absoluteFillObject, + overflow: 'hidden', + zIndex: 0, + }, + base: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#F7F5EF', + }, + mintWash: { + position: 'absolute', + left: 0, + right: 0, + top: 210, + bottom: 0, + backgroundColor: '#EAF3EE', + opacity: 0.72, + }, + topLightStrong: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 92, + backgroundColor: '#FFFFFF', + opacity: 0.42, + }, + topLightSoft: { + position: 'absolute', + left: 0, + right: 0, + top: 70, + height: 220, + backgroundColor: '#FFF8E8', + opacity: 0.34, + }, + focusGlow: { + position: 'absolute', + left: '50%', + top: 168, + backgroundColor: '#ACDDCA', + opacity: 0.14, + }, + focusGlowInner: { + position: 'absolute', + left: '50%', + top: 262, + backgroundColor: '#FFFFFF', + opacity: 0.11, + }, + focusGlowWarm: { + position: 'absolute', + left: '50%', + top: 358, + backgroundColor: '#E2A84A', + opacity: 0.075, + }, + mapLayer: { + position: 'absolute', + left: '-4%', + right: '-4%', + top: 64, + height: 660, + opacity: 0.76, + }, + mapLayerCompact: { + left: '-42%', + right: '-42%', + top: 110, + height: 540, + opacity: 0.54, + }, + gridLineVertical: { + position: 'absolute', + width: 1, + backgroundColor: 'rgba(21, 71, 52, 0.12)', + }, + gridLineHorizontal: { + position: 'absolute', + height: 1, + backgroundColor: 'rgba(21, 71, 52, 0.11)', + }, + routeSegment: { + position: 'absolute', + height: 3, + borderRadius: 2, + opacity: 0.44, + }, + accentDot: { + position: 'absolute', + borderWidth: 3, + borderColor: 'rgba(247, 245, 239, 0.88)', + opacity: 0.68, + }, + ghostCard: { + position: 'absolute', + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.10)', + backgroundColor: 'rgba(255, 255, 255, 0.22)', + opacity: 0.24, + paddingLeft: 14, + paddingRight: 14, + paddingTop: 14, + paddingBottom: 14, + }, + ghostImage: { + height: 54, + borderRadius: 12, + backgroundColor: 'rgba(21, 71, 52, 0.09)', + marginBottom: 12, + }, + ghostLineWide: { + width: '72%', + height: 8, + borderRadius: 4, + backgroundColor: 'rgba(21, 71, 52, 0.10)', + marginBottom: 8, + }, + ghostLineShort: { + width: '44%', + height: 8, + borderRadius: 4, + backgroundColor: 'rgba(226, 168, 74, 0.15)', + }, + gridCalmWash: { + position: 'absolute', + left: 0, + right: 0, + top: 280, + bottom: 0, + backgroundColor: '#F7F5EF', + opacity: 0.62, + }, + edgeShadeLeft: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 90, + backgroundColor: 'rgba(21, 71, 52, 0.025)', + }, + edgeShadeRight: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: 90, + backgroundColor: 'rgba(21, 71, 52, 0.025)', + }, +}); diff --git a/frontend/components/LandingScreen.web.tsx b/frontend/components/LandingScreen.web.tsx index 36b45cf9..f9a071a5 100644 --- a/frontend/components/LandingScreen.web.tsx +++ b/frontend/components/LandingScreen.web.tsx @@ -1,9 +1,18 @@ +import { useEffect } from 'react'; import Head from 'expo-router/head'; import { Footer, GetApp, GLOBAL_CSS, Hero, Nav, Why, useScrolled } from './landing'; +import { scrollToLandingHash } from './landing/scrollToLandingSection'; export default function LandingScreen() { const scrolled = useScrolled(); + useEffect(() => { + scrollToLandingHash(); + window.addEventListener('hashchange', scrollToLandingHash); + + return () => window.removeEventListener('hashchange', scrollToLandingHash); + }, []); + return ( <> @@ -16,7 +25,7 @@ export default function LandingScreen() { diff --git a/frontend/components/ListingCard.tsx b/frontend/components/ListingCard.tsx index 8147f06b..cc309da1 100644 --- a/frontend/components/ListingCard.tsx +++ b/frontend/components/ListingCard.tsx @@ -13,6 +13,12 @@ import { GlassIconButton } from './ui'; /** LRU-capped ids that already ran the entrance animation. */ const MAX_ANIMATED_CACHE = 200; const animatedListingIds = new Map(); +const landingTextFont = + Platform.OS === 'web' + ? { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' } + : undefined; +const landingListingTitleFont = + Platform.OS === 'web' ? { fontFamily: 'Fraunces, Georgia, serif' } : undefined; function markAsAnimated(id: string): void { if (animatedListingIds.size >= MAX_ANIMATED_CACHE) { @@ -34,9 +40,7 @@ function formatConditionLabel(condition: 'new' | 'used' | 'refurbished'): string } export type ListingCardBadge = 'sold' | 'unavailable'; - export type ListingCardStatusBadge = 'active' | 'inactive' | 'sold' | 'hidden'; - export type ListingCardDensity = 'default' | 'home'; export type ListingCardShellStyle = 'default' | 'flat'; @@ -78,6 +82,95 @@ function formatStatusBadgeLabel(status: ListingCardStatusBadge): string { } } +function getStatusPillStyle(status: ListingCardStatusBadge) { + switch (status) { + case 'active': + return { backgroundColor: colors.location }; + case 'hidden': + return { backgroundColor: colors.category }; + case 'sold': + case 'inactive': + return { backgroundColor: 'rgba(0,0,0,0.6)' }; + } +} + +function getStatusPillTextStyle(status: ListingCardStatusBadge) { + switch (status) { + case 'active': + return { color: colors.primary }; + case 'hidden': + case 'sold': + case 'inactive': + return { color: colors.white }; + } +} + +// ─── Web-only inline style objects ─────────────────────────────────────────── +// StyleSheet.create() validates and strips web-only CSS properties (boxShadow, +// transition, transform with non-native values, background gradients). +// We bypass it by using plain object literals applied only when Platform.OS === 'web'. + +const webCardBase: object = { + borderRadius: 16, + backgroundColor: '#FFFFFF', + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(21,71,52,0.11)', + boxShadow: '0 2px 12px rgba(21,71,52,0.10), 0 1px 3px rgba(21,71,52,0.06)', + transition: + 'transform 200ms cubic-bezier(0.2,0.7,0.2,1), box-shadow 200ms cubic-bezier(0.2,0.7,0.2,1), border-color 200ms ease', + cursor: 'pointer', + willChange: 'transform', +}; + +const webCardHover: object = { + transform: [{ translateY: -4 }, { scale: 1.015 }], + boxShadow: '0 10px 32px rgba(21,71,52,0.16), 0 3px 10px rgba(21,71,52,0.09)', + borderColor: 'rgba(21,71,52,0.16)', +}; + +const webCardPressed: object = { + transform: [{ scale: 0.985 }], + boxShadow: '0 2px 8px rgba(21,71,52,0.10)', + opacity: 0.96, +}; + +const webCardHighlight: object = { + backgroundColor: 'rgba(255,255,255,0.82)', +}; + +const webCardSweepBase: object = { + position: 'absolute', + top: 0, + bottom: 0, + width: '52%', + zIndex: 3, + pointerEvents: 'none', + left: '-58%', + opacity: 0, + // Angled sweep shape via skew + transform: 'skewX(-12deg)', + backgroundColor: 'rgba(255,255,255,0.14)', + transition: 'left 700ms cubic-bezier(0.25,0.46,0.45,0.94), opacity 200ms ease', +}; + +const webCardSweepHover: object = { + left: '115%', + opacity: 1, +}; + +const webImageBase: object = { + width: '100%', + height: '100%', + transition: 'transform 300ms cubic-bezier(0.2,0.7,0.2,1)', +}; + +const webImageHover: object = { + transform: [{ scale: 1.07 }], +}; + +const LISTING_IMAGE_HEIGHT = 200; + export default function ListingCard({ listing, index = 0, @@ -117,12 +210,7 @@ export default function ListingCard({ tension: 280, useNativeDriver: true, }), - Animated.spring(heartScale, { - toValue: 1, - friction: 7, - tension: 200, - useNativeDriver: true, - }), + Animated.spring(heartScale, { toValue: 1, friction: 7, tension: 200, useNativeDriver: true }), ]).start(); }, [heartScale, reduceMotion]); @@ -135,18 +223,12 @@ export default function ListingCard({ easing: Easing.out(Easing.quad), useNativeDriver: true, }), - Animated.spring(heartScale, { - toValue: 1, - friction: 6, - tension: 220, - useNativeDriver: true, - }), + Animated.spring(heartScale, { toValue: 1, friction: 6, tension: 220, useNativeDriver: true }), ]).start(); }, [heartScale, reduceMotion]); useEffect(() => { if (!shouldAnimate) return; - const delay = Math.min(index * motion.delayPerItem, motion.maxStaggerDelay); const anim = Animated.parallel([ Animated.timing(opacity, { @@ -164,16 +246,12 @@ export default function ListingCard({ useNativeDriver: true, }), ]); - anim.start(() => markAsAnimated(listing._id)); return () => anim.stop(); }, [listing._id, index, shouldAnimate, opacity, translateY]); const animatedStyle = useMemo( - () => ({ - opacity, - transform: [{ translateY }], - }), + () => ({ opacity, transform: [{ translateY }] }), [opacity, translateY] ); @@ -182,18 +260,17 @@ export default function ListingCard({ const isWeb = Platform.OS === 'web'; const conditionLabel = formatConditionLabel(listing.condition); const titleLines = 1; + const homeTextFontStyle = isWeb && isHomeDensity ? landingTextFont : undefined; + const homeTitleFontStyle = isWeb && isHomeDensity ? landingListingTitleFont : undefined; const accessibilityDescriptionHint = useMemo(() => { const raw = listing.description.trim().replace(/\s+/g, ' '); - if (raw.length <= 160) { - return raw; - } - return `${raw.slice(0, 157)}…`; + return raw.length <= 160 ? raw : `${raw.slice(0, 157)}…`; }, [listing.description]); const accessibilityLabel = useMemo(() => { const parts = [ - `${listing.title}, $${formatPrice(listing.price)}`, + `${listing.title}, ${formatPrice(listing.price)}`, conditionLabel, accessibilityDescriptionHint, badgeToShow === 'sold' ? 'Sold' : badgeToShow === 'unavailable' ? 'Unavailable' : null, @@ -214,13 +291,22 @@ export default function ListingCard({ if (prevSavedRef.current === isSaved) return; const wasSaved = prevSavedRef.current; prevSavedRef.current = isSaved; - if (isSaved && !wasSaved) { - playHeartSavedAnimation(); - } else if (!isSaved && wasSaved) { - playHeartUnsavedAnimation(); - } + if (isSaved && !wasSaved) playHeartSavedAnimation(); + else if (!isSaved && wasSaved) playHeartUnsavedAnimation(); }, [isSaved, onToggleSave, playHeartSavedAnimation, playHeartUnsavedAnimation]); + // ── Build web-only inline styles (bypasses StyleSheet validation) ────────── + const webCardStyle = + isWeb && !isFlatShell ? { ...webCardBase, ...(isHovered ? webCardHover : {}) } : undefined; + + const webSweepStyle = + isWeb && !isFlatShell && !reduceMotion + ? { ...webCardSweepBase, ...(isHovered ? webCardSweepHover : {}) } + : undefined; + + const webImageStyle = + isWeb && !isFlatShell ? { ...webImageBase, ...(isHovered ? webImageHover : {}) } : undefined; + return ( [ - styles.listingCard, + // Native styles from StyleSheet (safe — no web-only props) + styles.listingCardNative, isFlatShell && styles.listingCardFlat, - isHovered && !isFlatShell && styles.listingCardHover, - state.pressed && styles.listingCardPressed, + state.pressed && !isWeb && styles.listingCardPressed, + // Web styles applied as plain object (not through StyleSheet) + webCardStyle as never, + // Web pressed state + isWeb && state.pressed && !isFlatShell && (webCardPressed as never), ]} onPress={() => onPressProp ? onPressProp(listing) : router.push(`/listings/${listing._id}`) @@ -245,20 +335,42 @@ export default function ListingCard({ accessibilityLabel={accessibilityLabel} accessibilityRole="button" > - + {!isFlatShell ? ( + + ) : null} + + {/* Image container — overflow:hidden clips the zoom */} + {imageUrl ? ( - + ) : ( - No image + No image + + )} + + {/* Hover overlay: dark gradient + "View details →" chip */} + {isHovered && !isFlatShell && ( + + + {/* eslint-disable-next-line react-native/no-raw-text */} + View details + {/* eslint-disable-next-line react-native/no-raw-text */} + + )} + {badgeToShow && ( )} + + {/* Card body */} + {/* Title + condition badge */} {listing.title} - + {conditionLabel} + + {/* Price + date */} ${formatPrice(listing.price)} {(listing.postedOn || listing.createdAt) && ( - + {formatRelativeDate(listing.postedOn ?? listing.createdAt!)} )} + + {webSweepStyle ? ( + + ) : null} + + {/* Floating save / manage buttons */} {onToggleSave || onManagePress ? ( {onToggleSave ? ( @@ -351,34 +482,9 @@ export default function ListingCard({ ); } -function getStatusPillStyle(status: ListingCardStatusBadge) { - switch (status) { - case 'active': - return { backgroundColor: colors.location }; - case 'hidden': - return { backgroundColor: colors.category }; - case 'sold': - case 'inactive': - return { backgroundColor: 'rgba(0,0,0,0.6)' }; - } -} - -function getStatusPillTextStyle(status: ListingCardStatusBadge) { - switch (status) { - case 'active': - return { color: colors.primary }; - case 'hidden': - case 'sold': - case 'inactive': - return { color: colors.white }; - } -} - -const LISTING_IMAGE_HEIGHT = 220; - const styles = StyleSheet.create({ listingCardWrapper: { - marginBottom: spacing.md, + marginBottom: spacing.lg, flex: 1, minWidth: 0, }, @@ -395,61 +501,110 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', gap: spacing.xs, }, - listingCard: { - borderRadius: borderRadius.md, - backgroundColor: colors.surface, + + // Native-safe card shell — NO web-only properties here + listingCardNative: { + borderRadius: 16, + backgroundColor: colors.white, overflow: 'hidden', borderWidth: 1, - borderColor: colors.border, - boxShadow: '0 8px 20px rgba(21, 71, 52, 0.07)', + borderColor: 'rgba(21,71,52,0.12)', }, listingCardFlat: { backgroundColor: 'transparent', - boxShadow: 'none', - }, - listingCardHover: { - boxShadow: '0 18px 36px rgba(21, 71, 52, 0.14)', - transform: [{ translateY: -2 }], + borderWidth: 0, }, listingCardPressed: { - transform: [{ scale: 0.99 }], + // Native press feedback only + opacity: 0.92, }, + cardHighlightEdge: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 1, + backgroundColor: 'rgba(255,255,255,0.80)', + zIndex: 4, + }, + cardHoverSweep: { + position: 'absolute', + top: 0, + bottom: 0, + zIndex: 3, + }, + + // Image container — overflow:hidden is critical for zoom clip imageContainer: { width: '100%', height: LISTING_IMAGE_HEIGHT, - backgroundColor: colors.surface, - borderRadius: borderRadius.sm, + backgroundColor: colors.placeholderBg, overflow: 'hidden', position: 'relative', }, imageContainerHome: {}, - imageContainerHomeWeb: {}, - saveButton: { - width: 40, - height: 40, - borderRadius: 20, - }, - manageButton: { - width: 36, - height: 36, - borderRadius: 18, - }, - manageDots: { - flexDirection: 'row', + + image: { + width: '100%', + height: '100%', + }, + imagePlaceholder: { + flex: 1, alignItems: 'center', justifyContent: 'center', - gap: 3, + backgroundColor: '#EEF4F1', + gap: 6, }, - manageDot: { - width: 4, - height: 4, - borderRadius: 2, - backgroundColor: colors.textDark, + imagePlaceholderText: { + ...typography.footnote, + color: colors.muted, + fontSize: 12, + fontWeight: '500', + letterSpacing: 0, + }, + + // Hover overlay — gradient applied inline on web only + imageHoverOverlay: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + top: 0, + justifyContent: 'flex-end', + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + }, + imageHoverOverlayWeb: { + backgroundColor: 'rgba(10,30,20,0.36)', + opacity: 1, + }, + viewDetailsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + alignSelf: 'flex-start', + backgroundColor: 'rgba(255,248,232,0.93)', + borderRadius: borderRadius.full, + paddingHorizontal: 12, + paddingVertical: 6, }, + viewDetailsText: { + fontSize: 12, + fontWeight: '700', + color: colors.primary, + letterSpacing: 0, + }, + viewDetailsArrow: { + fontSize: 13, + fontWeight: '700', + color: colors.primary, + }, + + // Status badges statusPill: { position: 'absolute', - top: spacing.xs, - left: spacing.xs, + top: spacing.sm, + left: spacing.sm, paddingHorizontal: spacing.sm, paddingVertical: 3, borderRadius: borderRadius.full, @@ -462,49 +617,32 @@ const styles = StyleSheet.create({ }, statusBadge: { position: 'absolute', - bottom: spacing.xs, - left: spacing.xs, + bottom: spacing.sm, + left: spacing.sm, paddingHorizontal: spacing.sm, - paddingVertical: 2, + paddingVertical: 3, borderRadius: borderRadius.sm, }, - statusBadgeSold: { - backgroundColor: 'rgba(0,0,0,0.7)', - }, - statusBadgeUnavailable: { - backgroundColor: 'rgba(0,0,0,0.6)', - }, + statusBadgeSold: { backgroundColor: 'rgba(0,0,0,0.72)' }, + statusBadgeUnavailable: { backgroundColor: 'rgba(0,0,0,0.60)' }, statusBadgeText: { ...typography.footnote, color: colors.white, fontWeight: '600', }, - image: { - width: '100%', - height: '100%', - borderRadius: borderRadius.sm, - }, - imagePlaceholder: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - borderRadius: borderRadius.sm, - }, - imagePlaceholderText: { - ...typography.footnote, - color: colors.text, - }, + + // Card body cardDetails: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - paddingBottom: spacing.md, - gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.lg, + gap: spacing.sm, }, cardDetailsHome: { - paddingHorizontal: spacing.smPlus, - paddingTop: spacing.sm, - paddingBottom: spacing.md, - gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingTop: spacing.md, + paddingBottom: spacing.lg, + gap: spacing.sm, }, titleRow: { flexDirection: 'row', @@ -515,31 +653,38 @@ const styles = StyleSheet.create({ ...typography.subhead, flex: 1, minWidth: 0, - fontSize: 16, + fontSize: 15, fontWeight: '600', lineHeight: 21, color: colors.textDark, + letterSpacing: 0, }, listingTitleHome: { - fontSize: 15, - lineHeight: 20, - fontWeight: '600', + fontSize: 16, + lineHeight: 22, + fontWeight: '500', + letterSpacing: 0, }, + + // Condition pill — proper pill shape conditionPill: { flexShrink: 0, - paddingHorizontal: spacing.sm, - paddingVertical: 2, + paddingHorizontal: 9, + paddingVertical: 3, borderRadius: borderRadius.full, - backgroundColor: colors.border, + backgroundColor: 'rgba(21,71,52,0.08)', + borderWidth: 1, + borderColor: 'rgba(21,71,52,0.12)', }, conditionPillText: { ...typography.footnote, fontSize: 11, lineHeight: 14, fontWeight: '600', - letterSpacing: 0.3, + letterSpacing: 0, color: colors.primary, }, + priceRow: { flexDirection: 'row', alignItems: 'baseline', @@ -547,22 +692,32 @@ const styles = StyleSheet.create({ gap: spacing.sm, }, listingPrice: { - ...typography.title2, flexShrink: 1, minWidth: 0, - fontSize: 18, - fontWeight: '700', - color: colors.accent, + fontSize: 19, + fontWeight: '800', + color: colors.primary, + letterSpacing: 0, + lineHeight: 24, }, listingPriceHome: { fontSize: 17, + fontWeight: '700', + letterSpacing: 0, }, listingDate: { ...typography.footnote, flexShrink: 0, - fontSize: 12, + fontSize: 11, color: colors.muted, }, + + // Floating action buttons + saveButton: { width: 40, height: 40, borderRadius: 20 }, + manageButton: { width: 36, height: 36, borderRadius: 18 }, + manageDots: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 3 }, + manageDot: { width: 4, height: 4, borderRadius: 2, backgroundColor: colors.textDark }, + footer: { marginTop: spacing.sm, paddingHorizontal: spacing.md, diff --git a/frontend/components/landing/Brand.tsx b/frontend/components/landing/Brand.tsx index 5c5f0580..f2029af7 100644 --- a/frontend/components/landing/Brand.tsx +++ b/frontend/components/landing/Brand.tsx @@ -1,3 +1,5 @@ +import polybuysLogo from '../../assets/images/PolyBuysLogo.png'; +import { toWebImageSrc } from './assetSource'; import { cx } from './cx'; interface BrandProps { @@ -9,13 +11,16 @@ interface BrandProps { export function Brand({ muted = false, href, ariaLabel, className }: BrandProps) { const classes = cx('pb-brand', muted && 'pb-brand--muted', className); + const logoSrc = toWebImageSrc(polybuysLogo); const content = ( - <> - - - - PolyBuys - + PolyBuys logo ); if (href) { diff --git a/frontend/components/landing/Hero.tsx b/frontend/components/landing/Hero.tsx index 4d5c3c0d..08b5c4ba 100644 --- a/frontend/components/landing/Hero.tsx +++ b/frontend/components/landing/Hero.tsx @@ -74,13 +74,13 @@ function SocialProof() { function HeroStage() { return ( -
-
-
+ ); } diff --git a/frontend/components/landing/ListingCard.tsx b/frontend/components/landing/ListingCard.tsx index eb580c13..e70fe435 100644 --- a/frontend/components/landing/ListingCard.tsx +++ b/frontend/components/landing/ListingCard.tsx @@ -1,5 +1,4 @@ import type { SampleListing } from './data'; -import { ListingThumbIcon } from './icons'; import { cx } from './cx'; type CardVariant = 'back' | 'front'; @@ -14,9 +13,15 @@ export function ListingCard({ listing, variant, className }: ListingCardProps) { return (
- - - + {listing.image.alt} {listing.badge ? {listing.badge} : null}
diff --git a/frontend/components/landing/Nav.tsx b/frontend/components/landing/Nav.tsx index 9b2507ea..3e9f66e3 100644 --- a/frontend/components/landing/Nav.tsx +++ b/frontend/components/landing/Nav.tsx @@ -1,28 +1,43 @@ +import type { MouseEvent } from 'react'; import { Brand } from './Brand'; import { DownloadButton } from './DownloadButton'; import { cx } from './cx'; +import { scrollToLandingSection, type LandingSectionId } from './scrollToLandingSection'; interface NavProps { scrolled: boolean; } export function Nav({ scrolled }: NavProps) { + function handleSectionClick(event: MouseEvent, sectionId: LandingSectionId) { + event.preventDefault(); + scrollToLandingSection(sectionId); + } + return ( diff --git a/frontend/components/landing/assetSource.ts b/frontend/components/landing/assetSource.ts new file mode 100644 index 00000000..9aa416f3 --- /dev/null +++ b/frontend/components/landing/assetSource.ts @@ -0,0 +1,14 @@ +type WebImageAssetObject = { uri?: string; default?: string }; + +export function toWebImageSrc(asset: unknown): string { + if (typeof asset === 'string') { + return asset; + } + + if (asset && typeof asset === 'object') { + const imageAsset = asset as WebImageAssetObject; + return imageAsset.uri ?? imageAsset.default ?? ''; + } + + return ''; +} diff --git a/frontend/components/landing/data.ts b/frontend/components/landing/data.ts index 6ed36d14..84d3b82a 100644 --- a/frontend/components/landing/data.ts +++ b/frontend/components/landing/data.ts @@ -1,5 +1,6 @@ import { APP_STORE_URL } from '../../constants/app'; import qrDownloadPng from '../../assets/images/polybuys-download-qr.png'; +import { PRODUCT_IMAGES } from './productImages'; export { APP_STORE_URL }; @@ -18,7 +19,12 @@ export type SampleListing = { price: string; seller: string; location: string; - thumbIcon: ListingThumbIconId; + image: { + src: string; + alt: string; + width: number; + height: number; + }; gradient: string; badge?: string; }; @@ -30,7 +36,12 @@ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ price: '$40', seller: 'Hazel', location: 'Poly Canyon', - thumbIcon: 'textbook', + image: { + src: PRODUCT_IMAGES.calculusBook, + alt: 'Used maroon calculus textbook with a mathematical diagram on a desk', + width: 640, + height: 400, + }, gradient: 'linear-gradient(135deg, #1E5C44 0%, #154734 100%)', badge: 'Just listed', }, @@ -40,7 +51,12 @@ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ price: '$35', seller: 'Mateo', location: 'Mustang Village', - thumbIcon: 'furniture', + image: { + src: PRODUCT_IMAGES.cubeShelf, + alt: 'White four-cube shelf with books and woven baskets in a living room', + width: 640, + height: 400, + }, gradient: 'linear-gradient(135deg, #F3D38B 0%, #E2A84A 100%)', }, ]; diff --git a/frontend/components/landing/productImages.ts b/frontend/components/landing/productImages.ts new file mode 100644 index 00000000..99e810cb --- /dev/null +++ b/frontend/components/landing/productImages.ts @@ -0,0 +1,8 @@ +import calculusBookJpg from '../../assets/images/landing-calculus-book.jpg'; +import kallaxShelfJpg from '../../assets/images/landing-kallax-shelf.jpg'; +import { toWebImageSrc } from './assetSource'; + +export const PRODUCT_IMAGES = { + calculusBook: toWebImageSrc(calculusBookJpg), + cubeShelf: toWebImageSrc(kallaxShelfJpg), +} as const; diff --git a/frontend/components/landing/scrollToLandingSection.ts b/frontend/components/landing/scrollToLandingSection.ts new file mode 100644 index 00000000..ab295eed --- /dev/null +++ b/frontend/components/landing/scrollToLandingSection.ts @@ -0,0 +1,57 @@ +export type LandingSectionId = 'why' | 'get-app'; + +const SCROLL_SHELL_SELECTOR = '.pb-web-scroll-shell'; +const NAV_SELECTOR = '.pb-nav'; +const SECTION_OFFSET = 16; + +function isLandingSectionId(value: string): value is LandingSectionId { + return value === 'why' || value === 'get-app'; +} + +export function scrollToLandingSection(sectionId: LandingSectionId): void { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return; + } + + const target = document.getElementById(sectionId); + if (!target) { + return; + } + + const shell = document.querySelector(SCROLL_SHELL_SELECTOR); + const nav = document.querySelector(NAV_SELECTOR); + const navHeight = nav?.getBoundingClientRect().height ?? 0; + const topOffset = navHeight + SECTION_OFFSET; + + if (shell) { + const shellRect = shell.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + shell.scrollTo({ + top: Math.max(targetRect.top - shellRect.top + shell.scrollTop - topOffset, 0), + behavior: 'smooth', + }); + } else { + window.scrollTo({ + top: Math.max(target.getBoundingClientRect().top + window.scrollY - topOffset, 0), + behavior: 'smooth', + }); + } + + const nextHash = `#${sectionId}`; + if (window.location.hash !== nextHash) { + window.history.pushState(null, '', nextHash); + } +} + +export function scrollToLandingHash(): void { + if (typeof window === 'undefined') { + return; + } + + const sectionId = window.location.hash.replace(/^#/, ''); + if (!isLandingSectionId(sectionId)) { + return; + } + + window.requestAnimationFrame(() => scrollToLandingSection(sectionId)); +} diff --git a/frontend/components/landing/styles.ts b/frontend/components/landing/styles.ts index 80483f0e..244dd50f 100644 --- a/frontend/components/landing/styles.ts +++ b/frontend/components/landing/styles.ts @@ -49,23 +49,74 @@ img { max-width: 100%; display: block; } position: relative; min-height: 100vh; min-height: 100dvh; + isolation: isolate; background: - radial-gradient(1200px 600px at 85% -10%, rgba(226, 168, 74, 0.18), transparent 60%), - radial-gradient(900px 500px at -10% 20%, rgba(21, 71, 52, 0.10), transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.52) 0%, rgba(255, 248, 232, 0.36) 110px, rgba(250, 246, 236, 0) 360px), + radial-gradient(ellipse at 50% -10%, rgba(255, 255, 255, 0.62) 0%, rgba(255, 248, 232, 0.34) 34%, transparent 68%), var(--pb-cream); } +.pb-page::before, +.pb-page::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} +.pb-page::before { + background-image: + linear-gradient(rgba(21, 71, 52, 0.044) 1px, transparent 1px), + linear-gradient(90deg, rgba(21, 71, 52, 0.044) 1px, transparent 1px), + radial-gradient(circle at 24px 24px, rgba(226, 168, 74, 0.11) 0, rgba(226, 168, 74, 0.11) 1.6px, transparent 2px); + background-position: 50% 0, 50% 0, 50% 0; + background-size: 72px 72px, 72px 72px, 144px 144px; + mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.68) 46%, transparent 100%); + -webkit-mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.68) 46%, transparent 100%); +} +.pb-page::after { + background-image: + linear-gradient(32deg, transparent 0 47%, rgba(21, 71, 52, 0.055) 47.25% 47.55%, transparent 47.8% 100%), + linear-gradient(-28deg, transparent 0 55%, rgba(226, 168, 74, 0.065) 55.2% 55.55%, transparent 55.8% 100%), + linear-gradient(108deg, transparent 0 38%, rgba(21, 71, 52, 0.038) 38.2% 38.6%, transparent 38.9% 100%); + background-position: -80px 40px, 120px 10px, 40px 140px; + background-size: 420px 260px, 360px 220px, 520px 320px; + mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.46) 50%, transparent 96%); + -webkit-mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.46) 50%, transparent 96%); +} +.pb-page > * { + position: relative; + z-index: 1; +} +@media (max-width: 700px) { + .pb-page::before { + background-size: 52px 52px, 52px 52px, 104px 104px; + opacity: 0.72; + } + .pb-page::after { + background-size: 300px 190px, 280px 170px, 360px 230px; + opacity: 0.52; + } +} /* ---------- NAV ---------- */ .pb-nav { position: sticky; top: 0; z-index: 40; - background: var(--pb-cream); - border-bottom: 1px solid var(--pb-border); - transition: border-color 200ms var(--pb-ease); + background: rgba(250, 246, 236, 0.78); + backdrop-filter: blur(18px) saturate(1.08); + -webkit-backdrop-filter: blur(18px) saturate(1.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.48); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.62) inset, + 0 10px 30px rgba(21, 71, 52, 0.045); + transition: border-color 200ms var(--pb-ease), background 200ms var(--pb-ease), + box-shadow 200ms var(--pb-ease); } .pb-nav.is-scrolled { - border-bottom-color: var(--pb-border-strong); + background: rgba(250, 246, 236, 0.90); + border-bottom-color: rgba(21, 71, 52, 0.12); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.68) inset, + 0 14px 38px rgba(21, 71, 52, 0.075); } .pb-nav__inner { max-width: 1160px; @@ -113,32 +164,32 @@ img { max-width: 100%; display: block; } .pb-brand { display: inline-flex; align-items: center; - gap: 10px; - font-family: 'Fraunces', Georgia, serif; - font-weight: 600; - font-size: 22px; - letter-spacing: -0.01em; - color: var(--pb-green); + flex: 0 0 auto; + width: clamp(158px, 18vw, 196px); } -.pb-brand__mark { - position: relative; - width: 28px; - height: 28px; - border-radius: 999px; - background: var(--pb-green); - display: inline-flex; - align-items: center; - justify-content: center; -} -.pb-brand__dot { - width: 10px; - height: 10px; - border-radius: 999px; - background: var(--pb-gold); - border: 2px solid rgba(226, 168, 74, 0.35); +.pb-brand__logo { + display: block; + width: 100%; + height: auto; + aspect-ratio: 716 / 158; + object-fit: contain; +} +.pb-brand--muted { opacity: 0.74; } +.pb-footer .pb-brand { width: 172px; } +.pb-doc__nav .pb-brand { width: clamp(158px, 30vw, 196px); } +@media (max-width: 520px) { + .pb-nav__inner { + gap: 8px; + padding: 12px 16px; + } + .pb-nav .pb-brand { width: 124px; } + .pb-nav .pb-btn--sm { + padding: 0 10px; + gap: 7px; + font-size: 13px; + } + .pb-nav .pb-apple-icon { width: 11px; height: auto; margin-right: 0; } } -.pb-brand--muted { color: var(--pb-ink-2); } -.pb-brand--muted .pb-brand__mark { background: var(--pb-ink-2); } /* ---------- BUTTONS ---------- */ .pb-btn { @@ -257,18 +308,20 @@ img { max-width: 100%; display: block; } .pb-hero__copy { display: flex; flex-direction: column; gap: 22px; max-width: 620px; } .pb-hero__title { font-family: 'Cormorant', Georgia, 'Times New Roman', serif; - font-weight: 600; + font-weight: 700; font-size: clamp(44px, 7vw, 88px); line-height: 0.98; - letter-spacing: -0.028em; + letter-spacing: -0.018em; margin: 0; color: var(--pb-ink); + text-wrap: balance; } .pb-hero__accent { position: relative; display: inline-block; white-space: nowrap; font-style: italic; + font-weight: 700; color: var(--pb-green); } .pb-hero__underline { @@ -311,8 +364,41 @@ img { max-width: 100%; display: block; } margin-left: -8px; } .pb-proof__avatar:first-child { margin-left: 0; } -.pb-proof__text { margin: 0; font-size: 14px; color: var(--pb-ink-2); } +.pb-proof__text { + flex: 1; + min-width: 0; + margin: 0; + font-size: 14px; + color: var(--pb-ink-2); +} .pb-proof__text strong { color: var(--pb-ink); font-weight: 600; } +@media (max-width: 700px) { + .pb-hero { + padding-top: 32px; + } + .pb-hero__grid { + gap: 28px; + padding: 28px 0 28px; + } + .pb-hero__title { + font-size: clamp(38px, 11vw, 52px); + line-height: 1.04; + text-wrap: normal; + } + .pb-hero__accent { + white-space: normal; + } + .pb-hero__ctas { + flex-direction: column; + align-items: flex-start; + } + .pb-hero__ctas .pb-btn { + width: min(100%, 280px); + } + .pb-proof { + align-items: flex-start; + } +} /* ---------- HERO STAGE (preview cards) ---------- */ .pb-hero__stage { @@ -356,9 +442,44 @@ img { max-width: 100%; display: block; } background: var(--pb-surface); overflow: hidden; border: 1px solid var(--pb-border); - transition: transform 500ms var(--pb-ease), border-color 260ms var(--pb-ease); + box-shadow: 0 22px 52px rgba(21, 71, 52, 0.13), + 0 8px 20px rgba(21, 71, 52, 0.08), + 0 1px 0 rgba(255, 255, 255, 0.82) inset; + transition: transform 500ms var(--pb-ease), border-color 260ms var(--pb-ease), + box-shadow 260ms var(--pb-ease); will-change: transform; } +.pb-preview::before, +.pb-why__card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + z-index: 2; + pointer-events: none; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.32), transparent); +} +.pb-preview::after, +.pb-why__card::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -58%; + width: 48%; + z-index: 2; + pointer-events: none; + opacity: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.24), transparent); + transition: left 680ms var(--pb-ease), opacity 260ms ease; +} +.pb-preview:hover::after, +.pb-why__card:hover::after { + left: 118%; + opacity: 1; +} .pb-preview--back { top: 30px; left: 0; @@ -385,26 +506,31 @@ img { max-width: 100%; display: block; } .pb-preview:hover { transform: rotate(0) translateY(-6px); border-color: var(--pb-border-strong); + box-shadow: 0 30px 72px rgba(21, 71, 52, 0.18), + 0 12px 28px rgba(21, 71, 52, 0.11), + 0 1px 0 rgba(255, 255, 255, 0.88) inset; } -/* Back = gold hero card, front = green hero card (see HeroStage stack order). */ -.pb-preview--back .pb-preview__thumbIcon { color: rgba(21, 71, 52, 0.92); } -.pb-preview--front .pb-preview__thumbIcon { color: rgba(255, 248, 232, 0.95); } .pb-preview__thumb { position: relative; aspect-ratio: 16 / 10; display: flex; align-items: center; justify-content: center; + overflow: hidden; + background-color: var(--pb-cream-2); } -.pb-preview__thumbIcon { - display: flex; - align-items: center; - justify-content: center; +.pb-preview__image { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + transform: scale(1.01); } .pb-preview__badge { position: absolute; top: 10px; right: 10px; + z-index: 1; background: #FFF8E8; color: var(--pb-green); font-size: 11px; @@ -446,11 +572,6 @@ img { max-width: 100%; display: block; } width: max-content; animation: pb-marquee 50s linear infinite; } -@media (hover: hover) and (pointer: fine) { - .pb-ticker:hover .pb-ticker__track { - animation-play-state: paused; - } -} .pb-ticker__item { display: inline-flex; align-items: center; @@ -559,6 +680,7 @@ img { max-width: 100%; display: block; } to { opacity: 1; transform: translateY(0); } } .pb-why__card { + position: relative; background: var(--pb-surface); border: 1px solid var(--pb-border); border-radius: var(--pb-radius-lg); @@ -566,11 +688,19 @@ img { max-width: 100%; display: block; } display: flex; flex-direction: column; gap: 12px; - transition: transform 260ms var(--pb-ease), border-color 260ms var(--pb-ease); + overflow: hidden; + box-shadow: 0 16px 38px rgba(21, 71, 52, 0.08), + 0 4px 14px rgba(21, 71, 52, 0.055), + 0 1px 0 rgba(255, 255, 255, 0.80) inset; + transition: transform 260ms var(--pb-ease), border-color 260ms var(--pb-ease), + box-shadow 260ms var(--pb-ease); } .pb-why.pb-reveal.pb-reveal--visible .pb-why__card:hover { transform: translateY(-2px); border-color: var(--pb-border-strong); + box-shadow: 0 22px 52px rgba(21, 71, 52, 0.12), + 0 8px 20px rgba(21, 71, 52, 0.075), + 0 1px 0 rgba(255, 255, 255, 0.86) inset; } .pb-why__icon { width: 48px; @@ -921,6 +1051,8 @@ img { max-width: 100%; display: block; } transform: none !important; animation: none !important; } + .pb-preview::after, + .pb-why__card::after { display: none; } .pb-nav__link::after { display: none; } * { transition: none !important; } } diff --git a/frontend/contexts/SearchContext.tsx b/frontend/contexts/SearchContext.tsx new file mode 100644 index 00000000..d437070b --- /dev/null +++ b/frontend/contexts/SearchContext.tsx @@ -0,0 +1,44 @@ +/** + * SearchContext — shared search query state between the navbar and home screen. + * + * Lifting search state here means: + * - The navbar input and the home screen filter always share the same value. + * - Clearing search from a category chip immediately updates both without + * relying on URL param round-trips or debounce races. + */ + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; + +interface SearchContextValue { + searchQuery: string; + setSearchQuery: (q: string) => void; + clearSearch: () => void; +} + +const SearchContext = createContext({ + searchQuery: '', + setSearchQuery: () => {}, + clearSearch: () => {}, +}); + +export function SearchProvider({ children }: { children: ReactNode }) { + const [searchQuery, setSearchQueryState] = useState(''); + + const setSearchQuery = useCallback((q: string) => { + setSearchQueryState(q); + }, []); + + const clearSearch = useCallback(() => { + setSearchQueryState(''); + }, []); + + return ( + + {children} + + ); +} + +export function useSearch() { + return useContext(SearchContext); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 919a4c9a..40f70449 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], - "exclude": ["node_modules", "**/__tests__/**"] + "exclude": ["node_modules", "**/__tests__/**", ".expo/types/router.d.ts"] } diff --git a/frontend/types/assets.d.ts b/frontend/types/assets.d.ts index 75729796..b32a6136 100644 --- a/frontend/types/assets.d.ts +++ b/frontend/types/assets.d.ts @@ -3,3 +3,9 @@ declare module '*.png' { const value: ImageSourcePropType; export default value; } + +declare module '*.jpg' { + import type { ImageSourcePropType } from 'react-native'; + const value: ImageSourcePropType; + export default value; +} diff --git a/tsconfig.json b/tsconfig.json index 4e0d6951..08feee92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,9 @@ { - "compilerOptions": { - "esModuleInterop": true, - "skipLibCheck": true, - "target": "ES2020", - "module": "commonjs", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "strict": true - }, - "exclude": ["node_modules", "dist", "build", ".expo"] + // Root tsconfig — delegates to each workspace's own tsconfig. + // The frontend and backend each have their own tsconfig.json with the + // correct settings for their environment (jsx, moduleResolution, paths, etc.). + // This file exists so the IDE and root-level tooling have a valid config to + // reference without accidentally type-checking the wrong files. + "files": [], + "references": [{ "path": "./frontend" }, { "path": "./backend" }] } From 3274932bf1aa624c01d6e689cce54cc2e46862db Mon Sep 17 00:00:00 2001 From: saman Date: Wed, 6 May 2026 13:39:18 -0700 Subject: [PATCH 2/6] Fix CodeRabbit review issues --- backend/convex/convex.config.ts | 2 +- backend/tsconfig.json | 7 +++- frontend/app/(tabs)/home.tsx | 2 +- frontend/components/AppNav.web.tsx | 42 ++++++++++++++++++- frontend/components/CategoryRail.tsx | 14 +++++-- frontend/components/HomeBg.web.tsx | 3 ++ frontend/components/LandingScreen.web.tsx | 2 +- frontend/components/ListingCard.tsx | 2 +- .../components/landing/LegalDocument.web.tsx | 2 +- frontend/components/landing/assetSource.ts | 8 +++- frontend/components/landing/data.ts | 11 +++-- frontend/tsconfig.json | 23 +++++++++- package.json | 2 +- 13 files changed, 100 insertions(+), 20 deletions(-) diff --git a/backend/convex/convex.config.ts b/backend/convex/convex.config.ts index 30f2999a..11b6afa6 100644 --- a/backend/convex/convex.config.ts +++ b/backend/convex/convex.config.ts @@ -1,7 +1,7 @@ import { defineApp } from 'convex/server'; import pushNotifications from '@convex-dev/expo-push-notifications/convex.config.js'; -const app = defineApp(); +const app: ReturnType = defineApp(); app.use(pushNotifications); export default app; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 03b67b5d..b3f715d9 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -5,6 +5,9 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "composite": true, + "rootDir": "..", + "tsBuildInfoFile": "../.cache/tsbuildinfo/backend.tsbuildinfo", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, @@ -17,6 +20,6 @@ }, "types": ["node"] }, - "include": ["convex/**/*"], - "exclude": ["node_modules", "dist", "convex/__tests__"] + "include": ["convex/**/*", "../packages/shared/**/*.ts"], + "exclude": ["node_modules", "dist", "convex/**/*.test.ts", "../packages/shared/**/*.test.ts"] } diff --git a/frontend/app/(tabs)/home.tsx b/frontend/app/(tabs)/home.tsx index 9d2d21b9..406042d9 100644 --- a/frontend/app/(tabs)/home.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -40,7 +40,7 @@ import { borderRadius, colors, spacing, typography } from '../../theme/tokens'; const PAGE_SIZE = 20; const FOCUS_REFETCH_STALE_MS = 45_000; const HOMEPAGE_FONT_HREF = - 'https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,300..900,0..100&family=Inter:wght@400;500;600;700&display=swap'; + 'https://fonts.googleapis.com/css2?family=Fraunces:SOFT,opsz,wght@0..100,9..144,300..900&family=Inter:wght@400;500;600;700&display=swap'; const landingTextFont = Platform.OS === 'web' ? { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' } diff --git a/frontend/components/AppNav.web.tsx b/frontend/components/AppNav.web.tsx index 96720b9f..9f91270d 100644 --- a/frontend/components/AppNav.web.tsx +++ b/frontend/components/AppNav.web.tsx @@ -10,7 +10,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useRouter } from 'expo-router'; +import { useGlobalSearchParams, useRouter } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import { useSearch } from '../contexts/SearchContext'; import polybuysLogo from '../assets/images/PolyBuysLogo.png'; @@ -19,6 +19,11 @@ import { toWebImageSrc } from './landing/assetSource'; const DOWNLOAD_APP_LABEL = 'Download the app'; +function normalizeSearchParam(value: string | string[] | undefined): string { + const rawValue = Array.isArray(value) ? value[0] : value; + return typeof rawValue === 'string' ? rawValue.trim() : ''; +} + // ─── CSS for inner HTML elements ───────────────────────────────────────────── // Plain string constant — avoids react-native/no-raw-text ESLint crash. // Only covers elements that need CSS hover/focus/transition — the outer @@ -280,15 +285,43 @@ export function AppNav({ export function AppNavContainer() { const router = useRouter(); + const { q } = useGlobalSearchParams<{ q?: string | string[] }>(); const { searchQuery, setSearchQuery } = useSearch(); const [isScrolled, setIsScrolled] = useState(false); const rafRef = useRef(null); // Debounce timer ref — cancelled immediately when clearSearch fires const debounceRef = useRef | null>(null); + const hasHydratedUrlSearchRef = useRef(false); + const hasSkippedInitialUrlSyncRef = useRef(false); + const hydratedUrlSearchRef = useRef(null); + + useEffect(() => { + if (hasHydratedUrlSearchRef.current) return; + + const initialSearch = normalizeSearchParam(q); + hydratedUrlSearchRef.current = initialSearch; + hasHydratedUrlSearchRef.current = true; + + if (initialSearch !== searchQuery) { + setSearchQuery(initialSearch); + } + }, [q, searchQuery, setSearchQuery]); // Debounce: push search query to URL so home.tsx can read it useEffect(() => { + if (!hasHydratedUrlSearchRef.current) return; + + if (!hasSkippedInitialUrlSyncRef.current) { + hasSkippedInitialUrlSyncRef.current = true; + return; + } + + if (hydratedUrlSearchRef.current !== null && searchQuery === hydratedUrlSearchRef.current) { + hydratedUrlSearchRef.current = null; + return; + } + if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const trimmed = searchQuery.trim(); @@ -335,7 +368,12 @@ export function AppNavContainer() { }, [router]); const handleDownloadApp = useCallback(() => { - router.push('/landing#get-app' as never); + if (typeof window !== 'undefined') { + window.location.assign('/landing#get-app'); + return; + } + + router.push('/landing' as never); }, [router]); return ( diff --git a/frontend/components/CategoryRail.tsx b/frontend/components/CategoryRail.tsx index b7f5a826..a3d2799b 100644 --- a/frontend/components/CategoryRail.tsx +++ b/frontend/components/CategoryRail.tsx @@ -87,6 +87,7 @@ export function CategoryRail({ onPress={onPricePress} onClear={hasPrice ? onClearPrice : undefined} accessibilityLabel={hasPrice ? `Price: ${priceLabel}` : 'Filter by price'} + clearAccessibilityLabel="Clear price filter" /> void; onClear?: () => void; accessibilityLabel: string; + clearAccessibilityLabel?: string; }) { + const fallbackClearLabel = `Clear ${label.replace(/^Sort:\s*/i, '').toLowerCase()} filter`; + return ( diff --git a/frontend/components/HomeBg.web.tsx b/frontend/components/HomeBg.web.tsx index 7dde4d87..5f7750f1 100644 --- a/frontend/components/HomeBg.web.tsx +++ b/frontend/components/HomeBg.web.tsx @@ -68,6 +68,8 @@ const ghostCards = [ }, ] as const; +const decorativeAccessibilityProps = { 'aria-hidden': true } as const; + export function HomeBg() { const { width } = useWindowDimensions(); const isCompact = width < 700; @@ -79,6 +81,7 @@ export function HomeBg() { pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no-hide-descendants" + {...decorativeAccessibilityProps} style={styles.root} > diff --git a/frontend/components/LandingScreen.web.tsx b/frontend/components/LandingScreen.web.tsx index f9a071a5..4e59ad4b 100644 --- a/frontend/components/LandingScreen.web.tsx +++ b/frontend/components/LandingScreen.web.tsx @@ -25,7 +25,7 @@ export default function LandingScreen() { diff --git a/frontend/components/ListingCard.tsx b/frontend/components/ListingCard.tsx index cc309da1..7b41430b 100644 --- a/frontend/components/ListingCard.tsx +++ b/frontend/components/ListingCard.tsx @@ -149,7 +149,7 @@ const webCardSweepBase: object = { left: '-58%', opacity: 0, // Angled sweep shape via skew - transform: 'skewX(-12deg)', + transform: [{ skewX: '-12deg' }], backgroundColor: 'rgba(255,255,255,0.14)', transition: 'left 700ms cubic-bezier(0.25,0.46,0.45,0.94), opacity 200ms ease', }; diff --git a/frontend/components/landing/LegalDocument.web.tsx b/frontend/components/landing/LegalDocument.web.tsx index 4b414cea..b830904c 100644 --- a/frontend/components/landing/LegalDocument.web.tsx +++ b/frontend/components/landing/LegalDocument.web.tsx @@ -29,7 +29,7 @@ export function LegalDocumentPage({ diff --git a/frontend/components/landing/assetSource.ts b/frontend/components/landing/assetSource.ts index 9aa416f3..a4441bef 100644 --- a/frontend/components/landing/assetSource.ts +++ b/frontend/components/landing/assetSource.ts @@ -1,4 +1,8 @@ -type WebImageAssetObject = { uri?: string; default?: string }; +type WebImageAssetObject = { + uri?: string; + width?: number; + height?: number; +}; export function toWebImageSrc(asset: unknown): string { if (typeof asset === 'string') { @@ -7,7 +11,7 @@ export function toWebImageSrc(asset: unknown): string { if (asset && typeof asset === 'object') { const imageAsset = asset as WebImageAssetObject; - return imageAsset.uri ?? imageAsset.default ?? ''; + return imageAsset.uri ?? ''; } return ''; diff --git a/frontend/components/landing/data.ts b/frontend/components/landing/data.ts index 84d3b82a..820b55fb 100644 --- a/frontend/components/landing/data.ts +++ b/frontend/components/landing/data.ts @@ -4,10 +4,15 @@ import { PRODUCT_IMAGES } from './productImages'; export { APP_STORE_URL }; -type QrAsset = string | { uri?: string; default?: string }; +type QrAsset = + | string + | { + uri?: string; + width?: number; + height?: number; + }; const qrAsset = qrDownloadPng as QrAsset; -export const QR_SRC = - typeof qrAsset === 'string' ? qrAsset : (qrAsset.uri ?? qrAsset.default ?? ''); +export const QR_SRC = typeof qrAsset === 'string' ? qrAsset : (qrAsset.uri ?? ''); export type ListingThumbIconId = 'textbook' | 'furniture'; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 40f70449..3cf2f8b1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,6 +2,10 @@ "extends": "../node_modules/expo/tsconfig.base.json", "compilerOptions": { "strict": true, + "composite": true, + "noEmit": true, + "rootDir": "..", + "tsBuildInfoFile": "../.cache/tsbuildinfo/frontend.tsbuildinfo", "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, @@ -15,6 +19,21 @@ "convex/*": ["../backend/convex/*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], - "exclude": ["node_modules", "**/__tests__/**", ".expo/types/router.d.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "../backend/convex/**/*.ts", + "../backend/convex/**/*.d.ts", + "../packages/shared/**/*.ts" + ], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "../backend/convex/**/*.test.ts", + "../packages/shared/**/*.test.ts", + ".expo/types/router.d.ts" + ] } diff --git a/package.json b/package.json index f21e1ac5..05ad4b04 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test": "jest", "test:watch": "jest --watch", "clean": "rm -rf node_modules frontend/node_modules backend/node_modules packages/shared/node_modules", - "typecheck": "npm run typecheck --workspaces --if-present", + "typecheck": "tsc -b", "prepare": "node -e \"if (process.env.CI !== '1' && process.env.VERCEL !== '1') { require('child_process').execSync('husky', {stdio: 'inherit'}) }\"" }, "devDependencies": { From f1625235aa44dd999e04d23c14e508e51b857652 Mon Sep 17 00:00:00 2001 From: Evan Taylor Date: Wed, 6 May 2026 14:34:56 -0700 Subject: [PATCH 3/6] fix: download button when browsing listings and add site favicon, broken share links --- frontend/app.json | 3 +- frontend/components/AppNav.web.tsx | 55 +-- frontend/components/LandingScreen.web.tsx | 11 + frontend/components/landing/index.ts | 2 +- frontend/components/landing/styles.ts | 389 ++++++++++++---------- 5 files changed, 235 insertions(+), 225 deletions(-) diff --git a/frontend/app.json b/frontend/app.json index fa6170da..8f88d2ab 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -111,7 +111,8 @@ "typedRoutes": true }, "web": { - "bundler": "metro" + "bundler": "metro", + "favicon": "./assets/images/icon.png" }, "extra": { "router": { diff --git a/frontend/components/AppNav.web.tsx b/frontend/components/AppNav.web.tsx index 9f91270d..c1e47bcd 100644 --- a/frontend/components/AppNav.web.tsx +++ b/frontend/components/AppNav.web.tsx @@ -14,7 +14,7 @@ import { useGlobalSearchParams, useRouter } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import { useSearch } from '../contexts/SearchContext'; import polybuysLogo from '../assets/images/PolyBuysLogo.png'; -import { AppleIcon } from './landing/AppleIcon'; +import { DOWNLOAD_CTA_CSS, DownloadButton } from './landing'; import { toWebImageSrc } from './landing/assetSource'; const DOWNLOAD_APP_LABEL = 'Download the app'; @@ -33,8 +33,9 @@ const NAV_CSS = /* reset */ '.pbn*,.pbn*::before,.pbn*::after{box-sizing:border-box}' + '.pbn a{text-decoration:none;color:inherit}' + - /* reset form controls inside the web nav */ - '.pbn button{font-family:inherit;cursor:pointer;border:none;padding:0;background:none}' + + /* reset form controls inside the web nav (skip .pb-btn so the shared landing + button styles aren't clobbered by this reset's higher specificity) */ + '.pbn button:not(.pb-btn){font-family:inherit;cursor:pointer;border:none;padding:0;background:none}' + '.pbn input{font-family:inherit;outline:none}' + /* ── inner row: 3-column grid ── */ '.pbn__row{' + @@ -97,17 +98,9 @@ const NAV_CSS = '.pbn__search-clear:hover{background:#154734;color:#fff;transform:translateY(-50%) scale(1.10)}' + /* ── RIGHT: actions ── */ '.pbn__actions{display:flex;align-items:center;justify-self:end;flex-shrink:0}' + - '.pbn__download{' + - 'appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:10px;' + - 'height:36px;padding:0 16px;border-radius:999px;border:0;' + - 'background:#154734;color:#FFF8E8;' + - 'font-size:14px;font-weight:600;letter-spacing:0;line-height:1;white-space:nowrap;cursor:pointer;' + - 'transition:transform 200ms cubic-bezier(0.2,0.7,0.2,1),background 200ms cubic-bezier(0.2,0.7,0.2,1),color 200ms cubic-bezier(0.2,0.7,0.2,1),border-color 200ms cubic-bezier(0.2,0.7,0.2,1)' + - '}' + - '.pbn__download .pb-apple-icon{display:inline-block;vertical-align:-2px;margin-right:2px;flex-shrink:0}' + - '.pbn__download:hover:not(:active){background:#1E5C44;transform:translateY(-1px)}' + - '.pbn__download:active{transform:scale(0.98)}' + - '.pbn__download:focus-visible{outline:3px solid rgba(226,168,74,0.55);outline-offset:3px}' + + /* compact-button overrides for the shared .pb-btn--sm in this navbar */ + '.pbn .pb-btn--sm{height:36px;padding:0 16px;font-size:14px;gap:10px}' + + '.pbn .pb-apple-icon{flex-shrink:0}' + /* ── compact row (mobile < 640px) ── */ '.pbn__compact{display:none;padding:0 16px 12px;align-items:center}' + '.pbn__search--full{max-width:none;flex:1}' + @@ -117,8 +110,8 @@ const NAV_CSS = '.pbn__search{display:none}' + '.pbn__compact{display:flex}' + '.pbn__logo{width:106px}' + - '.pbn__download{height:34px;padding:0 11px;font-size:13px;gap:7px}' + - '.pbn__download .pb-apple-icon{width:11px;height:auto;margin-right:0}' + + '.pbn .pb-btn--sm{height:34px;padding:0 11px;font-size:13px;gap:7px}' + + '.pbn .pb-apple-icon{width:11px;height:auto;margin-right:0}' + '}' + '@media(min-width:480px) and (max-width:639px){' + '.pbn__row{height:60px;padding:0 16px;gap:12px;grid-template-columns:auto auto;justify-content:space-between}' + @@ -129,11 +122,11 @@ const NAV_CSS = '@media(min-width:640px) and (max-width:899px){' + '.pbn__row{padding:0 20px;gap:14px}' + '.pbn__search{max-width:260px}' + - '.pbn__download{padding:0 14px}' + + '.pbn .pb-btn--sm{padding:0 14px}' + '}' + /* ── reduced motion ── */ '@media(prefers-reduced-motion:reduce){' + - '.pbn__brand,.pbn__search-input,.pbn__download,.pbn__search-clear{transition:none!important}' + + '.pbn__brand,.pbn__search-input,.pbn__search-clear{transition:none!important}' + '}'; // ─── SVG icons ──────────────────────────────────────────────────────────────── @@ -181,7 +174,6 @@ interface AppNavProps { searchValue: string; onSearchChange: (value: string) => void; onHomePress: () => void; - onDownloadApp: () => void; isScrolled: boolean; } @@ -190,7 +182,6 @@ export function AppNav({ searchValue, onSearchChange, onHomePress, - onDownloadApp, isScrolled, }: AppNavProps) { const logoSrc = toWebImageSrc(polybuysLogo); @@ -230,6 +221,7 @@ export function AppNav({ // Outer shell: React Native View so it participates in the RN flex column // layout correctly (navbar above, page content below). +
{/* Main row: logo | search | actions */} @@ -259,18 +251,7 @@ export function AppNav({ {/* RIGHT */}
@@ -367,22 +348,12 @@ export function AppNavContainer() { router.replace('/home' as never); }, [router]); - const handleDownloadApp = useCallback(() => { - if (typeof window !== 'undefined') { - window.location.assign('/landing#get-app'); - return; - } - - router.push('/landing' as never); - }, [router]); - return ( ); diff --git a/frontend/components/LandingScreen.web.tsx b/frontend/components/LandingScreen.web.tsx index 4e59ad4b..6e371539 100644 --- a/frontend/components/LandingScreen.web.tsx +++ b/frontend/components/LandingScreen.web.tsx @@ -1,8 +1,12 @@ import { useEffect } from 'react'; import Head from 'expo-router/head'; +import polybuysIcon from '../assets/images/icon.png'; import { Footer, GetApp, GLOBAL_CSS, Hero, Nav, Why, useScrolled } from './landing'; +import { toWebImageSrc } from './landing/assetSource'; import { scrollToLandingHash } from './landing/scrollToLandingSection'; +const faviconHref = toWebImageSrc(polybuysIcon); + export default function LandingScreen() { const scrolled = useScrolled(); @@ -21,6 +25,13 @@ export default function LandingScreen() { name="description" content="Buy and sell with verified Cal Poly students. Textbooks, housing, bikes, furniture — browse on the web or get the iOS app." /> + + {faviconHref ? ( + <> + + + + ) : null} outside the landing page + (e.g. the in-app web navbar). Self-contained: includes the CSS variables it + depends on plus button, Apple-icon, and QR-modal rules. Concatenated into + GLOBAL_CSS below so the landing page picks up the same definitions. */ +export const DOWNLOAD_CTA_CSS = ` +:root { + --pb-ink: #14130F; + --pb-ink-3: #7A7A74; + --pb-green: #154734; + --pb-green-2: #1E5C44; + --pb-border: rgba(21, 71, 52, 0.10); + --pb-border-strong: rgba(21, 71, 52, 0.18); + --pb-radius-xl: 28px; + --pb-ease: cubic-bezier(0.2, 0.7, 0.2, 1); +} + +/* ---------- BUTTONS ---------- */ +.pb-btn { + --h: 44px; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + border: 0; + border-radius: 999px; + padding: 0 22px; + height: var(--h); + font-family: inherit; + font-weight: 600; + font-size: 15px; + letter-spacing: -0.005em; + cursor: pointer; + text-decoration: none; + transition: transform 200ms var(--pb-ease), background 200ms var(--pb-ease), + color 200ms var(--pb-ease), border-color 200ms var(--pb-ease); + white-space: nowrap; +} +.pb-btn:focus-visible { + outline: 3px solid rgba(226, 168, 74, 0.55); + outline-offset: 3px; +} +.pb-btn--sm { --h: 36px; padding: 0 16px; font-size: 14px; } +.pb-btn--md { --h: 44px; } +.pb-btn--lg { --h: 54px; padding: 0 26px; font-size: 16px; } +.pb-btn--primary { + background: var(--pb-green); + color: #FFF8E8; +} +.pb-btn--primary:hover:not(:active) { + background: var(--pb-green-2); + transform: translateY(-1px); +} +.pb-btn--primary:active { + transform: scale(0.98); +} +.pb-btn--ghost { + background: transparent; + color: var(--pb-ink); + border: 1px solid var(--pb-border-strong); +} +.pb-btn--ghost:hover { + background: rgba(21, 71, 52, 0.06); + border-color: rgba(21, 71, 52, 0.28); +} +.pb-btn--ghostOnDark { + background: transparent; + color: #FFF8E8; + border: 1px solid rgba(255, 248, 232, 0.35); +} +.pb-btn--ghostOnDark:hover { + background: rgba(255, 248, 232, 0.08); + border-color: rgba(255, 248, 232, 0.6); +} +.pb-btn--cream { + background: #FFF8E8; + color: var(--pb-green); +} +.pb-btn--cream:hover:not(:active) { + background: #fff; + color: var(--pb-green-2); + transform: translateY(-1px); +} +.pb-btn--cream:active { + transform: scale(0.98); +} +.pb-btn--ghost:active, +.pb-btn--ghostOnDark:active { + transform: scale(0.98); +} +.pb-btn__arrow { + display: inline-block; + transform: translateX(0); + transition: transform 220ms var(--pb-ease); + font-weight: 400; +} +.pb-btn:hover .pb-btn__arrow { transform: translateX(3px); } + +/* ---------- APPLE ICON ---------- */ +.pb-apple-icon { + display: inline-block; + vertical-align: -2px; + margin-right: 2px; +} + +/* ---------- DOWNLOAD QR MODAL ---------- */ +/* No opacity animation on this wrapper: animating parent opacity breaks/defers child + backdrop-filter in WebKit/Blink (dim + blur appears late). Card keeps pb-popIn only. */ +.pb-qrmodal { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) + max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + min-height: 100vh; + min-height: 100dvh; + box-sizing: border-box; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.pb-qrmodal__backdrop { + position: absolute; + inset: 0; + background: rgba(20, 30, 25, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.pb-qrmodal__card { + position: relative; + background: #fff; + border-radius: var(--pb-radius-xl); + padding: 28px 32px 22px; + border: 1px solid var(--pb-border); + width: min(320px, 100%); + flex-shrink: 0; + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + animation: pb-popIn 220ms var(--pb-ease); +} +.pb-qrmodal__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--pb-ink); + display: inline-flex; + align-items: center; + gap: 4px; + letter-spacing: -0.01em; +} +.pb-qrmodal__qr { + width: 232px; + height: 232px; + border-radius: 10px; + background: #fff; +} +.pb-qrmodal__caption { + margin: 0; + font-size: 13px; + color: var(--pb-ink-3); +} +.pb-qrmodal__close { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: 0; + cursor: pointer; + width: 30px; + height: 30px; + border-radius: 999px; + font-size: 14px; + color: var(--pb-ink-3); + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 150ms var(--pb-ease), color 150ms var(--pb-ease); +} +.pb-qrmodal__close:hover { + background: rgba(0, 0, 0, 0.05); + color: var(--pb-ink); +} +.pb-qrmodal__close:focus-visible { + outline: 3px solid rgba(226, 168, 74, 0.55); + outline-offset: 2px; +} +@keyframes pb-popIn { + from { opacity: 0; transform: scale(0.94); } + to { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .pb-qrmodal__card { animation: none !important; } +} +`; + +export const GLOBAL_CSS = + ` :root { --pb-ink: #14130F; --pb-ink-2: #4A4A48; @@ -191,87 +393,8 @@ img { max-width: 100%; display: block; } .pb-nav .pb-apple-icon { width: 11px; height: auto; margin-right: 0; } } -/* ---------- BUTTONS ---------- */ -.pb-btn { - --h: 44px; - appearance: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - border: 0; - border-radius: 999px; - padding: 0 22px; - height: var(--h); - font-family: inherit; - font-weight: 600; - font-size: 15px; - letter-spacing: -0.005em; - cursor: pointer; - text-decoration: none; - transition: transform 200ms var(--pb-ease), background 200ms var(--pb-ease), - color 200ms var(--pb-ease), border-color 200ms var(--pb-ease); - white-space: nowrap; -} -.pb-btn:focus-visible { - outline: 3px solid rgba(226, 168, 74, 0.55); - outline-offset: 3px; -} -.pb-btn--sm { --h: 36px; padding: 0 16px; font-size: 14px; } -.pb-btn--md { --h: 44px; } -.pb-btn--lg { --h: 54px; padding: 0 26px; font-size: 16px; } -.pb-btn--primary { - background: var(--pb-green); - color: #FFF8E8; -} -.pb-btn--primary:hover:not(:active) { - background: var(--pb-green-2); - transform: translateY(-1px); -} -.pb-btn--primary:active { - transform: scale(0.98); -} -.pb-btn--ghost { - background: transparent; - color: var(--pb-ink); - border: 1px solid var(--pb-border-strong); -} -.pb-btn--ghost:hover { - background: rgba(21, 71, 52, 0.06); - border-color: rgba(21, 71, 52, 0.28); -} -.pb-btn--ghostOnDark { - background: transparent; - color: #FFF8E8; - border: 1px solid rgba(255, 248, 232, 0.35); -} -.pb-btn--ghostOnDark:hover { - background: rgba(255, 248, 232, 0.08); - border-color: rgba(255, 248, 232, 0.6); -} -.pb-btn--cream { - background: #FFF8E8; - color: var(--pb-green); -} -.pb-btn--cream:hover:not(:active) { - background: #fff; - color: var(--pb-green-2); - transform: translateY(-1px); -} -.pb-btn--cream:active { - transform: scale(0.98); -} -.pb-btn--ghost:active, -.pb-btn--ghostOnDark:active { - transform: scale(0.98); -} -.pb-btn__arrow { - display: inline-block; - transform: translateX(0); - transition: transform 220ms var(--pb-ease); - font-weight: 400; -} -.pb-btn:hover .pb-btn__arrow { transform: translateX(3px); } +/* Button + Apple-icon + QR-modal styles live in DOWNLOAD_CTA_CSS (above) and + are concatenated to GLOBAL_CSS at the bottom of this file. */ /* ---------- EYEBROW ---------- */ .pb-eyebrow { @@ -939,103 +1062,8 @@ img { max-width: 100%; display: block; } } } -/* ---------- APPLE ICON ---------- */ -.pb-apple-icon { - display: inline-block; - vertical-align: -2px; - margin-right: 2px; -} - -/* ---------- DOWNLOAD QR MODAL ---------- */ -/* No opacity animation on this wrapper: animating parent opacity breaks/defers child - backdrop-filter in WebKit/Blink (dim + blur appears late). Card keeps pb-popIn only. */ -.pb-qrmodal { - position: fixed; - inset: 0; - z-index: 10000; - display: flex; - align-items: center; - justify-content: center; - padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) - max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); - min-height: 100vh; - min-height: 100dvh; - box-sizing: border-box; - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} -.pb-qrmodal__backdrop { - position: absolute; - inset: 0; - background: rgba(20, 30, 25, 0.55); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} -.pb-qrmodal__card { - position: relative; - background: #fff; - border-radius: var(--pb-radius-xl); - padding: 28px 32px 22px; - border: 1px solid var(--pb-border); - width: min(320px, 100%); - flex-shrink: 0; - margin: auto; - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - animation: pb-popIn 220ms var(--pb-ease); -} -.pb-qrmodal__title { - margin: 0; - font-size: 15px; - font-weight: 600; - color: var(--pb-ink); - display: inline-flex; - align-items: center; - gap: 4px; - letter-spacing: -0.01em; -} -.pb-qrmodal__qr { - width: 232px; - height: 232px; - border-radius: 10px; - background: #fff; -} -.pb-qrmodal__caption { - margin: 0; - font-size: 13px; - color: var(--pb-ink-3); -} -.pb-qrmodal__close { - position: absolute; - top: 10px; - right: 10px; - background: transparent; - border: 0; - cursor: pointer; - width: 30px; - height: 30px; - border-radius: 999px; - font-size: 14px; - color: var(--pb-ink-3); - display: inline-flex; - align-items: center; - justify-content: center; - transition: background 150ms var(--pb-ease), color 150ms var(--pb-ease); -} -.pb-qrmodal__close:hover { - background: rgba(0, 0, 0, 0.05); - color: var(--pb-ink); -} -.pb-qrmodal__close:focus-visible { - outline: 3px solid rgba(226, 168, 74, 0.55); - outline-offset: 2px; -} -@keyframes pb-popIn { - from { opacity: 0; transform: scale(0.94); } - to { opacity: 1; transform: scale(1); } -} +/* Apple-icon + QR-modal styles live in DOWNLOAD_CTA_CSS (top of file) and are + appended to GLOBAL_CSS at the bottom. */ /* ---------- MOTION PREFS ---------- */ @media (prefers-reduced-motion: reduce) { @@ -1043,7 +1071,6 @@ img { max-width: 100%; display: block; } .pb-preview--back { transform: rotate(-6deg); } .pb-preview--front { transform: rotate(4deg); } .pb-hero__underline { stroke-dashoffset: 0; animation: none; } - .pb-qrmodal__card { animation: none !important; } .pb-why .pb-section__head, .pb-why .pb-why__card, .pb-getapp .pb-getapp__panel { @@ -1056,4 +1083,4 @@ img { max-width: 100%; display: block; } .pb-nav__link::after { display: none; } * { transition: none !important; } } -`; +` + DOWNLOAD_CTA_CSS; From 2cd88634a83c16272d4a8e54064e3c613b5fd545 Mon Sep 17 00:00:00 2001 From: Evan Taylor Date: Wed, 6 May 2026 14:35:11 -0700 Subject: [PATCH 4/6] fix: browsing listings url --- frontend/app/l/[id].tsx | 72 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/frontend/app/l/[id].tsx b/frontend/app/l/[id].tsx index 6383903a..10639374 100644 --- a/frontend/app/l/[id].tsx +++ b/frontend/app/l/[id].tsx @@ -1,11 +1,75 @@ +import { useEffect, useMemo } from 'react'; +import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native'; +import Constants from 'expo-constants'; import { Redirect, useLocalSearchParams } from 'expo-router'; +import Head from 'expo-router/head'; +import { colors, typography } from '../../theme/tokens'; + +const DEFAULT_APP_ORIGIN = 'https://www.polybuys.com'; + +function getAppOrigin(): string { + const value = Constants.expoConfig?.extra?.appOrigin; + if (typeof value !== 'string') return DEFAULT_APP_ORIGIN; + const trimmed = value.trim(); + if (!trimmed) return DEFAULT_APP_ORIGIN; + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; +} export default function ShortListingRedirect() { - const { id } = useLocalSearchParams<{ id?: string }>(); + const { id } = useLocalSearchParams<{ id?: string | string[] }>(); + const cleanId = useMemo(() => { + const raw = Array.isArray(id) ? id[0] : id; + return typeof raw === 'string' ? raw.trim() : ''; + }, [id]); + + const target = cleanId.length > 0 ? `/listings/${encodeURIComponent(cleanId)}` : '/home'; - if (typeof id !== 'string' || id.trim().length === 0) { - return ; + // Web: hard-redirect via window.location.replace. Using alone has + // been unreliable on cold SPA loads (the route mounts but navigation never + // fires). location.replace is unambiguous and preserves history correctly. + useEffect(() => { + if (Platform.OS !== 'web' || typeof window === 'undefined') return; + window.location.replace(target); + }, [target]); + + // Native: keep router-based redirect so deep-linking handlers and in-app + // navigation behave normally. + if (Platform.OS !== 'web') { + return ; } - return ; + const appOrigin = getAppOrigin(); + + return ( + <> + + {/* eslint-disable-next-line react-native/no-raw-text */} + PolyBuys + {/* Meta-refresh as a no-JS fallback so the link still works in + preview crawlers and disabled-JS environments. */} + + + + + + + Opening listing… + + + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + gap: 12, + backgroundColor: colors.surface, + }, + text: { + ...typography.body, + color: colors.textDark, + }, +}); From 2848cdaa46f985f1d1e8021299ee5c417cce426b Mon Sep 17 00:00:00 2001 From: saman Date: Wed, 6 May 2026 19:08:33 -0700 Subject: [PATCH 5/6] feat: fixed the comments --- frontend/app/listings/[id].tsx | 776 +++++++++++++++++++-------------- 1 file changed, 460 insertions(+), 316 deletions(-) diff --git a/frontend/app/listings/[id].tsx b/frontend/app/listings/[id].tsx index a248f314..fc8cb089 100644 --- a/frontend/app/listings/[id].tsx +++ b/frontend/app/listings/[id].tsx @@ -62,6 +62,27 @@ import { APP_STORE_URL } from '../../constants/app'; import { REPORT_SUBMITTED_MESSAGE } from '../../constants/feedbackMessages'; const DEFAULT_APP_ORIGIN = 'https://www.polybuys.com'; +const DETAIL_FONT_HREF = + 'https://fonts.googleapis.com/css2?family=Fraunces:SOFT,opsz,wght@0..100,9..144,300..900&family=Inter:wght@400;500;600;700;800&display=swap'; +const EMPTY_IMAGE_IDS: string[] = []; +const detailTextFont = + Platform.OS === 'web' + ? { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' } + : undefined; +const detailDisplayFont = + Platform.OS === 'web' ? { fontFamily: 'Fraunces, Georgia, serif' } : undefined; + +function isRemoteImageUrl(value: string) { + return value.startsWith('http://') || value.startsWith('https://'); +} + +function formatLabel(value: string) { + if (!value) { + return 'Unknown'; + } + + return value.charAt(0).toUpperCase() + value.slice(1); +} function normalizeAppOrigin(value: unknown) { if (typeof value !== 'string') { @@ -126,13 +147,25 @@ export default function ListingDetailScreen() { const [messageBody, setMessageBody] = useState(''); const [isSendingMessage, setIsSendingMessage] = useState(false); const messageComposerTranslateY = useSharedValue(0); - const { mappedUrls } = useResolvedImageUrls(listing?.images ?? []); + const [failedImageUrls, setFailedImageUrls] = useState>({}); + const listingImageIds = listing?.images ?? EMPTY_IMAGE_IDS; + const { mappedUrls, resolvedUrls } = useResolvedImageUrls(listingImageIds); const { width: screenWidth } = useWindowDimensions(); - const contentMaxWidth = 980; - const contentHorizontalPadding = spacing.lg * 2; - const cardHorizontalPadding = spacing.xl * 2; - const imageWidth = - Math.min(screenWidth, contentMaxWidth) - contentHorizontalPadding - cardHorizontalPadding; + const contentMaxWidth = 1120; + const isWideLayout = screenWidth >= 900; + const contentHorizontalPadding = + screenWidth >= 900 ? spacing.xxl : screenWidth >= 520 ? spacing.lg : spacing.md; + const layoutGap = isWideLayout ? spacing.xl : spacing.lg; + const shellWidth = Math.max( + 0, + Math.min(screenWidth, contentMaxWidth) - contentHorizontalPadding * 2 + ); + const imageWidth = isWideLayout + ? Math.min(540, Math.max(360, Math.floor((shellWidth - layoutGap) * 0.52))) + : shellWidth; + const imageHeight = isWideLayout + ? Math.round(imageWidth * 0.78) + : Math.min(360, Math.max(248, Math.round(imageWidth * 0.72))); const sellerProfile = useQuery( api.profiles.getProfileByUserId, listing?.sellerId ? { userId: listing.sellerId } : 'skip' @@ -141,9 +174,10 @@ export default function ListingDetailScreen() { sellerProfile?.picture ? [sellerProfile.picture] : [] ); const sellerAvatarUrl = sellerAvatarUrls[0] ?? null; - const hasMultipleImages = mappedUrls.length > 1; + const displayImageUrls = mappedUrls.map((url) => (url && !failedImageUrls[url] ? url : null)); + const hasMultipleImages = displayImageUrls.length > 1; const hasPreviousImage = imageIndex > 0; - const hasNextImage = imageIndex < mappedUrls.length - 1; + const hasNextImage = imageIndex < displayImageUrls.length - 1; const bookmarkScale = useRef(new Animated.Value(1)).current; const prevDisplayedSavedRef = useRef(null); const displayedSaved = savedOptimistic ?? isSaved ?? false; @@ -187,19 +221,30 @@ export default function ListingDetailScreen() { useEffect(() => { setImageIndex((currentIndex) => { - if (mappedUrls.length === 0) { + if (displayImageUrls.length === 0) { return 0; } - return Math.min(currentIndex, mappedUrls.length - 1); + return Math.min(currentIndex, displayImageUrls.length - 1); }); - }, [mappedUrls.length]); + }, [displayImageUrls.length]); useEffect(() => { prevDisplayedSavedRef.current = null; setSavedOptimistic(null); + setFailedImageUrls({}); bookmarkScale.setValue(1); }, [bookmarkScale, listingId]); + const onDetailImageError = useCallback((imageUrl: string) => { + setFailedImageUrls((current) => { + if (current[imageUrl]) { + return current; + } + + return { ...current, [imageUrl]: true }; + }); + }, []); + useEffect(() => { if (prevDisplayedSavedRef.current === null) { prevDisplayedSavedRef.current = displayedSaved; @@ -381,7 +426,7 @@ export default function ListingDetailScreen() { const goToNextImage = () => { if (!hasNextImage) return; - setImageIndex((currentIndex) => Math.min(currentIndex + 1, mappedUrls.length - 1)); + setImageIndex((currentIndex) => Math.min(currentIndex + 1, displayImageUrls.length - 1)); }; useEffect(() => { @@ -419,13 +464,88 @@ export default function ListingDetailScreen() { return ; } + const isListingImagePending = (index: number) => { + const imageId = listingImageIds[index]; + return Boolean(imageId && !isRemoteImageUrl(imageId) && resolvedUrls[imageId] === undefined); + }; + + const getImageFallbackCopy = (index: number) => { + if (isListingImagePending(index)) { + return { + title: 'Loading photo...', + body: 'Resolving the uploaded image.', + }; + } + + return { + title: 'Photo unavailable', + body: 'This listing image could not be displayed.', + }; + }; + + const renderImageFallback = (title: string, body: string) => ( + + + + + {title} + {body} + + ); + + const renderImageFrame = (imageUrl: string | null, frameIndex: number) => { + const fallbackCopy = getImageFallbackCopy(frameIndex); + + return ( + + {imageUrl ? ( + [styles.heroImagePressable, pressed && styles.imagePressed]} + onPress={() => { + setImageIndex(frameIndex); + setLightboxOpen(true); + }} + accessibilityLabel="View full image" + accessibilityRole="button" + > + onDetailImageError(imageUrl)} + /> + + ) : ( + renderImageFallback(fallbackCopy.title, fallbackCopy.body) + )} + + ); + }; + + const postedDateLabel = formatRelativeDate(listing.postedOn ?? listing.createdAt); + const categoryLabel = formatLabel(listing.category); + const conditionLabel = formatLabel(listing.condition); + const lightboxImages = displayImageUrls.filter((url): url is string => url !== null); + const lightboxInitialIndex = Math.max( + 0, + displayImageUrls.slice(0, imageIndex + 1).filter((url): url is string => url !== null).length - + 1 + ); + return ( + {Platform.OS === 'web' && ( + <> + + + + + )} {/* eslint-disable-next-line react-native/no-raw-text */} {`${listing.title} - PolyBuys`} @@ -454,281 +574,236 @@ export default function ListingDetailScreen() {
)} - + [styles.backLink, pressed && styles.buttonPressed]} + onPress={() => router.push('/home')} + accessibilityRole="button" + accessibilityLabel="Back to listings" + > + + Back to listings + + + {isHiddenOwnerView && } - {listing.images.length > 0 ? ( - - {hasMultipleImages && Platform.OS === 'web' ? ( - <> - - {mappedUrls[imageIndex] ? ( - setLightboxOpen(true)} - accessibilityLabel="View full image" - > - - - ) : ( - - Loading image... - - )} - [ - styles.carouselArrow, - styles.carouselArrowLeft, - !hasPreviousImage && styles.carouselArrowDisabled, - pressed && hasPreviousImage && styles.buttonPressed, - ]} - onPress={goToPreviousImage} - disabled={!hasPreviousImage} - accessibilityRole="button" - accessibilityLabel="Previous image" - > - - - [ - styles.carouselArrow, - styles.carouselArrowRight, - !hasNextImage && styles.carouselArrowDisabled, - pressed && hasNextImage && styles.buttonPressed, - ]} - onPress={goToNextImage} - disabled={!hasNextImage} - accessibilityRole="button" - accessibilityLabel="Next image" - > - - - - - - {imageIndex + 1} of {mappedUrls.length} - - - {mappedUrls.map((_, i) => ( - setImageIndex(i)} - style={[ - styles.dot, - i === imageIndex ? styles.dotActive : styles.dotInactive, - ]} - accessibilityRole="button" - accessibilityLabel={`View image ${i + 1}`} - /> - ))} - - - - ) : hasMultipleImages ? ( - <> - String(i)} - horizontal - pagingEnabled - showsHorizontalScrollIndicator={false} - onMomentumScrollEnd={(e: NativeSyntheticEvent) => { - const idx = Math.round( - e.nativeEvent.contentOffset.x / e.nativeEvent.layoutMeasurement.width - ); - setImageIndex(idx); - }} - renderItem={({ item, index: itemIndex }) => ( - - {item ? ( + + + {listingImageIds.length > 0 ? ( + + {hasMultipleImages && Platform.OS !== 'web' ? ( + String(i)} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + onMomentumScrollEnd={(e: NativeSyntheticEvent) => { + const idx = Math.round( + e.nativeEvent.contentOffset.x / e.nativeEvent.layoutMeasurement.width + ); + setImageIndex(idx); + }} + renderItem={({ item, index: itemIndex }) => renderImageFrame(item, itemIndex)} + /> + ) : ( + renderImageFrame(displayImageUrls[imageIndex] ?? null, imageIndex) + )} + + {hasMultipleImages && ( + + + {imageIndex + 1} of {displayImageUrls.length} + + + {displayImageUrls.map((_, i) => ( { - setImageIndex(itemIndex); - setLightboxOpen(true); - }} - accessibilityLabel="View full image" - > - - - ) : ( - - Loading image... - - )} + key={i} + onPress={() => setImageIndex(i)} + style={[ + styles.dot, + i === imageIndex ? styles.dotActive : styles.dotInactive, + ]} + accessibilityRole="button" + accessibilityLabel={`View image ${i + 1}`} + /> + ))} - )} - /> - - - {imageIndex + 1} of {mappedUrls.length} - - - {mappedUrls.map((_, i) => ( - - ))} - - - ) : mappedUrls[0] ? ( - - setLightboxOpen(true)} - accessibilityLabel="View full image" - > - - + )} + + {hasMultipleImages && Platform.OS === 'web' ? ( + <> + [ + styles.carouselArrow, + styles.carouselArrowLeft, + !hasPreviousImage && styles.carouselArrowDisabled, + pressed && hasPreviousImage && styles.buttonPressed, + ]} + onPress={goToPreviousImage} + disabled={!hasPreviousImage} + accessibilityRole="button" + accessibilityLabel="Previous image" + > + + + [ + styles.carouselArrow, + styles.carouselArrowRight, + !hasNextImage && styles.carouselArrowDisabled, + pressed && hasNextImage && styles.buttonPressed, + ]} + onPress={goToNextImage} + disabled={!hasNextImage} + accessibilityRole="button" + accessibilityLabel="Next image" + > + + + + ) : null} ) : ( - - - Loading image... - + + {renderImageFallback('No photo added', 'This listing does not have an image yet.')} )} - ) : ( - - No image provided - - )} - - - {listing.title} - ${formatPrice(listing.price)} - - {!isOwner && ( - - [styles.messageButton, pressed && styles.buttonPressed]} - onPress={() => void onMessageSellerPress()} - > - Message Seller - - void onSavePress()} - accessibilityLabel={displayedSaved ? 'Unsave listing' : 'Save listing'} - pressedScale={0.94} - > - - - - - void shareListing()} - accessibilityLabel="Share listing" - pressedScale={0.94} - > - - - - )} + + + Campus marketplace + + {listing.title} + + + ${formatPrice(listing.price)} + + - - - - + {!isOwner && ( + + [styles.messageButton, pressed && styles.buttonPressed]} + onPress={() => void onMessageSellerPress()} + accessibilityRole="button" + accessibilityLabel="Message seller" + > + + Message seller + + void onSavePress()} + accessibilityLabel={displayedSaved ? 'Unsave listing' : 'Save listing'} + pressedScale={0.94} + > + + + + + void shareListing()} + accessibilityLabel="Share listing" + pressedScale={0.94} + > + + + + )} - - Posted {formatRelativeDate(listing.postedOn ?? listing.createdAt)} - + + + + - Description - {listing.description} + + Posted {postedDateLabel} + - {sellerProfile && ( - router.push(`/profile/${encodeURIComponent(listing.sellerId)}` as never)} - accessibilityLabel={`View ${sellerProfile.name}'s profile`} - accessibilityRole="button" - > - - - {sellerProfile.name} - - {formatMajorLabel(sellerProfile.major)} · Year {sellerProfile.year} - - - - )} + Description + + {listing.description} + - {isOwner && !isHidden && listing.status !== 'sold' && ( - - {listing.status === 'active' && ( + {sellerProfile && ( [ - styles.markSoldButton, - pressed && styles.buttonPressed, - markingSold && styles.buttonDisabled, - ]} - onPress={() => void onMarkSoldPress()} - disabled={markingSold} - accessibilityLabel="Mark as sold" + style={({ pressed }) => [styles.sellerBlock, pressed && styles.buttonPressed]} + onPress={() => + router.push(`/profile/${encodeURIComponent(listing.sellerId)}` as never) + } + accessibilityLabel={`View ${sellerProfile.name}'s profile`} accessibilityRole="button" > - {markingSold ? ( - - ) : ( - Mark as Sold + + + + {sellerProfile.name} + + + {formatMajorLabel(sellerProfile.major)} - Year {sellerProfile.year} + + + + + )} + + {isOwner && !isHidden && listing.status !== 'sold' && ( + + {listing.status === 'active' && ( + [ + styles.markSoldButton, + pressed && styles.buttonPressed, + markingSold && styles.buttonDisabled, + ]} + onPress={() => void onMarkSoldPress()} + disabled={markingSold} + accessibilityLabel="Mark as sold" + accessibilityRole="button" + > + {markingSold ? ( + + ) : ( + Mark as sold + )} + )} + [styles.editButton, pressed && styles.buttonPressed]} + onPress={() => router.push(`/listings/${listing._id}/edit`)} + > + Edit listing + + + )} + + {!isOwner && !isWeb && ( + [styles.reportLink, pressed && styles.buttonPressed]} + onPress={() => setReportOpen(true)} + accessible + accessibilityLabel="Report listing" + accessibilityRole="button" + > + Report listing )} - [styles.editButton, pressed && styles.buttonPressed]} - onPress={() => router.push(`/listings/${listing._id}/edit`)} - > - Edit Listing - - )} - - {!isOwner && !isWeb && ( - [styles.reportLink, pressed && styles.buttonPressed]} - onPress={() => setReportOpen(true)} - accessible - accessibilityLabel="Report listing" - accessibilityRole="button" - > - Report listing - - )} + url !== null)} - initialIndex={ - mappedUrls.slice(0, imageIndex + 1).filter((url): url is string => url !== null).length - - 1 - } + images={lightboxImages} + initialIndex={lightboxInitialIndex} visible={lightboxOpen} onClose={() => setLightboxOpen(false)} /> @@ -877,15 +949,15 @@ const styles = StyleSheet.create({ }, container: { flex: 1, - backgroundColor: colors.surface, + backgroundColor: '#F7F5EF', }, content: { width: '100%', - maxWidth: 980, + maxWidth: 1120, alignSelf: 'center', - paddingHorizontal: spacing.lg, paddingTop: spacing.xl, paddingBottom: spacing.xxl, + gap: spacing.md, }, webBannerContainer: { marginBottom: 12, @@ -925,29 +997,76 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 13, }, - card: { - backgroundColor: colors.white, - borderRadius: borderRadius.lg, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, - padding: spacing.lg, + backLink: { + alignSelf: 'flex-start', + minHeight: 38, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.12)', + backgroundColor: 'rgba(255, 255, 255, 0.78)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + boxShadow: '0 2px 8px rgba(21, 71, 52, 0.06)', + }, + backLinkText: { + ...typography.footnoteMed, + color: colors.primary, + fontWeight: '700', + letterSpacing: 0, + }, + detailShell: { gap: spacing.md, + }, + detailLayout: { + gap: spacing.lg, + }, + detailLayoutWide: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + imagePanel: { + width: '100%', + borderRadius: borderRadius.xl, + backgroundColor: '#FFFCF4', + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.12)', overflow: 'hidden', + boxShadow: '0 12px 34px rgba(21, 71, 52, 0.11), 0 2px 8px rgba(21, 71, 52, 0.06)', + }, + detailsPanel: { + flex: 1, + minWidth: 0, + backgroundColor: colors.white, + borderRadius: borderRadius.xl, + borderWidth: 1, + borderColor: 'rgba(21, 71, 52, 0.12)', + padding: spacing.xl, + gap: spacing.lg, + boxShadow: '0 12px 34px rgba(21, 71, 52, 0.10), 0 2px 8px rgba(21, 71, 52, 0.05)', }, imageSection: { - marginBottom: spacing.lg, + position: 'relative', }, heroImageWrap: { - height: 280, marginRight: 0, position: 'relative', + backgroundColor: '#EEF4F1', + }, + heroImagePressable: { + width: '100%', + height: '100%', }, heroImage: { width: '100%', height: '100%', - borderRadius: borderRadius.md, backgroundColor: colors.border, }, + imagePressed: { + opacity: 0.96, + }, imageIndicator: { alignItems: 'center', marginTop: spacing.sm, @@ -1001,46 +1120,62 @@ const styles = StyleSheet.create({ carouselArrowDisabled: { opacity: 0.45, }, - carouselArrowText: { - color: colors.textDark, - fontSize: 24, - fontWeight: '600', - lineHeight: 24, - }, placeholderImage: { width: '100%', flex: 1, minHeight: 180, - borderRadius: borderRadius.sm, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, + backgroundColor: '#EEF4F1', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + padding: spacing.xl, + }, + placeholderIcon: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(21, 71, 52, 0.08)', alignItems: 'center', justifyContent: 'center', - marginBottom: spacing.lg, }, placeholderText: { - color: colors.text, - fontSize: 14, + ...typography.subhead, + color: colors.textDark, + fontWeight: '700', + textAlign: 'center', + letterSpacing: 0, + }, + placeholderSubtext: { + ...typography.footnote, + color: colors.muted, + textAlign: 'center', + letterSpacing: 0, }, headerRow: { - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: spacing.md, - marginTop: spacing.xs, + gap: spacing.sm, + }, + eyebrow: { + ...typography.footnoteMed, + color: colors.primary, + textTransform: 'uppercase', + fontSize: 12, + fontWeight: '800', + letterSpacing: 0.4, }, title: { ...typography.title1, - fontSize: 24, - lineHeight: 30, - flex: 1, + fontSize: 30, + lineHeight: 36, + fontWeight: '600', color: colors.textDark, + letterSpacing: 0, }, price: { ...typography.title1, - fontSize: 22, - color: colors.accent, + fontSize: 34, + lineHeight: 40, + color: colors.primary, + letterSpacing: 0, }, actionRow: { flexDirection: 'row', @@ -1049,13 +1184,15 @@ const styles = StyleSheet.create({ }, messageButton: { flex: 1, + flexDirection: 'row', backgroundColor: colors.primary, paddingHorizontal: spacing.lg, paddingVertical: spacing.md, - borderRadius: borderRadius.md, + borderRadius: borderRadius.full, minHeight: 48, justifyContent: 'center', alignItems: 'center', + gap: spacing.sm, boxShadow: '0 12px 24px rgba(21, 71, 52, 0.18)', }, messageButtonText: { @@ -1072,33 +1209,37 @@ const styles = StyleSheet.create({ flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, - marginTop: spacing.xs, + paddingTop: spacing.xs, }, postedDate: { ...typography.footnote, - color: colors.muted, - marginBottom: spacing.lg, + color: colors.primary, + backgroundColor: 'rgba(172, 221, 202, 0.35)', + alignSelf: 'flex-start', + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + overflow: 'hidden', }, descriptionLabel: { ...typography.heading, color: colors.textDark, - marginTop: spacing.sm, + paddingTop: spacing.sm, }, description: { ...typography.body, - color: colors.textDark, - lineHeight: 24, + color: colors.text, + lineHeight: 26, }, sellerBlock: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.white, - borderRadius: borderRadius.md, + backgroundColor: '#F7FCF9', + borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: 'rgba(21, 71, 52, 0.14)', + borderColor: 'rgba(21, 71, 52, 0.12)', padding: spacing.md, gap: spacing.md, - marginTop: spacing.sm, boxShadow: '0 8px 18px rgba(21, 71, 52, 0.08)', }, sellerAvatar: { @@ -1114,6 +1255,8 @@ const styles = StyleSheet.create({ }, sellerInfo: { flex: 1, + minWidth: 0, + gap: 2, }, sellerName: { ...typography.subhead, @@ -1123,6 +1266,7 @@ const styles = StyleSheet.create({ sellerMeta: { ...typography.footnote, color: colors.text, + letterSpacing: 0, }, buttonContainer: { gap: spacing.sm, From 651b457b69e0bc8d3b05b5c7170b7634854bb88a Mon Sep 17 00:00:00 2001 From: saman Date: Wed, 6 May 2026 19:36:46 -0700 Subject: [PATCH 6/6] feat: fixed issues --- frontend/app/(tabs)/home.tsx | 36 +++++++++++------ frontend/app/listings/[id].tsx | 20 +++++++++- frontend/components/AppNav.web.tsx | 63 ++++++++++++++++++++---------- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/frontend/app/(tabs)/home.tsx b/frontend/app/(tabs)/home.tsx index 406042d9..eabefb78 100644 --- a/frontend/app/(tabs)/home.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -93,6 +93,11 @@ function buildWebRows(items: Doc<'listings'>[], size: number) { return rows; } +function normalizeSearchParam(value: string | string[] | undefined): string { + const rawValue = Array.isArray(value) ? value[0] : value; + return typeof rawValue === 'string' ? rawValue.trim() : ''; +} + export default function HomeScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); @@ -105,11 +110,9 @@ export default function HomeScreen() { const isDesktopWeb = isWeb && width >= 1024; const topSafeSpace = Platform.OS === 'ios' ? Math.max(insets.top - 6, 10) : 0; - // On web: use the shared context search query (instant, no URL round-trip). - // On native: fall back to URL param q (native doesn't use SearchContext). - const searchQuery = isWeb - ? contextSearchQuery.trim() - : (Array.isArray(q) ? (q[0] ?? '') : (q ?? '')).trim(); + // Web gets instant navbar state from SearchContext. Native has no navbar + // provider, so deep links and native navigation use the q route param. + const searchQuery = isWeb ? contextSearchQuery.trim() : normalizeSearchParam(q); const [filters, setFilters] = useState({}); const [sortBy, setSortBy] = useState('newest'); @@ -133,7 +136,7 @@ export default function HomeScreen() { const lastFirstPageAtRef = useRef(0); const hasLoadedOnceRef = useRef(false); - const hasSearchQuery = isWeb && searchQuery.length > 0; + const hasSearchQuery = searchQuery.length > 0; const activeFilterKey = `${filters.category ?? ''}|${filters.minPrice ?? ''}|${filters.maxPrice ?? ''}|${searchQuery}|${sortBy}`; const queryFilterVersion = currentFilterVersionRef.current; @@ -286,10 +289,21 @@ export default function HomeScreen() { setFilters((prev) => ({ ...prev, minPrice: undefined, maxPrice: undefined })); }; + const handleClearSearch = useCallback(() => { + if (isWeb) { + clearSearch(); + return; + } + + if (normalizeSearchParam(q).length > 0) { + router.replace('/home' as never); + } + }, [clearSearch, isWeb, q, router]); + const handleClearAll = () => { setFilters({}); setSortBy('newest'); - clearSearch(); + handleClearSearch(); }; const handleLoadMore = useCallback(() => { @@ -472,7 +486,7 @@ export default function HomeScreen() { setShowPricePicker(true)} @@ -480,7 +494,7 @@ export default function HomeScreen() { sortLabel={LISTING_SORT_SHORT[sortBy]} hasNonDefaultSort={sortBy !== 'newest'} onSortPress={() => setShowSortPicker(true)} - hasAnyFilter={hasActiveFilters || sortBy !== 'newest'} + hasAnyFilter={hasActiveFilters || hasSearchQuery || sortBy !== 'newest'} onClearAll={handleClearAll} /> @@ -592,7 +606,7 @@ export default function HomeScreen() { setShowPricePicker(true)} @@ -600,7 +614,7 @@ export default function HomeScreen() { sortLabel={LISTING_SORT_SHORT[sortBy]} hasNonDefaultSort={sortBy !== 'newest'} onSortPress={() => setShowSortPicker(true)} - hasAnyFilter={hasActiveFilters || sortBy !== 'newest'} + hasAnyFilter={hasActiveFilters || hasSearchQuery || sortBy !== 'newest'} onClearAll={handleClearAll} /> diff --git a/frontend/app/listings/[id].tsx b/frontend/app/listings/[id].tsx index fc8cb089..747b7291 100644 --- a/frontend/app/listings/[id].tsx +++ b/frontend/app/listings/[id].tsx @@ -429,6 +429,24 @@ export default function ListingDetailScreen() { setImageIndex((currentIndex) => Math.min(currentIndex + 1, displayImageUrls.length - 1)); }; + const goBackToListings = useCallback(() => { + try { + if (isWeb) { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + return; + } + } else if (router.canGoBack()) { + router.back(); + return; + } + } catch { + // Fall through to the stable listings route if history is unavailable. + } + + router.replace('/home' as never); + }, [isWeb, router]); + useEffect(() => { if (Platform.OS === 'web' && listing && typeof document !== 'undefined') { document.title = `${listing.title} - PolyBuys`; @@ -576,7 +594,7 @@ export default function ListingDetailScreen() { [styles.backLink, pressed && styles.buttonPressed]} - onPress={() => router.push('/home')} + onPress={goBackToListings} accessibilityRole="button" accessibilityLabel="Back to listings" > diff --git a/frontend/components/AppNav.web.tsx b/frontend/components/AppNav.web.tsx index c1e47bcd..eea9d6c5 100644 --- a/frontend/components/AppNav.web.tsx +++ b/frontend/components/AppNav.web.tsx @@ -31,7 +31,7 @@ function normalizeSearchParam(value: string | string[] | undefined): string { const NAV_CSS = /* reset */ - '.pbn*,.pbn*::before,.pbn*::after{box-sizing:border-box}' + + '.pbn,.pbn::before,.pbn::after,.pbn *,.pbn *::before,.pbn *::after{box-sizing:border-box}' + '.pbn a{text-decoration:none;color:inherit}' + /* reset form controls inside the web nav (skip .pb-btn so the shared landing button styles aren't clobbered by this reset's higher specificity) */ @@ -268,53 +268,66 @@ export function AppNavContainer() { const router = useRouter(); const { q } = useGlobalSearchParams<{ q?: string | string[] }>(); const { searchQuery, setSearchQuery } = useSearch(); + const urlSearch = normalizeSearchParam(q); const [isScrolled, setIsScrolled] = useState(false); const rafRef = useRef(null); // Debounce timer ref — cancelled immediately when clearSearch fires const debounceRef = useRef | null>(null); - const hasHydratedUrlSearchRef = useRef(false); - const hasSkippedInitialUrlSyncRef = useRef(false); - const hydratedUrlSearchRef = useRef(null); + const searchQueryRef = useRef(searchQuery); + const pendingUrlSyncRef = useRef<{ value: string } | null>(null); useEffect(() => { - if (hasHydratedUrlSearchRef.current) return; + searchQueryRef.current = searchQuery; + }, [searchQuery]); - const initialSearch = normalizeSearchParam(q); - hydratedUrlSearchRef.current = initialSearch; - hasHydratedUrlSearchRef.current = true; + useEffect(() => { + if (searchQueryRef.current === urlSearch) { + if (pendingUrlSyncRef.current?.value === urlSearch) { + pendingUrlSyncRef.current = null; + } + return; + } - if (initialSearch !== searchQuery) { - setSearchQuery(initialSearch); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; } - }, [q, searchQuery, setSearchQuery]); + + pendingUrlSyncRef.current = { value: urlSearch }; + setSearchQuery(urlSearch); + }, [setSearchQuery, urlSearch]); // Debounce: push search query to URL so home.tsx can read it useEffect(() => { - if (!hasHydratedUrlSearchRef.current) return; - - if (!hasSkippedInitialUrlSyncRef.current) { - hasSkippedInitialUrlSyncRef.current = true; + const trimmed = searchQuery.trim(); + const pendingUrlSync = pendingUrlSyncRef.current; + if (pendingUrlSync) { + if (pendingUrlSync.value === trimmed) { + pendingUrlSyncRef.current = null; + } return; } - if (hydratedUrlSearchRef.current !== null && searchQuery === hydratedUrlSearchRef.current) { - hydratedUrlSearchRef.current = null; + if (trimmed === urlSearch) { return; } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - const trimmed = searchQuery.trim(); router.replace({ pathname: '/home' as never, params: (trimmed.length > 0 ? { q: trimmed } : {}) as never, }); + debounceRef.current = null; }, 250); return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } }; - }, [searchQuery, router]); + }, [searchQuery, router, urlSearch]); useEffect(() => { const onScroll = () => { @@ -345,8 +358,16 @@ export function AppNavContainer() { ); const handleHomePress = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + pendingUrlSyncRef.current = { value: '' }; + if (searchQueryRef.current.length > 0) { + setSearchQuery(''); + } router.replace('/home' as never); - }, [router]); + }, [router, setSearchQuery]); return (