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.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/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..eabefb78 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: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' } + : 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; @@ -66,17 +93,27 @@ 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(); 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; + // 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'); const [showCategoryPicker, setShowCategoryPicker] = useState(false); @@ -99,8 +136,7 @@ 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 hasSearchQuery = searchQuery.length > 0; const activeFilterKey = `${filters.category ?? ''}|${filters.minPrice ?? ''}|${filters.maxPrice ?? ''}|${searchQuery}|${sortBy}`; const queryFilterVersion = currentFilterVersionRef.current; @@ -249,18 +285,25 @@ 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 })); }; + 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'); - router.setParams({ q: undefined }); + handleClearSearch(); }; const handleLoadMore = useCallback(() => { @@ -304,16 +347,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 +414,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 || hasSearchQuery || 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 +603,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 || hasSearchQuery || sortBy !== 'newest'} onClearAll={handleClearAll} /> @@ -615,7 +679,12 @@ export default function HomeScreen() { /> } ListEmptyComponent={listEmptyComponent} - ListFooterComponent={listFooterComponent} + ListFooterComponent={ + <> + {loadingFooterComponent} + + + } /> @@ -625,13 +694,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 +716,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 +761,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 +799,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 +836,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/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, + }, +}); 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/app/listings/[id].tsx b/frontend/app/listings/[id].tsx index a248f314..747b7291 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,9 +426,27 @@ 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)); }; + 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`; @@ -419,13 +482,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 +592,236 @@ export default function ListingDetailScreen() { )} - + [styles.backLink, pressed && styles.buttonPressed]} + onPress={goBackToListings} + 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 +967,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 +1015,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 +1138,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 +1202,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 +1227,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 +1273,8 @@ const styles = StyleSheet.create({ }, sellerInfo: { flex: 1, + minWidth: 0, + gap: 2, }, sellerName: { ...typography.subhead, @@ -1123,6 +1284,7 @@ const styles = StyleSheet.create({ sellerMeta: { ...typography.footnote, color: colors.text, + letterSpacing: 0, }, buttonContainer: { gap: spacing.sm, diff --git a/frontend/assets/images/PolyBuysLogo.png b/frontend/assets/images/PolyBuysLogo.png new file mode 100644 index 00000000..5b6a86ab Binary files /dev/null and b/frontend/assets/images/PolyBuysLogo.png differ diff --git a/frontend/assets/images/landing-calculus-book.jpg b/frontend/assets/images/landing-calculus-book.jpg new file mode 100644 index 00000000..c893c678 Binary files /dev/null and b/frontend/assets/images/landing-calculus-book.jpg differ diff --git a/frontend/assets/images/landing-kallax-shelf.jpg b/frontend/assets/images/landing-kallax-shelf.jpg new file mode 100644 index 00000000..a330c4b1 Binary files /dev/null and b/frontend/assets/images/landing-kallax-shelf.jpg differ 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..eea9d6c5 --- /dev/null +++ b/frontend/components/AppNav.web.tsx @@ -0,0 +1,401 @@ +/** + * 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 { useGlobalSearchParams, useRouter } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; +import { useSearch } from '../contexts/SearchContext'; +import polybuysLogo from '../assets/images/PolyBuysLogo.png'; +import { DOWNLOAD_CTA_CSS, DownloadButton } from './landing'; +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 +// shell dimensions and background come from React Native StyleSheet below. + +const NAV_CSS = + /* reset */ + '.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) */ + '.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{' + + '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}' + + /* 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}' + + /* ── 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 .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}' + + '.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 .pb-btn--sm{padding:0 14px}' + + '}' + + /* ── reduced motion ── */ + '@media(prefers-reduced-motion:reduce){' + + '.pbn__brand,.pbn__search-input,.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; + isScrolled: boolean; +} + +export function AppNav({ + onSearch, + searchValue, + onSearchChange, + onHomePress, + 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 */} +
+ {/* LEFT */} + { + e.preventDefault(); + onHomePress(); + }} + > + PolyBuys + + + {/* CENTER */} + {searchBar()} + + {/* RIGHT */} +
+ +
+
+ + {/* Compact row (mobile) */} +
{searchBar('pbn__search--full')}
+
+
+ ); +} + +// ─── Container ──────────────────────────────────────────────────────────────── + +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 searchQueryRef = useRef(searchQuery); + const pendingUrlSyncRef = useRef<{ value: string } | null>(null); + + useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + + useEffect(() => { + if (searchQueryRef.current === urlSearch) { + if (pendingUrlSyncRef.current?.value === urlSearch) { + pendingUrlSyncRef.current = null; + } + return; + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + + pendingUrlSyncRef.current = { value: urlSearch }; + setSearchQuery(urlSearch); + }, [setSearchQuery, urlSearch]); + + // Debounce: push search query to URL so home.tsx can read it + useEffect(() => { + const trimmed = searchQuery.trim(); + const pendingUrlSync = pendingUrlSyncRef.current; + if (pendingUrlSync) { + if (pendingUrlSync.value === trimmed) { + pendingUrlSyncRef.current = null; + } + return; + } + + if (trimmed === urlSearch) { + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + 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); + debounceRef.current = null; + } + }; + }, [searchQuery, router, urlSearch]); + + 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(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + pendingUrlSyncRef.current = { value: '' }; + if (searchQueryRef.current.length > 0) { + setSearchQuery(''); + } + router.replace('/home' as never); + }, [router, setSearchQuery]); + + 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..a3d2799b --- /dev/null +++ b/frontend/components/CategoryRail.tsx @@ -0,0 +1,359 @@ +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: -1 }], + boxShadow: '0 3px 10px rgba(21,71,52,0.10)', + } as never) + : isWeb && hovered && isActive + ? ({ + transform: [{ translateY: -1 }], + 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, + clearAccessibilityLabel, +}: { + label: string; + iconName: keyof typeof Ionicons.glyphMap; + isActive: boolean; + onPress: () => void; + onClear?: () => void; + accessibilityLabel: string; + clearAccessibilityLabel?: string; +}) { + const fallbackClearLabel = `Clear ${label.replace(/^Sort:\s*/i, '').toLowerCase()} filter`; + + return ( + [ + styles.filterControl, + isActive && styles.filterControlActive, + pressed && styles.pressed, + ]} + > + + + {label} + + {onClear ? ( + { + event.stopPropagation?.(); + onClear(); + }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={clearAccessibilityLabel ?? fallbackClearLabel} + 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..5f7750f1 --- /dev/null +++ b/frontend/components/HomeBg.web.tsx @@ -0,0 +1,319 @@ +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; + +const decorativeAccessibilityProps = { 'aria-hidden': true } 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..6e371539 100644 --- a/frontend/components/LandingScreen.web.tsx +++ b/frontend/components/LandingScreen.web.tsx @@ -1,9 +1,22 @@ +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(); + useEffect(() => { + scrollToLandingHash(); + window.addEventListener('hashchange', scrollToLandingHash); + + return () => window.removeEventListener('hashchange', scrollToLandingHash); + }, []); + return ( <> @@ -12,11 +25,18 @@ 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} diff --git a/frontend/components/ListingCard.tsx b/frontend/components/ListingCard.tsx index 8147f06b..7b41430b 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/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/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..a4441bef --- /dev/null +++ b/frontend/components/landing/assetSource.ts @@ -0,0 +1,18 @@ +type WebImageAssetObject = { + uri?: string; + width?: number; + height?: number; +}; + +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 ?? ''; + } + + return ''; +} diff --git a/frontend/components/landing/data.ts b/frontend/components/landing/data.ts index 6ed36d14..820b55fb 100644 --- a/frontend/components/landing/data.ts +++ b/frontend/components/landing/data.ts @@ -1,12 +1,18 @@ 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 }; -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'; @@ -18,7 +24,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 +41,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 +56,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/index.ts b/frontend/components/landing/index.ts index e3ee6989..a4f424f4 100644 --- a/frontend/components/landing/index.ts +++ b/frontend/components/landing/index.ts @@ -5,6 +5,6 @@ export { Why } from './Why'; export { GetApp } from './GetApp'; export { Footer } from './Footer'; export { DownloadButton } from './DownloadButton'; -export { GLOBAL_CSS } from './styles'; +export { GLOBAL_CSS, DOWNLOAD_CTA_CSS } from './styles'; export { useScrolled } from './useScrolled'; export { useRevealOnVisible } from './useRevealOnVisible'; 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..a1b27e57 100644 --- a/frontend/components/landing/styles.ts +++ b/frontend/components/landing/styles.ts @@ -1,4 +1,206 @@ -export const GLOBAL_CSS = ` +/* Styles needed to render the landing 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; @@ -49,23 +251,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,114 +366,35 @@ 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 { - --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 { @@ -257,18 +431,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 +487,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 +565,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 +629,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 +695,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 +803,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 +811,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; @@ -809,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) { @@ -913,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 { @@ -921,7 +1078,9 @@ 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; } } -`; +` + DOWNLOAD_CTA_CSS; 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..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__/**"] + "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/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/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": { 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" }] }