From 9b758ace74dd8c3d1ad55aadf95ca3313001845f Mon Sep 17 00:00:00 2001 From: dfed25 Date: Mon, 13 Apr 2026 21:59:52 -0700 Subject: [PATCH 1/7] fix(vercel): declare @sentry/react-native in frontend workspace Expo resolves @sentry/react-native/expo relative to the frontend package; it was only on the root workspace so Vercel installs did not expose the plugin and expo export failed. Made-with: Cursor --- frontend/package.json | 1 + package-lock.json | 8 ++++---- package.json | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2dad364..fb0860a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@sentry/react-native": "^8.3.0", "@convex-dev/auth": "^0.0.90", "@expo/metro-runtime": "~55.0.6", "@expo/vector-icons": "^15.0.3", diff --git a/package-lock.json b/package-lock.json index 39d5ed3..0163acd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "packages/shared" ], "dependencies": { - "@sentry/react-native": "^8.3.0", "convex": "^1.32.0" }, "devDependencies": { @@ -70,6 +69,7 @@ "@expo/vector-icons": "^15.0.3", "@polybuys/shared": "*", "@react-native-async-storage/async-storage": "2.2.0", + "@sentry/react-native": "^8.3.0", "convex": "^1.32.0", "expo": "~55.0.5", "expo-constants": "~55.0.7", @@ -5000,7 +5000,7 @@ }, "node_modules/@types/react": { "version": "19.2.14", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6541,7 +6541,7 @@ }, "node_modules/csstype": { "version": "3.2.3", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -15560,7 +15560,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index ae06190..cf8bf5d 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ ] }, "dependencies": { - "@sentry/react-native": "^8.3.0", "convex": "^1.32.0" } } From 9c2626c2f6a45debed3b74e4fbade5435f8c9785 Mon Sep 17 00:00:00 2001 From: dfed25 Date: Sat, 18 Apr 2026 16:16:57 -0700 Subject: [PATCH 2/7] feat(frontend): add marketing landing page and /home feed route - Add web-only landing at / with value props, CTAs, and App Store compliance - Desktop: QR code and mailto draft for the download link - Narrow web: official App Store badge linking to APP_STORE_URL - Rename (tabs)/index to home so / is the landing without route conflicts - Point in-app "home" navigation and post-auth default to /home - Register root index in the stack; dedupe @sentry/react-native in package.json - Refresh lockfile so workspace resolves expo-blur for typecheck/bundling Made-with: Cursor --- frontend/app/(tabs)/_layout.tsx | 8 +- frontend/app/(tabs)/{index.tsx => home.tsx} | 2 +- frontend/app/(tabs)/inbox.tsx | 2 +- frontend/app/(tabs)/my-listings.tsx | 2 +- frontend/app/(tabs)/settings.tsx | 2 +- frontend/app/_layout.tsx | 1 + frontend/app/account-settings.tsx | 2 +- frontend/app/auth/login.tsx | 2 +- frontend/app/index.tsx | 11 + frontend/app/l/[id].tsx | 2 +- frontend/app/listings/new.tsx | 2 +- frontend/components/LandingScreen.tsx | 311 ++++++++++++++++++++ frontend/components/ListingUnavailable.tsx | 2 +- frontend/package.json | 1 - package-lock.json | 6 +- 15 files changed, 339 insertions(+), 17 deletions(-) rename frontend/app/(tabs)/{index.tsx => home.tsx} (99%) create mode 100644 frontend/app/index.tsx create mode 100644 frontend/components/LandingScreen.tsx diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index 4961e47..d919b33 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -23,7 +23,7 @@ function WebHeaderLayout() { }, [q]); const [searchInput, setSearchInput] = useState(currentQuery); const searchActive = - pathname === '/' + pathname === '/home' ? searchInput.trim().length > 0 : pathname === '/search' || pathname.startsWith('/search/'); const searchControlStyle = StyleSheet.flatten([ @@ -55,7 +55,7 @@ function WebHeaderLayout() { } router.replace({ - pathname: '/' as never, + pathname: '/home' as never, params: trimmed.length > 0 ? { ...mergedParams, q: trimmed } : mergedParams, }); }, 250); @@ -67,7 +67,7 @@ function WebHeaderLayout() { - + PolyBuys @@ -132,7 +132,7 @@ export default function TabsLayout() { disableTransparentOnScrollEdge > diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/home.tsx similarity index 99% rename from frontend/app/(tabs)/index.tsx rename to frontend/app/(tabs)/home.tsx index 08752fa..6138384 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -201,7 +201,7 @@ export default function HomeScreen() { const handleToggleSave = useCallback( async (listingId: Id<'listings'>) => { if (!isAuthenticated) { - router.replace('/auth/login?returnTo=%2F' as never); + router.replace('/auth/login?returnTo=%2Fhome' as never); return; } diff --git a/frontend/app/(tabs)/inbox.tsx b/frontend/app/(tabs)/inbox.tsx index a0384b8..75a5ac5 100644 --- a/frontend/app/(tabs)/inbox.tsx +++ b/frontend/app/(tabs)/inbox.tsx @@ -202,7 +202,7 @@ export default function InboxScreen() { path="/inbox" buttonLabel="Open Inbox in App" secondaryActionLabel="Back to home" - onSecondaryAction={() => router.replace('/')} + onSecondaryAction={() => router.replace('/home')} /> ); } diff --git a/frontend/app/(tabs)/my-listings.tsx b/frontend/app/(tabs)/my-listings.tsx index 34e88d5..e8b1ccb 100644 --- a/frontend/app/(tabs)/my-listings.tsx +++ b/frontend/app/(tabs)/my-listings.tsx @@ -190,7 +190,7 @@ export default function MyListingsScreen() { path="/my-listings" buttonLabel="Open My Listings in App" secondaryActionLabel="Back to home" - onSecondaryAction={() => router.replace('/')} + onSecondaryAction={() => router.replace('/home')} /> ); } diff --git a/frontend/app/(tabs)/settings.tsx b/frontend/app/(tabs)/settings.tsx index 6250d55..fd0c865 100644 --- a/frontend/app/(tabs)/settings.tsx +++ b/frontend/app/(tabs)/settings.tsx @@ -86,7 +86,7 @@ export default function SettingsScreen() { path="/settings" buttonLabel="Open Profile in App" secondaryActionLabel="Back to home" - onSecondaryAction={() => router.replace('/')} + onSecondaryAction={() => router.replace('/home')} /> ); } diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 3d6adde..261d7c4 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -80,6 +80,7 @@ function RootLayout() { contentStyle: { backgroundColor: colors.surface }, }} > + router.replace('/')} + onSecondaryAction={() => router.replace('/home')} /> ); } diff --git a/frontend/app/auth/login.tsx b/frontend/app/auth/login.tsx index 98676e5..6668bf8 100644 --- a/frontend/app/auth/login.tsx +++ b/frontend/app/auth/login.tsx @@ -65,7 +65,7 @@ export default function LoginScreen() { normalizedReturnTo.startsWith('/') && !normalizedReturnTo.startsWith('//') ? (normalizedReturnTo as Href) - : '/'; + : '/home'; useEffect(() => { const entryAction = getLoginEntryAction({ diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx new file mode 100644 index 0000000..2f4bc56 --- /dev/null +++ b/frontend/app/index.tsx @@ -0,0 +1,11 @@ +import { Redirect } from 'expo-router'; +import { Platform } from 'react-native'; +import LandingScreen from '../components/LandingScreen'; + +export default function IndexRoute() { + if (Platform.OS !== 'web') { + return ; + } + + return ; +} diff --git a/frontend/app/l/[id].tsx b/frontend/app/l/[id].tsx index 09ada4c..6383903 100644 --- a/frontend/app/l/[id].tsx +++ b/frontend/app/l/[id].tsx @@ -4,7 +4,7 @@ export default function ShortListingRedirect() { const { id } = useLocalSearchParams<{ id?: string }>(); if (typeof id !== 'string' || id.trim().length === 0) { - return ; + return ; } return ; diff --git a/frontend/app/listings/new.tsx b/frontend/app/listings/new.tsx index c5dbc42..d306db8 100644 --- a/frontend/app/listings/new.tsx +++ b/frontend/app/listings/new.tsx @@ -143,7 +143,7 @@ export default function NewListingScreen() { images, }); setFlash('Listing created.'); - router.replace('/'); + router.replace('/home'); } catch (error) { const actionError = getListingActionError(error, 'Create failed'); showAlert(actionError.title, actionError.message); diff --git a/frontend/components/LandingScreen.tsx b/frontend/components/LandingScreen.tsx new file mode 100644 index 0000000..03e323b --- /dev/null +++ b/frontend/components/LandingScreen.tsx @@ -0,0 +1,311 @@ +import Head from 'expo-router/head'; +import { useRouter } from 'expo-router'; +import { + Image, + Linking, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { APP_STORE_URL } from '../constants/app'; +import { AppButton, AppText, SectionCard } from './ui'; +import { borderRadius, colors, spacing, typography } from '../theme/tokens'; + +/* eslint-disable react-native/no-raw-text -- AppButton and expo-router Head handle text appropriately. */ + +/** Official App Store badge artwork (Apple Marketing Resources). */ +const APP_STORE_BADGE_URI = + 'https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83'; + +const LAYOUT_BREAKPOINT = 768; + +function qrCodeImageUri(targetUrl: string) { + return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(targetUrl)}`; +} + +function openDownloadLinkEmail() { + const subject = encodeURIComponent('PolyBuys — download link'); + const body = encodeURIComponent( + `Here's the link to get PolyBuys:\n\n${APP_STORE_URL}\n\nOpen it on your phone to install or download.\n` + ); + void Linking.openURL(`mailto:?subject=${subject}&body=${body}`); +} + +const VALUE_POINTS = [ + { + title: 'Students only', + body: 'Sign in with your Cal Poly email so listings stay in the campus community.', + }, + { + title: 'Built for how you actually buy', + body: 'Textbooks, housing, furniture, and everyday gear — organized for quick browsing and search.', + }, + { + title: 'Campus-first trust', + body: 'Clear seller profiles and in-app messaging help you coordinate pickups without giving out extra contact info.', + }, +] as const; + +export default function LandingScreen() { + const router = useRouter(); + const { width } = useWindowDimensions(); + const isDesktopWeb = Platform.OS === 'web' && width >= LAYOUT_BREAKPOINT; + const qrUri = qrCodeImageUri(APP_STORE_URL); + + return ( + <> + {Platform.OS === 'web' ? ( + + PolyBuys — campus marketplace for Cal Poly + + + ) : null} + + + PolyBuys + router.push('/auth/login')} + accessibilityRole="link" + accessibilityLabel="Sign in or sign up" + style={({ pressed }) => [styles.topBarLinkWrap, pressed && styles.pressed]} + > + + Sign in + + + + + + + Cal Poly · Student marketplace + + + Buy and sell on campus, without the noise. + + + PolyBuys is the marketplace for Mustangs — textbooks, subleases, furniture, and daily + essentials from people who share your campus. + + + router.push('/home')} + accessibilityLabel="Browse marketplace" + > + Browse marketplace + + router.push('/auth/login')} + accessibilityLabel="Sign up or sign in" + > + Sign up + + + + + + + {VALUE_POINTS.map((item) => ( + + + {item.title} + + + {item.body} + + + ))} + + + + + {isDesktopWeb ? ( + + + + + Scan with your phone to open the download link. + + + + + Prefer email? We’ll open a draft with the link so you can send it to yourself or + a friend. + + + Email the download link + + + + ) : ( + + void Linking.openURL(APP_STORE_URL)} + accessibilityRole="link" + accessibilityLabel="Download on the App Store" + style={({ pressed }) => [styles.badgePressable, pressed && styles.pressed]} + > + + + + Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and + other countries. App Store is a service mark of Apple Inc. + + + )} + + + + PolyBuys is an independent student marketplace and is not affiliated with California + Polytechnic State University. + + + + + ); +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: colors.background, + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + maxWidth: 960, + width: '100%', + alignSelf: 'center', + }, + brandMark: { + ...typography.title1, + fontSize: 26, + lineHeight: 32, + color: colors.primary, + }, + topBarLinkWrap: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm, + }, + topBarLink: { + fontWeight: '700', + }, + pressed: { + opacity: 0.85, + }, + scrollContent: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.lg, + maxWidth: 960, + width: '100%', + alignSelf: 'center', + }, + hero: { + gap: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.md, + }, + eyebrow: { + letterSpacing: 0.5, + fontWeight: '700', + textTransform: 'uppercase', + }, + heroTitle: { + fontSize: 32, + lineHeight: 40, + }, + heroSubtitle: { + lineHeight: 24, + maxWidth: 640, + }, + ctaRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.md, + marginTop: spacing.sm, + }, + valueList: { + gap: spacing.lg, + }, + valueItem: { + gap: spacing.xs, + }, + valueTitle: { + fontSize: 17, + }, + getAppDesktop: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xxl, + alignItems: 'flex-start', + }, + qrBlock: { + alignItems: 'center', + gap: spacing.sm, + }, + qrImage: { + width: 220, + height: 220, + borderRadius: borderRadius.md, + backgroundColor: colors.white, + }, + qrCaption: { + textAlign: 'center', + maxWidth: 220, + }, + emailBlock: { + flex: 1, + minWidth: 240, + gap: spacing.md, + }, + emailBlurb: { + lineHeight: 22, + }, + getAppMobile: { + gap: spacing.md, + alignItems: 'flex-start', + }, + badgePressable: {}, + appStoreBadge: { + width: 250, + height: 83, + }, + badgeFootnote: { + lineHeight: 18, + maxWidth: 320, + }, + footerNote: { + lineHeight: 18, + marginTop: spacing.sm, + marginBottom: spacing.lg, + }, +}); diff --git a/frontend/components/ListingUnavailable.tsx b/frontend/components/ListingUnavailable.tsx index ad78a2d..8dd713f 100644 --- a/frontend/components/ListingUnavailable.tsx +++ b/frontend/components/ListingUnavailable.tsx @@ -16,7 +16,7 @@ export default function ListingUnavailable() { [styles.button, pressed && styles.buttonPressed]} - onPress={() => router.replace('/')} + onPress={() => router.replace('/home')} accessibilityLabel="Back to browse" accessibilityRole="button" > diff --git a/frontend/package.json b/frontend/package.json index 6b06612..22d4bb6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "@expo/vector-icons": "^15.0.3", "@polybuys/shared": "*", "@react-native-async-storage/async-storage": "2.2.0", - "@sentry/react-native": "^8.3.0", "convex": "^1.32.0", "expo": "~55.0.5", "expo-blur": "~55.0.8", diff --git a/package-lock.json b/package-lock.json index 5abdc6d..22974d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5613,7 +5613,7 @@ }, "node_modules/@types/react": { "version": "19.2.14", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -7154,7 +7154,7 @@ }, "node_modules/csstype": { "version": "3.2.3", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -16258,7 +16258,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", From 23910d5ae5e04a9fcfb0dd4c8e5f13a4db4f0ded Mon Sep 17 00:00:00 2001 From: dfed25 Date: Mon, 20 Apr 2026 09:35:36 -0700 Subject: [PATCH 3/7] feat(web): block auth routes and remove login CTAs from landing - Add auth/login.web.tsx so /auth/login always redirects to / on web - Remove Sign in header link and Sign up button from LandingScreen - Clarify copy: sign-in is app-only; web is browse-focused Made-with: Cursor --- frontend/app/auth/login.web.tsx | 9 ++++++++ frontend/components/LandingScreen.tsx | 31 +++------------------------ 2 files changed, 12 insertions(+), 28 deletions(-) create mode 100644 frontend/app/auth/login.web.tsx diff --git a/frontend/app/auth/login.web.tsx b/frontend/app/auth/login.web.tsx new file mode 100644 index 0000000..9f7a33d --- /dev/null +++ b/frontend/app/auth/login.web.tsx @@ -0,0 +1,9 @@ +import { Redirect } from 'expo-router'; + +/** + * Sign-in and sign-up are only supported in the native app. Web visitors hitting + * /auth/login (bookmark, deep link, or in-app navigation) are sent to the landing page. + */ +export default function LoginWebRedirect() { + return ; +} diff --git a/frontend/components/LandingScreen.tsx b/frontend/components/LandingScreen.tsx index 03e323b..50b414a 100644 --- a/frontend/components/LandingScreen.tsx +++ b/frontend/components/LandingScreen.tsx @@ -39,7 +39,7 @@ function openDownloadLinkEmail() { const VALUE_POINTS = [ { title: 'Students only', - body: 'Sign in with your Cal Poly email so listings stay in the campus community.', + body: 'Cal Poly email sign-in runs in the iOS app so listings stay in the campus community.', }, { title: 'Built for how you actually buy', @@ -71,16 +71,6 @@ export default function LandingScreen() { PolyBuys - router.push('/auth/login')} - accessibilityRole="link" - accessibilityLabel="Sign in or sign up" - style={({ pressed }) => [styles.topBarLinkWrap, pressed && styles.pressed]} - > - - Sign in - - Browse marketplace - router.push('/auth/login')} - accessibilityLabel="Sign up or sign in" - > - Sign up - @@ -133,7 +115,7 @@ export default function LandingScreen() { {isDesktopWeb ? ( @@ -198,7 +180,7 @@ const styles = StyleSheet.create({ topBar: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'flex-start', paddingHorizontal: spacing.lg, paddingVertical: spacing.md, maxWidth: 960, @@ -211,13 +193,6 @@ const styles = StyleSheet.create({ lineHeight: 32, color: colors.primary, }, - topBarLinkWrap: { - paddingVertical: spacing.sm, - paddingHorizontal: spacing.sm, - }, - topBarLink: { - fontWeight: '700', - }, pressed: { opacity: 0.85, }, From 153594126612273f73432136ff65f749656ccfff Mon Sep 17 00:00:00 2001 From: dfed25 Date: Mon, 20 Apr 2026 09:46:09 -0700 Subject: [PATCH 4/7] chore(frontend): address CodeRabbit marketing + web auth UX - index: redirect authenticated web users to /home; show boot spinner while auth loads - LandingScreen: brand Link to /home; static QR asset (no third-party API); App Store badge uses Link target=_blank on web - types/assets.d.ts: declare *.png for Metro image imports - constants: document APP_STORE_URL + QR regeneration - home: web save/create no longer hits blocked /auth/login; use app-only alerts Made-with: Cursor --- frontend/app/(tabs)/home.tsx | 7 +- frontend/app/index.tsx | 27 ++++++- .../assets/images/polybuys-download-qr.png | Bin 0 -> 2759 bytes frontend/components/LandingScreen.tsx | 68 ++++++++++++------ frontend/constants/app.ts | 7 +- frontend/types/assets.d.ts | 5 ++ 6 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 frontend/assets/images/polybuys-download-qr.png create mode 100644 frontend/types/assets.d.ts diff --git a/frontend/app/(tabs)/home.tsx b/frontend/app/(tabs)/home.tsx index b044f2e..bfbaeaf 100644 --- a/frontend/app/(tabs)/home.tsx +++ b/frontend/app/(tabs)/home.tsx @@ -203,7 +203,7 @@ export default function HomeScreen() { const handleToggleSave = useCallback( async (listingId: Id<'listings'>) => { if (isWeb) { - router.push('/auth/login?returnTo=%2F' as never); + Alert.alert('Open in the PolyBuys app', 'Saving listings is available in the mobile app.'); return; } @@ -262,10 +262,7 @@ export default function HomeScreen() { const handleCreateListing = () => { if (isWeb) { - router.push({ - pathname: '/auth/login', - params: { returnTo: '/listings/new' }, - } as never); + Alert.alert('Open in the PolyBuys app', 'Creating listings is available in the mobile app.'); return; } diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx index 2f4bc56..77dd09f 100644 --- a/frontend/app/index.tsx +++ b/frontend/app/index.tsx @@ -1,11 +1,36 @@ import { Redirect } from 'expo-router'; -import { Platform } from 'react-native'; +import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native'; import LandingScreen from '../components/LandingScreen'; +import { useAuth } from '../hooks/useAuth'; +import { colors } from '../theme/tokens'; export default function IndexRoute() { + const { isAuthenticated, isLoading } = useAuth(); + if (Platform.OS !== 'web') { return ; } + if (isLoading) { + return ( + + + + ); + } + + if (isAuthenticated) { + return ; + } + return ; } + +const styles = StyleSheet.create({ + boot: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background, + }, +}); diff --git a/frontend/assets/images/polybuys-download-qr.png b/frontend/assets/images/polybuys-download-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..e5a2513b4828b1622cdfc23d3e41d06d3e767cff GIT binary patch literal 2759 zcmbuBT})GF7{^aFqQlf1FPaF+V$6ESj(D*3!eqligov`t8e{x8+msV*dDBH>ToVHL zMY52MXcOVI$%4d>z(q;09A#^)lN&4T;FYf^t)&}EB}uZ@*VTL@NtWCEXH5zI;(U<3iC-(~YpPv8TK?J4_E?fi zJNT!6|EL~Z^~t63FT1Bx#Rs9YC6!$h8UOu1OXDp|-O}C4GHF}$N@+Edru(c?dDzl% z&LREqutZunDc!C9-(N1z)GF(zUN{;P4ZpgB9?h;kcX)Mu`!k2?RMLmpe5c?;+jF+& zvP*jG;N3}HHrN>b6#^h{;;+KQ`>T{`KF_~Y=b83~6PF-Qssbd0W z6;8O4bHD4cGJ5z8&F(!O!xgP`zaFmI`odA|-YXI5 z(PV+bra5(s;@g$nzE#Q9CJ!k}ZrC_eJT7#kem(Y1?k2^53--NTp?KsPL+{;+H{5B_ zvaV#LiLd$ZY5$$TbFLTf?;;6N3I$Cs(b!Xu9WUa1#lGzkZ|}$z#-DZB@Qn!sY579Y zqh-4cpBp+-S=fchn5H2(aIE#2Ktb+BG`}{Sfd6PBMrU>>CkHg=eud=$?N2x>5+_OW;8Gj+Td9iuvKC`vI$ zNP7|up$RcSE%ew%5;75p(NSS7jHV2(y`Zp07p0sEBKA;gI56J_( zD?5*A*;51(Na7Vh>q}Vb9+HJ*B!v2$O!fXHCQ8-v&c$}* zZ;(x4{p!DPAcbZk(_fg_@&uhN0s}%lQuRL4JlMm$sZW5T70}r*Kcdrf!(byRFNW+R zPbp;xa;M=NkDtFvJ&gWnA_^gRctmbc0N8xk!NkuXu7{(-%rsJNLK7*B4Dr51&)jhf zFr$#|H@Jv2TCDj-a~xo{fKNmb9!IyBx*)*Pco&^$arXx}>ojt|nR*&&r@-W1=xIM= zYy`EyNope1!BLDzo8;la4zW7Q#*oNcLA0E#2UZsUChJ|C@-Qp|V8eG;^3Y8I*5FDQ zgvD6=e2S(toPv6A%G;}RsKa#;%lV8p-$$AWiZmklpa@%wpm?wT5hKDJxYXAG05Xdg z2(&!h#{4%CjLm>CstNQEhlW~M1;a5ONJ2g+pU}9eRMMwu)B%X1eYEhYrQ@gokwjk; ai`Dc0HViIqrQhn{7n$V7K&%OcdCs92B literal 0 HcmV?d00001 diff --git a/frontend/components/LandingScreen.tsx b/frontend/components/LandingScreen.tsx index 50b414a..605e46d 100644 --- a/frontend/components/LandingScreen.tsx +++ b/frontend/components/LandingScreen.tsx @@ -1,5 +1,5 @@ import Head from 'expo-router/head'; -import { useRouter } from 'expo-router'; +import { Link, useRouter } from 'expo-router'; import { Image, Linking, @@ -13,6 +13,7 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { APP_STORE_URL } from '../constants/app'; +import qrDownloadPng from '../assets/images/polybuys-download-qr.png'; import { AppButton, AppText, SectionCard } from './ui'; import { borderRadius, colors, spacing, typography } from '../theme/tokens'; @@ -24,10 +25,6 @@ const APP_STORE_BADGE_URI = const LAYOUT_BREAKPOINT = 768; -function qrCodeImageUri(targetUrl: string) { - return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(targetUrl)}`; -} - function openDownloadLinkEmail() { const subject = encodeURIComponent('PolyBuys — download link'); const body = encodeURIComponent( @@ -55,7 +52,6 @@ export default function LandingScreen() { const router = useRouter(); const { width } = useWindowDimensions(); const isDesktopWeb = Platform.OS === 'web' && width >= LAYOUT_BREAKPOINT; - const qrUri = qrCodeImageUri(APP_STORE_URL); return ( <> @@ -70,7 +66,15 @@ export default function LandingScreen() { ) : null} - PolyBuys + + [pressed && styles.pressed]} + > + PolyBuys + + @@ -141,19 +145,41 @@ export default function LandingScreen() { ) : ( - void Linking.openURL(APP_STORE_URL)} - accessibilityRole="link" - accessibilityLabel="Download on the App Store" - style={({ pressed }) => [styles.badgePressable, pressed && styles.pressed]} - > - - + {Platform.OS === 'web' ? ( + + [styles.badgePressable, pressed && styles.pressed]} + > + + + + ) : ( + void Linking.openURL(APP_STORE_URL)} + accessibilityRole="link" + accessibilityLabel="Download on the App Store" + style={({ pressed }) => [styles.badgePressable, pressed && styles.pressed]} + > + + + )} Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other countries. App Store is a service mark of Apple Inc. diff --git a/frontend/constants/app.ts b/frontend/constants/app.ts index bbe0abe..59ef13e 100644 --- a/frontend/constants/app.ts +++ b/frontend/constants/app.ts @@ -1,4 +1,9 @@ -// TODO: Replace with actual App Store / Play Store URLs once published +/** + * Public download / App Store entry URL (QR on the marketing page, badge, mailto body). + * Before shipping broadly: ensure this URL 301/302s to the real App Store listing or serves + * a reliable interstitial. If you change it, regenerate `assets/images/polybuys-download-qr.png` + * so the encoded payload matches (e.g. `npx qrcode "" -o frontend/assets/images/polybuys-download-qr.png -w 440`). + */ export const APP_STORE_URL = 'https://polybuys.com/download'; export const APP_SCHEME = 'polybuys'; diff --git a/frontend/types/assets.d.ts b/frontend/types/assets.d.ts new file mode 100644 index 0000000..7572979 --- /dev/null +++ b/frontend/types/assets.d.ts @@ -0,0 +1,5 @@ +declare module '*.png' { + import type { ImageSourcePropType } from 'react-native'; + const value: ImageSourcePropType; + export default value; +} From d441cfd22c397a3cf997459f4944f1c7962ef3cc Mon Sep 17 00:00:00 2001 From: Evan Taylor Date: Mon, 20 Apr 2026 18:47:23 -0700 Subject: [PATCH 5/7] feat: revamp landing page, add legal docs --- .eslintrc.js | 11 + PRIVACY_POLICY.md | 83 ++ TERMS_OF_SERVICE.md | 66 ++ frontend/app/_layout.tsx | 2 + frontend/app/privacy.tsx | 5 + frontend/app/privacy.web.tsx | 12 + frontend/app/terms.tsx | 5 + frontend/app/terms.web.tsx | 12 + frontend/components/LandingScreen.web.tsx | 42 + frontend/components/landing/AppleIcon.tsx | 23 + frontend/components/landing/AvatarStack.tsx | 23 + frontend/components/landing/Brand.tsx | 31 + frontend/components/landing/Button.tsx | 71 ++ .../components/landing/DownloadButton.tsx | 119 +++ frontend/components/landing/Eyebrow.tsx | 28 + frontend/components/landing/Footer.tsx | 27 + frontend/components/landing/GetApp.tsx | 37 + frontend/components/landing/Hero.tsx | 88 ++ .../components/landing/LegalDocument.web.tsx | 96 ++ frontend/components/landing/ListingCard.tsx | 31 + frontend/components/landing/Nav.tsx | 31 + frontend/components/landing/SectionHead.tsx | 35 + frontend/components/landing/Ticker.tsx | 17 + frontend/components/landing/Why.tsx | 31 + frontend/components/landing/cx.ts | 4 + frontend/components/landing/data.ts | 99 ++ frontend/components/landing/helpers.ts | 10 + frontend/components/landing/index.ts | 9 + frontend/components/landing/legalContent.ts | 220 +++++ frontend/components/landing/styles.ts | 869 ++++++++++++++++++ frontend/components/landing/useScrolled.ts | 16 + frontend/package.json | 3 +- package-lock.json | 11 + 33 files changed, 2166 insertions(+), 1 deletion(-) create mode 100644 PRIVACY_POLICY.md create mode 100644 TERMS_OF_SERVICE.md create mode 100644 frontend/app/privacy.tsx create mode 100644 frontend/app/privacy.web.tsx create mode 100644 frontend/app/terms.tsx create mode 100644 frontend/app/terms.web.tsx create mode 100644 frontend/components/LandingScreen.web.tsx create mode 100644 frontend/components/landing/AppleIcon.tsx create mode 100644 frontend/components/landing/AvatarStack.tsx create mode 100644 frontend/components/landing/Brand.tsx create mode 100644 frontend/components/landing/Button.tsx create mode 100644 frontend/components/landing/DownloadButton.tsx create mode 100644 frontend/components/landing/Eyebrow.tsx create mode 100644 frontend/components/landing/Footer.tsx create mode 100644 frontend/components/landing/GetApp.tsx create mode 100644 frontend/components/landing/Hero.tsx create mode 100644 frontend/components/landing/LegalDocument.web.tsx create mode 100644 frontend/components/landing/ListingCard.tsx create mode 100644 frontend/components/landing/Nav.tsx create mode 100644 frontend/components/landing/SectionHead.tsx create mode 100644 frontend/components/landing/Ticker.tsx create mode 100644 frontend/components/landing/Why.tsx create mode 100644 frontend/components/landing/cx.ts create mode 100644 frontend/components/landing/data.ts create mode 100644 frontend/components/landing/helpers.ts create mode 100644 frontend/components/landing/index.ts create mode 100644 frontend/components/landing/legalContent.ts create mode 100644 frontend/components/landing/styles.ts create mode 100644 frontend/components/landing/useScrolled.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5375cfa..d380c6d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,5 +51,16 @@ module.exports = { '@typescript-eslint/no-require-imports': 'off', }, }, + { + // Web-only marketing pages render raw DOM (not React Native ), + // so the RN text-wrapping rule doesn't apply. + files: [ + 'frontend/components/landing/**/*.{ts,tsx}', + 'frontend/components/LandingScreen.web.tsx', + ], + rules: { + 'react-native/no-raw-text': 'off', + }, + }, ], }; diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..9998c70 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,83 @@ +# PolyBuys Privacy Policy + +Last updated: April 20, 2026 + +PolyBuys is a student marketplace for the Cal Poly community. This Privacy Policy explains what information we collect, how we use it, and the choices you have when you use the PolyBuys app, website, and related services. + +This policy applies to the PolyBuys iOS app, Android app, website, and any related features we operate. + +## Quick summary + +- We collect your Cal Poly email, account details, profile information, listings, listing photos, messages, reports, and other content you choose to submit. +- We also collect optional push notification tokens and limited device, app, crash, and error diagnostics to keep PolyBuys working reliably. +- We use this information to authenticate users, operate the marketplace, enable messaging and notifications, prevent abuse, and improve the service. + +## Information we collect + +We collect information you provide directly when you create an account, verify your email, edit your profile, upload photos, post a listing, message another user, save a listing, report content, or contact support. + +This can include: + +- Account and verification data, such as your Cal Poly email address, authentication records, and session information. +- Profile data, such as your name, bio, major, graduation year, and profile photo. +- Marketplace content, such as listing titles, descriptions, prices, item condition, categories, listing images, saved listings, messages, reports, and blocks. +- Notification and preference data, such as your push notification token and whether message notifications are enabled. +- Technical and diagnostic data, such as device/app diagnostics, crash reports, and error logs. + +## How we use information + +We use information we collect to: + +- verify eligible users and secure accounts; +- create profiles, publish listings, show marketplace content, and enable messaging between users; +- send verification emails and optional push notifications; +- detect spam, scams, abusive content, and other misuse of the service; and +- troubleshoot bugs, monitor reliability, respond to support requests, and improve PolyBuys. + +## Permissions and device access + +Some features ask for device permissions only when you choose to use them. + +- Photo library or camera access is used only if you choose to upload listing photos or a profile picture. +- Notification permission is used only if you allow PolyBuys to send message-related push notifications. +- PolyBuys stores authentication/session data locally on your device so you can stay signed in. + +## What we do not currently collect + +- We do not currently collect precise GPS location. +- We do not run third-party advertising inside PolyBuys or sell personal information. +- We do not process payments inside the app. + +## How information is shared + +Some information is shared with other users as part of the marketplace experience. For example, your public profile details, listings, listing photos, and messages are visible to the users involved in those interactions. + +We may also share information: + +- with service providers that help us operate PolyBuys, including Convex for backend infrastructure and storage, Resend for verification emails, Expo push notification services, and Sentry for crash and error monitoring; and +- when required by law, legal process, or a good-faith belief that sharing is necessary to protect the safety, rights, or integrity of PolyBuys, our users, or the public. + +## Retention and deletion + +We keep information for as long as needed to operate the service, maintain security, resolve disputes, and enforce our policies. + +If you delete your account, we will remove or de-identify associated account data from active systems, subject to records we may need to keep for fraud prevention, abuse investigation, or legal compliance. + +## Your choices + +- You can update your profile details in the app. +- You can remove or replace uploaded photos and listings you no longer want to display. +- You can disable message notifications. +- You can request account deletion from the app settings. + +## Children's privacy + +PolyBuys is not directed to children under 13, and we do not knowingly collect personal information from children under 13. + +## Changes to this policy + +We may update this Privacy Policy from time to time. If we make material changes, we will post the updated policy here and update the "Last updated" date. + +## Contact us + +If you have questions about this Privacy Policy or how PolyBuys handles data, contact us at [support@polybuys.com](mailto:support@polybuys.com). diff --git a/TERMS_OF_SERVICE.md b/TERMS_OF_SERVICE.md new file mode 100644 index 0000000..238fce5 --- /dev/null +++ b/TERMS_OF_SERVICE.md @@ -0,0 +1,66 @@ +# PolyBuys Terms of Service + +Last updated: April 20, 2026 + +These Terms of Service govern your use of PolyBuys. By accessing or using the PolyBuys app, website, or related services, you agree to these terms. + +PolyBuys is an independent student marketplace and is not affiliated with California Polytechnic State University. + +## Eligibility and accounts + +- You must be at least 13 years old and legally allowed to use the service. +- You must use a valid `@calpoly.edu` email address or another account expressly approved by PolyBuys. +- You are responsible for keeping your account information accurate and for activity that occurs under your account. + +## What PolyBuys provides + +PolyBuys provides a platform that lets users browse listings, post items, manage profiles, and message one another. + +PolyBuys is not the buyer, seller, broker, shipper, insurer, or guarantor in transactions between users. + +## Your content and listings + +- You are responsible for the listings, photos, profile content, messages, and other material you submit. +- You must have the right to post any content you upload and any item you offer for sale. +- By posting content to PolyBuys, you give us a limited license to host, store, reproduce, and display that content as needed to operate the service. +- You must keep listing information accurate, lawful, and not misleading. + +## Prohibited conduct + +You may not: + +- post illegal, stolen, counterfeit, unsafe, or otherwise prohibited goods; +- scam, spam, harass, threaten, impersonate, or abuse other users; +- submit false reports, evade blocks or moderation, or try to bypass account restrictions; +- scrape the service, interfere with its operation, upload malicious code, or attempt unauthorized access; or +- share another person's private information without permission. + +## Transactions and safety + +- Users are solely responsible for their own transactions, including pricing, payment, delivery, pickup, inspections, and resolving disputes. +- PolyBuys encourages users to meet in safe, public locations and to use reasonable caution before completing a transaction. +- PolyBuys does not guarantee item quality, seller identity, buyer identity, payment completion, or transaction outcomes. + +## Enforcement and termination + +We may remove content, limit features, suspend accounts, or terminate access at any time if we believe a user has violated these terms, created a safety risk, or exposed PolyBuys or other users to legal or operational harm. + +## Disclaimers + +PolyBuys is provided on an "as is" and "as available" basis. To the fullest extent permitted by law, we disclaim warranties of merchantability, fitness for a particular purpose, non-infringement, and uninterrupted availability. + +## Limitation of liability + +To the fullest extent permitted by law, PolyBuys and its operators will not be liable for indirect, incidental, special, consequential, or punitive damages, or for losses arising from user-to-user transactions, listings, messages, or use of the service. + +## Privacy + +Your use of PolyBuys is also governed by the [Privacy Policy](./PRIVACY_POLICY.md), which explains how we collect, use, and share data. + +## Changes to these terms + +We may update these Terms of Service from time to time. If we do, we will post the revised version here and update the "Last updated" date. + +## Contact us + +If you have questions about these Terms of Service, contact us at [support@polybuys.com](mailto:support@polybuys.com). diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 261d7c4..b6dec77 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -122,6 +122,8 @@ function RootLayout() { name="profile/[userId]" options={{ title: 'Profile', headerBackTitle: 'Back' }} /> + + diff --git a/frontend/app/privacy.tsx b/frontend/app/privacy.tsx new file mode 100644 index 0000000..213b6fe --- /dev/null +++ b/frontend/app/privacy.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function PrivacyRoute() { + return ; +} diff --git a/frontend/app/privacy.web.tsx b/frontend/app/privacy.web.tsx new file mode 100644 index 0000000..92ad3ff --- /dev/null +++ b/frontend/app/privacy.web.tsx @@ -0,0 +1,12 @@ +import { LegalDocumentPage } from '../components/landing/LegalDocument.web'; +import { PRIVACY_POLICY_DOC } from '../components/landing/legalContent'; + +export default function PrivacyRoute() { + return ( + + ); +} diff --git a/frontend/app/terms.tsx b/frontend/app/terms.tsx new file mode 100644 index 0000000..0a1d2c9 --- /dev/null +++ b/frontend/app/terms.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function TermsRoute() { + return ; +} diff --git a/frontend/app/terms.web.tsx b/frontend/app/terms.web.tsx new file mode 100644 index 0000000..5e671b3 --- /dev/null +++ b/frontend/app/terms.web.tsx @@ -0,0 +1,12 @@ +import { LegalDocumentPage } from '../components/landing/LegalDocument.web'; +import { TERMS_OF_SERVICE_DOC } from '../components/landing/legalContent'; + +export default function TermsRoute() { + return ( + + ); +} diff --git a/frontend/components/LandingScreen.web.tsx b/frontend/components/LandingScreen.web.tsx new file mode 100644 index 0000000..cc723e1 --- /dev/null +++ b/frontend/components/LandingScreen.web.tsx @@ -0,0 +1,42 @@ +import Head from 'expo-router/head'; +import { Footer, GetApp, GLOBAL_CSS, Hero, Nav, Why, useScrolled } from './landing'; + +/** + * Marketing landing page (web only). The app redirects non-web clients to `/home` + * in `app/index.tsx`, so this file only runs in the browser. + * + * Each section lives in `./landing/` as its own component; this file composes them. + */ +export default function LandingScreen() { + const scrolled = useScrolled(); + + return ( + <> + + PolyBuys — the Cal Poly student marketplace + + + + + + + +
+
+ + ); +} diff --git a/frontend/components/landing/AppleIcon.tsx b/frontend/components/landing/AppleIcon.tsx new file mode 100644 index 0000000..7280ced --- /dev/null +++ b/frontend/components/landing/AppleIcon.tsx @@ -0,0 +1,23 @@ +import { cx } from './cx'; + +interface AppleIconProps { + /** Size in pixels. Height scales proportionally. Defaults to 14. */ + size?: number; + className?: string; +} + +/** Apple logo glyph. Use only to refer to Apple products (App Store, iOS), per Apple guidelines. */ +export function AppleIcon({ size = 14, className }: AppleIconProps) { + return ( + + + + ); +} diff --git a/frontend/components/landing/AvatarStack.tsx b/frontend/components/landing/AvatarStack.tsx new file mode 100644 index 0000000..badef25 --- /dev/null +++ b/frontend/components/landing/AvatarStack.tsx @@ -0,0 +1,23 @@ +import type { AvatarEntry } from './data'; +import { cx } from './cx'; + +interface AvatarStackProps { + items: readonly AvatarEntry[]; + className?: string; +} + +export function AvatarStack({ items, className }: AvatarStackProps) { + return ( +
+ {items.map((a, i) => ( + + {a.initial} + + ))} +
+ ); +} diff --git a/frontend/components/landing/Brand.tsx b/frontend/components/landing/Brand.tsx new file mode 100644 index 0000000..4f1cb13 --- /dev/null +++ b/frontend/components/landing/Brand.tsx @@ -0,0 +1,31 @@ +import { cx } from './cx'; + +interface BrandProps { + /** Render as a subtle monochrome lockup (used in the footer). */ + muted?: boolean; + /** Href for the anchor. When omitted, renders as a ``. */ + href?: string; + ariaLabel?: string; + className?: string; +} + +export function Brand({ muted = false, href, ariaLabel, className }: BrandProps) { + const classes = cx('pb-brand', muted && 'pb-brand--muted', className); + const content = ( + <> + + + + PolyBuys + + ); + + if (href) { + return ( + + {content} + + ); + } + return {content}; +} diff --git a/frontend/components/landing/Button.tsx b/frontend/components/landing/Button.tsx new file mode 100644 index 0000000..63c0eec --- /dev/null +++ b/frontend/components/landing/Button.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import { cx } from './cx'; + +export type ButtonVariant = 'primary' | 'ghost' | 'ghostOnDark' | 'cream'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +type CommonProps = { + variant?: ButtonVariant; + size?: ButtonSize; + /** Appends an animated `→` glyph that slides on hover. */ + trailingArrow?: boolean; + className?: string; + children: ReactNode; +}; + +type LinkProps = CommonProps & { + href: string; + target?: string; + rel?: string; + ariaLabel?: string; + onClick?: never; +}; + +type ActionProps = CommonProps & { + onClick: () => void; + href?: never; + ariaLabel?: string; +}; + +export type ButtonProps = LinkProps | ActionProps; + +function classesFor(variant: ButtonVariant, size: ButtonSize, extra?: string) { + return cx('pb-btn', `pb-btn--${size}`, `pb-btn--${variant}`, extra); +} + +/** Single entry point for all CTAs — renders `` when given `href`, else ` + ); +} diff --git a/frontend/components/landing/DownloadButton.tsx b/frontend/components/landing/DownloadButton.tsx new file mode 100644 index 0000000..52c460a --- /dev/null +++ b/frontend/components/landing/DownloadButton.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AppleIcon } from './AppleIcon'; +import type { ButtonSize, ButtonVariant } from './Button'; +import { cx } from './cx'; +import { APP_STORE_URL, QR_SRC } from './data'; + +interface DownloadButtonProps { + variant?: ButtonVariant; + size?: ButtonSize; + /** Visible button label. Defaults to "Download on iOS". */ + label?: string; + className?: string; +} + +/** + * Smart download CTA. On touch-primary devices, clicks open the App Store + * directly. On desktop (hover-capable pointer), clicks open a QR code modal + * so the visitor can scan from their phone. + */ +export function DownloadButton({ + variant = 'primary', + size = 'lg', + label = 'Download on iOS', + className, +}: DownloadButtonProps) { + const [modalOpen, setModalOpen] = useState(false); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + if (typeof window === 'undefined') return; + const touchPrimary = window.matchMedia('(hover: none) and (pointer: coarse)').matches; + if (touchPrimary) { + window.location.href = APP_STORE_URL; + return; + } + setModalOpen(true); + }, []); + + const handleClose = useCallback(() => { + setModalOpen(false); + // Return focus to the trigger button on close. + triggerRef.current?.focus(); + }, []); + + return ( + <> + + + {modalOpen && typeof document !== 'undefined' + ? createPortal(, document.body) + : null} + + ); +} + +function QrModal({ onClose }: { onClose: () => void }) { + const cardRef = useRef(null); + const closeBtnRef = useRef(null); + + useEffect(() => { + closeBtnRef.current?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const onClickAway = (e: MouseEvent) => { + if (cardRef.current && !cardRef.current.contains(e.target as Node)) onClose(); + }; + + window.addEventListener('keydown', onKey); + // Defer so the triggering click doesn't immediately close the modal. + const t = window.setTimeout(() => document.addEventListener('mousedown', onClickAway), 0); + + return () => { + window.removeEventListener('keydown', onKey); + window.clearTimeout(t); + document.removeEventListener('mousedown', onClickAway); + }; + }, [onClose]); + + return ( +
+
+
+ +

+ Download on the App Store +

+ {QR_SRC ? ( + QR code for PolyBuys on the App Store + ) : null} +

Scan with your iPhone

+
+
+ ); +} diff --git a/frontend/components/landing/Eyebrow.tsx b/frontend/components/landing/Eyebrow.tsx new file mode 100644 index 0000000..f9a4771 --- /dev/null +++ b/frontend/components/landing/Eyebrow.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import { cx } from './cx'; + +export type EyebrowTone = 'default' | 'muted' | 'onDark'; + +interface EyebrowProps { + tone?: EyebrowTone; + /** Render as a `

` for section heads, or keep the default `

` inside flex rows. */ + as?: 'p' | 'div'; + className?: string; + children: ReactNode; +} + +export function Eyebrow({ tone = 'default', as = 'div', className, children }: EyebrowProps) { + const classes = cx( + 'pb-eyebrow', + tone === 'muted' && 'pb-eyebrow--muted', + tone === 'onDark' && 'pb-eyebrow--onDark', + className + ); + const inner = ( + <> + + {children} + + ); + return as === 'p' ?

{inner}

:
{inner}
; +} diff --git a/frontend/components/landing/Footer.tsx b/frontend/components/landing/Footer.tsx new file mode 100644 index 0000000..6b33397 --- /dev/null +++ b/frontend/components/landing/Footer.tsx @@ -0,0 +1,27 @@ +import { Brand } from './Brand'; +import { LEGAL_SUPPORT_EMAIL } from './legalContent'; + +export function Footer() { + return ( + + ); +} diff --git a/frontend/components/landing/GetApp.tsx b/frontend/components/landing/GetApp.tsx new file mode 100644 index 0000000..94f6ca6 --- /dev/null +++ b/frontend/components/landing/GetApp.tsx @@ -0,0 +1,37 @@ +import { Button } from './Button'; +import { DownloadButton } from './DownloadButton'; +import { Eyebrow } from './Eyebrow'; +import { openDownloadLinkEmail } from './helpers'; + +export function GetApp() { + return ( +
+
+
+ + Get the app + +

+ Browse on the web. Trade in the app. +

+

+ Browse listings here anytime. On iOS you can post, message buyers and sellers, and sign + in with your Cal Poly email — Mustangs only, like the rest of PolyBuys. +

+ +
+ + +
+ +

+ Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other + countries. +

+
+
+
+ ); +} diff --git a/frontend/components/landing/Hero.tsx b/frontend/components/landing/Hero.tsx new file mode 100644 index 0000000..ba3b81e --- /dev/null +++ b/frontend/components/landing/Hero.tsx @@ -0,0 +1,88 @@ +import { useRouter } from 'expo-router'; +import { AvatarStack } from './AvatarStack'; +import { Button } from './Button'; +import { DownloadButton } from './DownloadButton'; +import { Eyebrow } from './Eyebrow'; +import { ListingCard } from './ListingCard'; +import { Ticker } from './Ticker'; +import { AVATAR_STACK, HERO_LISTINGS } from './data'; + +/** Eyebrow → headline → subtitle → CTAs → social proof on the left, + * floating preview cards + ambient orbs on the right. Includes the ticker. */ +export function Hero() { + const router = useRouter(); + + return ( +
+
+
+ Cal Poly · Student marketplace + +

+ Buy & sell on campus,{' '} + + without the noise. + + +

+ +

+ PolyBuys is the marketplace for Mustangs — textbooks, subleases, furniture, and daily + essentials from people who share your campus. +

+ +
+ + +
+ + +
+ + +
+ + +
+ ); +} + +function HeroUnderline() { + return ( + + + + ); +} + +function SocialProof() { + return ( +
+ +

+ Mustangs only — verified by Cal Poly email +

+
+ ); +} + +function HeroStage() { + return ( +
+
+
+
+ + +
+
+ ); +} diff --git a/frontend/components/landing/LegalDocument.web.tsx b/frontend/components/landing/LegalDocument.web.tsx new file mode 100644 index 0000000..549c0cb --- /dev/null +++ b/frontend/components/landing/LegalDocument.web.tsx @@ -0,0 +1,96 @@ +import Head from 'expo-router/head'; +import { Brand } from './Brand'; +import { Button } from './Button'; +import { Footer } from './Footer'; +import type { LegalDocument } from './legalContent'; +import { GLOBAL_CSS } from './styles'; + +interface LegalDocumentPageProps { + document: LegalDocument; + siblingHref: string; + siblingLabel: string; +} + +export function LegalDocumentPage({ document, siblingHref, siblingLabel }: LegalDocumentPageProps) { + return ( + <> + + {document.title} | PolyBuys + + + + + + + + + +
+
+
+ + +
+

{document.eyebrow}

+

{document.title}

+

Last updated {document.updatedAt}

+ +
+ {document.intro.map((paragraph) => ( +

{paragraph}

+ ))} +
+ +
+ {document.sections.map((section) => ( +
+

{section.title}

+ + {section.paragraphs?.map((paragraph) => ( +

+ {paragraph} +

+ ))} + + {section.bullets?.length ? ( +
    + {section.bullets.map((bullet) => ( +
  • {bullet}
  • + ))} +
+ ) : null} +
+ ))} +
+ +
+ + +
+
+
+ +
+
+
+ + ); +} diff --git a/frontend/components/landing/ListingCard.tsx b/frontend/components/landing/ListingCard.tsx new file mode 100644 index 0000000..71aff8f --- /dev/null +++ b/frontend/components/landing/ListingCard.tsx @@ -0,0 +1,31 @@ +import type { SampleListing } from './data'; +import { cx } from './cx'; + +type CardVariant = 'back' | 'front'; + +interface ListingCardProps { + listing: SampleListing; + /** Controls the tilt / stacking position of the card in the hero stage. */ + variant: CardVariant; + className?: string; +} + +/** Compact preview card used in the hero stage to showcase marketplace listings. */ +export function ListingCard({ listing, variant, className }: ListingCardProps) { + return ( +
+
+ {listing.emoji} + {listing.badge ? {listing.badge} : null} +
+
+ {listing.category} +

{listing.title}

+
+ {listing.price} + · {listing.seller} +
+
+
+ ); +} diff --git a/frontend/components/landing/Nav.tsx b/frontend/components/landing/Nav.tsx new file mode 100644 index 0000000..f0aa696 --- /dev/null +++ b/frontend/components/landing/Nav.tsx @@ -0,0 +1,31 @@ +import { Brand } from './Brand'; +import { DownloadButton } from './DownloadButton'; +import { cx } from './cx'; + +interface NavProps { + /** Whether the page has scrolled past the top — used to bump background opacity. */ + scrolled: boolean; +} + +export function Nav({ scrolled }: NavProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/components/landing/SectionHead.tsx b/frontend/components/landing/SectionHead.tsx new file mode 100644 index 0000000..b30b9d8 --- /dev/null +++ b/frontend/components/landing/SectionHead.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { Eyebrow, type EyebrowTone } from './Eyebrow'; +import { cx } from './cx'; + +interface SectionHeadProps { + eyebrow: ReactNode; + eyebrowTone?: EyebrowTone; + /** Accepts rich content so callers can embed `` accents. */ + title: ReactNode; + /** Optional `id` for the heading, used to wire `aria-labelledby` on the parent section. */ + titleId?: string; + subtitle?: ReactNode; + className?: string; +} + +export function SectionHead({ + eyebrow, + eyebrowTone = 'muted', + title, + titleId, + subtitle, + className, +}: SectionHeadProps) { + return ( +
+ + {eyebrow} + +

+ {title} +

+ {subtitle ?

{subtitle}

: null} +
+ ); +} diff --git a/frontend/components/landing/Ticker.tsx b/frontend/components/landing/Ticker.tsx new file mode 100644 index 0000000..caa3b4e --- /dev/null +++ b/frontend/components/landing/Ticker.tsx @@ -0,0 +1,17 @@ +import { TICKER_ITEMS } from './data'; + +/** Infinite horizontal marquee of marketplace categories. Items duplicated for seamless loop. */ +export function Ticker() { + return ( +
+
+ {[...TICKER_ITEMS, ...TICKER_ITEMS].map((item, i) => ( + + {item.emoji} + {item.label} + + ))} +
+
+ ); +} diff --git a/frontend/components/landing/Why.tsx b/frontend/components/landing/Why.tsx new file mode 100644 index 0000000..2702929 --- /dev/null +++ b/frontend/components/landing/Why.tsx @@ -0,0 +1,31 @@ +import { SectionHead } from './SectionHead'; +import { VALUE_POINTS, type ValuePoint } from './data'; + +export function Why() { + return ( +
+ +
+ {VALUE_POINTS.map((item, i) => ( + + ))} +
+
+ ); +} + +function ValueCard({ item, delayMs }: { item: ValuePoint; delayMs: number }) { + return ( +
+
+ {item.icon} +
+

{item.title}

+

{item.body}

+
+ ); +} diff --git a/frontend/components/landing/cx.ts b/frontend/components/landing/cx.ts new file mode 100644 index 0000000..c69ace0 --- /dev/null +++ b/frontend/components/landing/cx.ts @@ -0,0 +1,4 @@ +/** Join class name fragments, dropping falsy entries. */ +export function cx(...parts: Array): string { + return parts.filter(Boolean).join(' '); +} diff --git a/frontend/components/landing/data.ts b/frontend/components/landing/data.ts new file mode 100644 index 0000000..b0828f9 --- /dev/null +++ b/frontend/components/landing/data.ts @@ -0,0 +1,99 @@ +import { APP_STORE_URL } from '../../constants/app'; +import qrDownloadPng from '../../assets/images/polybuys-download-qr.png'; + +/** Official App Store badge artwork (Apple Marketing Resources). */ +export const APP_STORE_BADGE_URI = + 'https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83'; + +export { APP_STORE_URL }; + +/** Metro resolves PNG imports differently per environment. Normalise to a URL string. */ +export const QR_SRC = + typeof qrDownloadPng === 'string' + ? qrDownloadPng + : ((qrDownloadPng as { uri?: string; default?: string }).uri ?? + (qrDownloadPng as unknown as { default: string }).default ?? + ''); + +export type SampleListing = { + title: string; + category: string; + price: string; + seller: string; + location: string; + emoji: string; + gradient: string; + badge?: string; +}; + +/** Just the two cards rendered in the hero stage. */ +export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ + { + title: 'Calculus: Early Transcendentals, 8e', + category: 'Textbooks', + price: '$40', + seller: 'Hazel', + location: 'Poly Canyon', + emoji: '📚', + gradient: 'linear-gradient(135deg, #1E5C44 0%, #154734 100%)', + badge: 'Just listed', + }, + { + title: 'IKEA Kallax 4-cube shelf', + category: 'Furniture', + price: '$35', + seller: 'Mateo', + location: 'Mustang Village', + emoji: '🗄️', + gradient: 'linear-gradient(135deg, #F3D38B 0%, #E2A84A 100%)', + }, +]; + +export type ValuePoint = { + title: string; + body: string; + icon: string; +}; + +export const VALUE_POINTS: readonly ValuePoint[] = [ + { + title: 'Students only', + body: 'Cal Poly email sign-in runs in the iOS app so listings stay in the campus community.', + icon: '🎓', + }, + { + title: 'Built for how you actually buy', + body: 'Textbooks, housing, furniture, and everyday gear — organized for quick browsing and search.', + icon: '🔍', + }, + { + title: 'Campus-first trust', + body: 'Clear seller profiles and in-app messaging help you coordinate pickups without giving out extra contact info.', + icon: '🤝', + }, +]; + +export type TickerItem = { emoji: string; label: string }; + +export const TICKER_ITEMS: readonly TickerItem[] = [ + { emoji: '📚', label: 'Textbooks' }, + { emoji: '🚲', label: 'Bikes' }, + { emoji: '🛋️', label: 'Furniture' }, + { emoji: '🏠', label: 'Subleases' }, + { emoji: '🎒', label: 'Backpacks' }, + { emoji: '🧊', label: 'Mini fridges' }, + { emoji: '🖥️', label: 'Desks' }, + { emoji: '🚗', label: 'Parking passes' }, + { emoji: '🪴', label: 'Plants' }, + { emoji: '🎸', label: 'Instruments' }, +]; + +export type AvatarEntry = { initial: string; bg: string }; + +export const AVATAR_STACK: readonly AvatarEntry[] = [ + { initial: 'H', bg: '#1E5C44' }, + { initial: 'M', bg: '#E2A84A' }, + { initial: 'P', bg: '#FF6E5E' }, + { initial: 'A', bg: '#2B7A5A' }, + { initial: 'C', bg: '#A48BD1' }, +]; diff --git a/frontend/components/landing/helpers.ts b/frontend/components/landing/helpers.ts new file mode 100644 index 0000000..109928b --- /dev/null +++ b/frontend/components/landing/helpers.ts @@ -0,0 +1,10 @@ +import { APP_STORE_URL } from './data'; + +/** Open the user's mail client with a pre-filled download link. */ +export function openDownloadLinkEmail(): void { + const subject = encodeURIComponent('PolyBuys — download link'); + const body = encodeURIComponent( + `Here's the link to get PolyBuys:\n\n${APP_STORE_URL}\n\nOpen it on your phone to install or download.\n` + ); + window.location.href = `mailto:?subject=${subject}&body=${body}`; +} diff --git a/frontend/components/landing/index.ts b/frontend/components/landing/index.ts new file mode 100644 index 0000000..033aa8d --- /dev/null +++ b/frontend/components/landing/index.ts @@ -0,0 +1,9 @@ +export { Nav } from './Nav'; +export { Hero } from './Hero'; +export { Ticker } from './Ticker'; +export { Why } from './Why'; +export { GetApp } from './GetApp'; +export { Footer } from './Footer'; +export { DownloadButton } from './DownloadButton'; +export { GLOBAL_CSS } from './styles'; +export { useScrolled } from './useScrolled'; diff --git a/frontend/components/landing/legalContent.ts b/frontend/components/landing/legalContent.ts new file mode 100644 index 0000000..9e9be32 --- /dev/null +++ b/frontend/components/landing/legalContent.ts @@ -0,0 +1,220 @@ +export const LEGAL_SUPPORT_EMAIL = + process.env.EXPO_PUBLIC_SUPPORT_EMAIL?.trim() || 'support@polybuys.com'; + +export const LEGAL_UPDATED_AT = 'April 20, 2026'; + +export type LegalSection = { + title: string; + paragraphs?: readonly string[]; + bullets?: readonly string[]; +}; + +export type LegalDocument = { + slug: 'privacy' | 'terms'; + eyebrow: string; + title: string; + description: string; + updatedAt: string; + intro: readonly string[]; + sections: readonly LegalSection[]; +}; + +export const PRIVACY_POLICY_DOC: LegalDocument = { + slug: 'privacy', + eyebrow: 'Privacy Policy', + title: 'PolyBuys Privacy Policy', + description: + 'How PolyBuys collects, uses, shares, and deletes account data, listings, messages, notifications, and diagnostics.', + updatedAt: LEGAL_UPDATED_AT, + intro: [ + 'PolyBuys is a student marketplace for the Cal Poly community. This Privacy Policy explains what information we collect, how we use it, and the choices you have when you use the PolyBuys app, website, and related services.', + 'This policy applies to the PolyBuys iOS app, Android app, website, and any related features we operate.', + ], + sections: [ + { + title: 'Quick summary', + bullets: [ + 'We collect your Cal Poly email, account details, profile information, listings, listing photos, messages, reports, and other content you choose to submit.', + 'We also collect optional push notification tokens and limited device, app, crash, and error diagnostics to keep PolyBuys working reliably.', + 'We use this information to authenticate users, operate the marketplace, enable messaging and notifications, prevent abuse, and improve the service.', + ], + }, + { + title: 'Information we collect', + paragraphs: [ + 'We collect information you provide directly when you create an account, verify your email, edit your profile, upload photos, post a listing, message another user, save a listing, report content, or contact support.', + ], + bullets: [ + 'Account and verification data, such as your Cal Poly email address, authentication records, and session information.', + 'Profile data, such as your name, bio, major, graduation year, and profile photo.', + 'Marketplace content, such as listing titles, descriptions, prices, item condition, categories, listing images, saved listings, messages, reports, and blocks.', + 'Notification and preference data, such as your push notification token and whether message notifications are enabled.', + 'Technical and diagnostic data, such as device/app diagnostics, crash reports, and error logs.', + ], + }, + { + title: 'How we use information', + bullets: [ + 'To verify eligible users and secure accounts.', + 'To create profiles, publish listings, show marketplace content, and enable messaging between users.', + 'To send verification emails and optional push notifications.', + 'To detect spam, scams, abusive content, and other misuse of the service.', + 'To troubleshoot bugs, monitor reliability, respond to support requests, and improve PolyBuys.', + ], + }, + { + title: 'Permissions and device access', + paragraphs: ['Some features ask for device permissions only when you choose to use them.'], + bullets: [ + 'Photo library or camera access is used only if you choose to upload listing photos or a profile picture.', + 'Notification permission is used only if you allow PolyBuys to send message-related push notifications.', + 'PolyBuys stores authentication/session data locally on your device so you can stay signed in.', + ], + }, + { + title: 'What we do not currently collect', + bullets: [ + 'We do not currently collect precise GPS location.', + 'We do not run third-party advertising inside PolyBuys or sell personal information.', + 'We do not process payments inside the app.', + ], + }, + { + title: 'How information is shared', + paragraphs: [ + 'Some information is shared with other users as part of the marketplace experience. For example, your public profile details, listings, listing photos, and messages are visible to the users involved in those interactions.', + ], + bullets: [ + 'With service providers that help us operate PolyBuys, including Convex for backend infrastructure and storage, Resend for verification emails, Expo push notification services, and Sentry for crash and error monitoring.', + 'When required by law, legal process, or a good-faith belief that sharing is necessary to protect the safety, rights, or integrity of PolyBuys, our users, or the public.', + ], + }, + { + title: 'Retention and deletion', + paragraphs: [ + 'We keep information for as long as needed to operate the service, maintain security, resolve disputes, and enforce our policies.', + 'If you delete your account, we will remove or de-identify associated account data from active systems, subject to records we may need to keep for fraud prevention, abuse investigation, or legal compliance.', + ], + }, + { + title: 'Your choices', + bullets: [ + 'You can update your profile details in the app.', + 'You can remove or replace uploaded photos and listings you no longer want to display.', + 'You can disable message notifications.', + 'You can request account deletion from the app settings.', + ], + }, + { + title: "Children's privacy", + paragraphs: [ + 'PolyBuys is not directed to children under 13, and we do not knowingly collect personal information from children under 13.', + ], + }, + { + title: 'Changes to this policy', + paragraphs: [ + 'We may update this Privacy Policy from time to time. If we make material changes, we will post the updated policy here and update the "Last updated" date.', + ], + }, + { + title: 'Contact us', + paragraphs: [ + `If you have questions about this Privacy Policy or how PolyBuys handles data, contact us at ${LEGAL_SUPPORT_EMAIL}.`, + ], + }, + ], +}; + +export const TERMS_OF_SERVICE_DOC: LegalDocument = { + slug: 'terms', + eyebrow: 'Terms of Service', + title: 'PolyBuys Terms of Service', + description: + 'Rules for using PolyBuys, including eligibility, listings, user conduct, transactions, enforcement, and liability.', + updatedAt: LEGAL_UPDATED_AT, + intro: [ + 'These Terms of Service govern your use of PolyBuys. By accessing or using the PolyBuys app, website, or related services, you agree to these terms.', + 'PolyBuys is an independent student marketplace and is not affiliated with California Polytechnic State University.', + ], + sections: [ + { + title: 'Eligibility and accounts', + bullets: [ + 'You must be at least 13 years old and legally allowed to use the service.', + 'You must use a valid @calpoly.edu email address or another account expressly approved by PolyBuys.', + 'You are responsible for keeping your account information accurate and for activity that occurs under your account.', + ], + }, + { + title: 'What PolyBuys provides', + paragraphs: [ + 'PolyBuys provides a platform that lets users browse listings, post items, manage profiles, and message one another.', + 'PolyBuys is not the buyer, seller, broker, shipper, insurer, or guarantor in transactions between users.', + ], + }, + { + title: 'Your content and listings', + bullets: [ + 'You are responsible for the listings, photos, profile content, messages, and other material you submit.', + 'You must have the right to post any content you upload and any item you offer for sale.', + 'By posting content to PolyBuys, you give us a limited license to host, store, reproduce, and display that content as needed to operate the service.', + 'You must keep listing information accurate, lawful, and not misleading.', + ], + }, + { + title: 'Prohibited conduct', + bullets: [ + 'Do not post illegal, stolen, counterfeit, unsafe, or otherwise prohibited goods.', + 'Do not scam, spam, harass, threaten, impersonate, or abuse other users.', + 'Do not submit false reports, evade blocks or moderation, or try to bypass account restrictions.', + 'Do not scrape the service, interfere with its operation, upload malicious code, or attempt unauthorized access.', + "Do not share another person's private information without permission.", + ], + }, + { + title: 'Transactions and safety', + bullets: [ + 'Users are solely responsible for their own transactions, including pricing, payment, delivery, pickup, inspections, and resolving disputes.', + 'PolyBuys encourages users to meet in safe, public locations and to use reasonable caution before completing a transaction.', + 'PolyBuys does not guarantee item quality, seller identity, buyer identity, payment completion, or transaction outcomes.', + ], + }, + { + title: 'Enforcement and termination', + paragraphs: [ + 'We may remove content, limit features, suspend accounts, or terminate access at any time if we believe a user has violated these terms, created a safety risk, or exposed PolyBuys or other users to legal or operational harm.', + ], + }, + { + title: 'Disclaimers', + paragraphs: [ + 'PolyBuys is provided on an "as is" and "as available" basis. To the fullest extent permitted by law, we disclaim warranties of merchantability, fitness for a particular purpose, non-infringement, and uninterrupted availability.', + ], + }, + { + title: 'Limitation of liability', + paragraphs: [ + 'To the fullest extent permitted by law, PolyBuys and its operators will not be liable for indirect, incidental, special, consequential, or punitive damages, or for losses arising from user-to-user transactions, listings, messages, or use of the service.', + ], + }, + { + title: 'Privacy', + paragraphs: [ + 'Your use of PolyBuys is also governed by our Privacy Policy, which explains how we collect, use, and share data.', + ], + }, + { + title: 'Changes to these terms', + paragraphs: [ + 'We may update these Terms of Service from time to time. If we do, we will post the revised version here and update the "Last updated" date.', + ], + }, + { + title: 'Contact us', + paragraphs: [ + `If you have questions about these Terms of Service, contact us at ${LEGAL_SUPPORT_EMAIL}.`, + ], + }, + ], +}; diff --git a/frontend/components/landing/styles.ts b/frontend/components/landing/styles.ts new file mode 100644 index 0000000..0f2f757 --- /dev/null +++ b/frontend/components/landing/styles.ts @@ -0,0 +1,869 @@ +/** + * Global CSS for the web landing page. Injected once via a ` diff --git a/frontend/components/landing/AppleIcon.tsx b/frontend/components/landing/AppleIcon.tsx index 7280ced..cbab269 100644 --- a/frontend/components/landing/AppleIcon.tsx +++ b/frontend/components/landing/AppleIcon.tsx @@ -1,12 +1,10 @@ import { cx } from './cx'; interface AppleIconProps { - /** Size in pixels. Height scales proportionally. Defaults to 14. */ size?: number; className?: string; } -/** Apple logo glyph. Use only to refer to Apple products (App Store, iOS), per Apple guidelines. */ export function AppleIcon({ size = 14, className }: AppleIconProps) { return ( `. */ href?: string; ariaLabel?: string; className?: string; diff --git a/frontend/components/landing/Button.tsx b/frontend/components/landing/Button.tsx index 63c0eec..52d341f 100644 --- a/frontend/components/landing/Button.tsx +++ b/frontend/components/landing/Button.tsx @@ -7,7 +7,6 @@ export type ButtonSize = 'sm' | 'md' | 'lg'; type CommonProps = { variant?: ButtonVariant; size?: ButtonSize; - /** Appends an animated `→` glyph that slides on hover. */ trailingArrow?: boolean; className?: string; children: ReactNode; @@ -33,7 +32,6 @@ function classesFor(variant: ButtonVariant, size: ButtonSize, extra?: string) { return cx('pb-btn', `pb-btn--${size}`, `pb-btn--${variant}`, extra); } -/** Single entry point for all CTAs — renders `` when given `href`, else ` - {modalOpen && typeof document !== 'undefined' - ? createPortal(, document.body) - : null} + {modalOpen ? createPortal(, document.body) : null} ); } @@ -79,7 +69,6 @@ function QrModal({ onClose }: { onClose: () => void }) { }; window.addEventListener('keydown', onKey); - // Defer so the triggering click doesn't immediately close the modal. const t = window.setTimeout(() => document.addEventListener('mousedown', onClickAway), 0); return () => { diff --git a/frontend/components/landing/Eyebrow.tsx b/frontend/components/landing/Eyebrow.tsx index f9a4771..8712a70 100644 --- a/frontend/components/landing/Eyebrow.tsx +++ b/frontend/components/landing/Eyebrow.tsx @@ -5,7 +5,6 @@ export type EyebrowTone = 'default' | 'muted' | 'onDark'; interface EyebrowProps { tone?: EyebrowTone; - /** Render as a `

` for section heads, or keep the default `

` inside flex rows. */ as?: 'p' | 'div'; className?: string; children: ReactNode; diff --git a/frontend/components/landing/GetApp.tsx b/frontend/components/landing/GetApp.tsx index 94f6ca6..322bbc7 100644 --- a/frontend/components/landing/GetApp.tsx +++ b/frontend/components/landing/GetApp.tsx @@ -1,11 +1,20 @@ import { Button } from './Button'; import { DownloadButton } from './DownloadButton'; import { Eyebrow } from './Eyebrow'; +import { cx } from './cx'; import { openDownloadLinkEmail } from './helpers'; +import { useRevealOnVisible } from './useRevealOnVisible'; export function GetApp() { + const { ref, visible } = useRevealOnVisible(); + return ( -
+
diff --git a/frontend/components/landing/Hero.tsx b/frontend/components/landing/Hero.tsx index ba3b81e..4d5c3c0 100644 --- a/frontend/components/landing/Hero.tsx +++ b/frontend/components/landing/Hero.tsx @@ -7,8 +7,6 @@ import { ListingCard } from './ListingCard'; import { Ticker } from './Ticker'; import { AVATAR_STACK, HERO_LISTINGS } from './data'; -/** Eyebrow → headline → subtitle → CTAs → social proof on the left, - * floating preview cards + ambient orbs on the right. Includes the ticker. */ export function Hero() { const router = useRouter(); @@ -19,7 +17,7 @@ export function Hero() { Cal Poly · Student marketplace

- Buy & sell on campus,{' '} + Buy {'&'} sell on campus,{' '} without the noise. diff --git a/frontend/components/landing/LegalDocument.web.tsx b/frontend/components/landing/LegalDocument.web.tsx index 549c0cb..ab10054 100644 --- a/frontend/components/landing/LegalDocument.web.tsx +++ b/frontend/components/landing/LegalDocument.web.tsx @@ -22,7 +22,7 @@ export function LegalDocumentPage({ document, siblingHref, siblingLabel }: Legal diff --git a/frontend/components/landing/ListingCard.tsx b/frontend/components/landing/ListingCard.tsx index 71aff8f..eb580c1 100644 --- a/frontend/components/landing/ListingCard.tsx +++ b/frontend/components/landing/ListingCard.tsx @@ -1,21 +1,22 @@ import type { SampleListing } from './data'; +import { ListingThumbIcon } from './icons'; import { cx } from './cx'; type CardVariant = 'back' | 'front'; interface ListingCardProps { listing: SampleListing; - /** Controls the tilt / stacking position of the card in the hero stage. */ variant: CardVariant; className?: string; } -/** Compact preview card used in the hero stage to showcase marketplace listings. */ export function ListingCard({ listing, variant, className }: ListingCardProps) { return (
- {listing.emoji} + + + {listing.badge ? {listing.badge} : null}
diff --git a/frontend/components/landing/Nav.tsx b/frontend/components/landing/Nav.tsx index f0aa696..b9289f5 100644 --- a/frontend/components/landing/Nav.tsx +++ b/frontend/components/landing/Nav.tsx @@ -3,7 +3,6 @@ import { DownloadButton } from './DownloadButton'; import { cx } from './cx'; interface NavProps { - /** Whether the page has scrolled past the top — used to bump background opacity. */ scrolled: boolean; } diff --git a/frontend/components/landing/SectionHead.tsx b/frontend/components/landing/SectionHead.tsx index b30b9d8..9ece09a 100644 --- a/frontend/components/landing/SectionHead.tsx +++ b/frontend/components/landing/SectionHead.tsx @@ -5,9 +5,7 @@ import { cx } from './cx'; interface SectionHeadProps { eyebrow: ReactNode; eyebrowTone?: EyebrowTone; - /** Accepts rich content so callers can embed `` accents. */ title: ReactNode; - /** Optional `id` for the heading, used to wire `aria-labelledby` on the parent section. */ titleId?: string; subtitle?: ReactNode; className?: string; diff --git a/frontend/components/landing/Ticker.tsx b/frontend/components/landing/Ticker.tsx index caa3b4e..95aeec6 100644 --- a/frontend/components/landing/Ticker.tsx +++ b/frontend/components/landing/Ticker.tsx @@ -1,13 +1,15 @@ import { TICKER_ITEMS } from './data'; +import { TickerIcon } from './icons'; -/** Infinite horizontal marquee of marketplace categories. Items duplicated for seamless loop. */ export function Ticker() { return (
{[...TICKER_ITEMS, ...TICKER_ITEMS].map((item, i) => ( - {item.emoji} + + + {item.label} ))} diff --git a/frontend/components/landing/Why.tsx b/frontend/components/landing/Why.tsx index 2702929..8741af6 100644 --- a/frontend/components/landing/Why.tsx +++ b/frontend/components/landing/Why.tsx @@ -1,28 +1,38 @@ import { SectionHead } from './SectionHead'; import { VALUE_POINTS, type ValuePoint } from './data'; +import { WhyValueIcon } from './icons'; +import { cx } from './cx'; +import { useRevealOnVisible } from './useRevealOnVisible'; export function Why() { + const { ref, visible } = useRevealOnVisible(); + return ( -
+
- {VALUE_POINTS.map((item, i) => ( - + {VALUE_POINTS.map((item) => ( + ))}
); } -function ValueCard({ item, delayMs }: { item: ValuePoint; delayMs: number }) { +function ValueCard({ item }: { item: ValuePoint }) { return ( -
+
- {item.icon} +

{item.title}

{item.body}

diff --git a/frontend/components/landing/cx.ts b/frontend/components/landing/cx.ts index c69ace0..41b43d4 100644 --- a/frontend/components/landing/cx.ts +++ b/frontend/components/landing/cx.ts @@ -1,4 +1,3 @@ -/** Join class name fragments, dropping falsy entries. */ export function cx(...parts: Array): string { return parts.filter(Boolean).join(' '); } diff --git a/frontend/components/landing/data.ts b/frontend/components/landing/data.ts index b0828f9..cbd15bc 100644 --- a/frontend/components/landing/data.ts +++ b/frontend/components/landing/data.ts @@ -1,19 +1,19 @@ import { APP_STORE_URL } from '../../constants/app'; import qrDownloadPng from '../../assets/images/polybuys-download-qr.png'; -/** Official App Store badge artwork (Apple Marketing Resources). */ export const APP_STORE_BADGE_URI = 'https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83'; export { APP_STORE_URL }; -/** Metro resolves PNG imports differently per environment. Normalise to a URL string. */ +type QrAsset = string | { uri?: string; default?: string }; +const qrAsset = qrDownloadPng as QrAsset; export const QR_SRC = - typeof qrDownloadPng === 'string' - ? qrDownloadPng - : ((qrDownloadPng as { uri?: string; default?: string }).uri ?? - (qrDownloadPng as unknown as { default: string }).default ?? - ''); + typeof qrAsset === 'string' ? qrAsset : (qrAsset.uri ?? qrAsset.default ?? ''); + +export type ListingThumbIconId = 'textbook' | 'furniture'; + +export type WhyIconId = 'students' | 'browse' | 'trust'; export type SampleListing = { title: string; @@ -21,12 +21,11 @@ export type SampleListing = { price: string; seller: string; location: string; - emoji: string; + thumbIcon: ListingThumbIconId; gradient: string; badge?: string; }; -/** Just the two cards rendered in the hero stage. */ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ { title: 'Calculus: Early Transcendentals, 8e', @@ -34,7 +33,7 @@ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ price: '$40', seller: 'Hazel', location: 'Poly Canyon', - emoji: '📚', + thumbIcon: 'textbook', gradient: 'linear-gradient(135deg, #1E5C44 0%, #154734 100%)', badge: 'Just listed', }, @@ -44,7 +43,7 @@ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ price: '$35', seller: 'Mateo', location: 'Mustang Village', - emoji: '🗄️', + thumbIcon: 'furniture', gradient: 'linear-gradient(135deg, #F3D38B 0%, #E2A84A 100%)', }, ]; @@ -52,40 +51,52 @@ export const HERO_LISTINGS: readonly [SampleListing, SampleListing] = [ export type ValuePoint = { title: string; body: string; - icon: string; + icon: WhyIconId; }; export const VALUE_POINTS: readonly ValuePoint[] = [ { title: 'Students only', body: 'Cal Poly email sign-in runs in the iOS app so listings stay in the campus community.', - icon: '🎓', + icon: 'students', }, { title: 'Built for how you actually buy', body: 'Textbooks, housing, furniture, and everyday gear — organized for quick browsing and search.', - icon: '🔍', + icon: 'browse', }, { title: 'Campus-first trust', body: 'Clear seller profiles and in-app messaging help you coordinate pickups without giving out extra contact info.', - icon: '🤝', + icon: 'trust', }, ]; -export type TickerItem = { emoji: string; label: string }; +export type TickerCategoryId = + | 'textbooks' + | 'bikes' + | 'furniture' + | 'subleases' + | 'backpacks' + | 'miniFridges' + | 'desks' + | 'parking' + | 'plants' + | 'instruments'; + +export type TickerItem = { id: TickerCategoryId; label: string }; export const TICKER_ITEMS: readonly TickerItem[] = [ - { emoji: '📚', label: 'Textbooks' }, - { emoji: '🚲', label: 'Bikes' }, - { emoji: '🛋️', label: 'Furniture' }, - { emoji: '🏠', label: 'Subleases' }, - { emoji: '🎒', label: 'Backpacks' }, - { emoji: '🧊', label: 'Mini fridges' }, - { emoji: '🖥️', label: 'Desks' }, - { emoji: '🚗', label: 'Parking passes' }, - { emoji: '🪴', label: 'Plants' }, - { emoji: '🎸', label: 'Instruments' }, + { id: 'textbooks', label: 'Textbooks' }, + { id: 'bikes', label: 'Bikes' }, + { id: 'furniture', label: 'Furniture' }, + { id: 'subleases', label: 'Subleases' }, + { id: 'backpacks', label: 'Backpacks' }, + { id: 'miniFridges', label: 'Mini fridges' }, + { id: 'desks', label: 'Desks' }, + { id: 'parking', label: 'Parking passes' }, + { id: 'plants', label: 'Plants' }, + { id: 'instruments', label: 'Instruments' }, ]; export type AvatarEntry = { initial: string; bg: string }; diff --git a/frontend/components/landing/helpers.ts b/frontend/components/landing/helpers.ts index 109928b..167857d 100644 --- a/frontend/components/landing/helpers.ts +++ b/frontend/components/landing/helpers.ts @@ -1,6 +1,5 @@ import { APP_STORE_URL } from './data'; -/** Open the user's mail client with a pre-filled download link. */ export function openDownloadLinkEmail(): void { const subject = encodeURIComponent('PolyBuys — download link'); const body = encodeURIComponent( diff --git a/frontend/components/landing/icons.tsx b/frontend/components/landing/icons.tsx new file mode 100644 index 0000000..eeebf22 --- /dev/null +++ b/frontend/components/landing/icons.tsx @@ -0,0 +1,186 @@ +import type { ReactNode } from 'react'; +import type { ListingThumbIconId, TickerCategoryId, WhyIconId } from './data'; +import { cx } from './cx'; + +const stroke = { + fill: 'none' as const, + stroke: 'currentColor', + strokeWidth: 1.5, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, +}; + +function Svg({ + size, + className, + children, + viewBox = '0 0 24 24', +}: { + size: number; + className?: string; + children: ReactNode; + viewBox?: string; +}) { + return ( + + {children} + + ); +} + +export function TickerIcon({ id, size = 18 }: { id: TickerCategoryId; size?: number }) { + switch (id) { + case 'textbooks': + return ( + + + + + + ); + case 'bikes': + return ( + + + + + + + ); + case 'furniture': + return ( + + + + + + ); + case 'subleases': + return ( + + + + + ); + case 'backpacks': + return ( + + + + + + ); + case 'miniFridges': + return ( + + + + + ); + case 'desks': + return ( + + + + + ); + case 'parking': + return ( + + + + + ); + case 'plants': + return ( + + + + + + ); + case 'instruments': + return ( + + + + + + ); + default: { + const _exhaustive: never = id; + return _exhaustive; + } + } +} + +export function WhyValueIcon({ id, size = 24 }: { id: WhyIconId; size?: number }) { + switch (id) { + case 'students': + return ( + + + + + + ); + case 'browse': + return ( + + + + + ); + case 'trust': + return ( + + + + ); + default: { + const _exhaustive: never = id; + return _exhaustive; + } + } +} + +export function ListingThumbIcon({ id, size = 32 }: { id: ListingThumbIconId; size?: number }) { + switch (id) { + case 'textbook': + return ( + + + + + + ); + case 'furniture': + return ( + + + + + ); + default: { + const _exhaustive: never = id; + return _exhaustive; + } + } +} diff --git a/frontend/components/landing/index.ts b/frontend/components/landing/index.ts index 033aa8d..e3ee698 100644 --- a/frontend/components/landing/index.ts +++ b/frontend/components/landing/index.ts @@ -7,3 +7,4 @@ export { Footer } from './Footer'; export { DownloadButton } from './DownloadButton'; export { GLOBAL_CSS } from './styles'; export { useScrolled } from './useScrolled'; +export { useRevealOnVisible } from './useRevealOnVisible'; diff --git a/frontend/components/landing/styles.ts b/frontend/components/landing/styles.ts index 0f2f757..ae87751 100644 --- a/frontend/components/landing/styles.ts +++ b/frontend/components/landing/styles.ts @@ -1,8 +1,3 @@ -/** - * Global CSS for the web landing page. Injected once via a ` - -
@@ -45,31 +48,31 @@ export function LegalDocumentPage({ document, siblingHref, siblingLabel }: Legal
-

{document.eyebrow}

-

{document.title}

-

Last updated {document.updatedAt}

+

{legalDocument.eyebrow}

+

{legalDocument.title}

+

Last updated {legalDocument.updatedAt}

- {document.intro.map((paragraph) => ( -

{paragraph}

+ {legalDocument.intro.map((paragraph, i) => ( +

{paragraph}

))}
- {document.sections.map((section) => ( + {legalDocument.sections.map((section) => (

{section.title}

- {section.paragraphs?.map((paragraph) => ( -

+ {section.paragraphs?.map((paragraph, i) => ( +

{paragraph}

))} {section.bullets?.length ? (
    - {section.bullets.map((bullet) => ( -
  • {bullet}
  • + {section.bullets.map((bullet, i) => ( +
  • {bullet}
  • ))}
) : null} diff --git a/frontend/components/landing/Nav.tsx b/frontend/components/landing/Nav.tsx index b9289f5..9b2507e 100644 --- a/frontend/components/landing/Nav.tsx +++ b/frontend/components/landing/Nav.tsx @@ -10,7 +10,7 @@ export function Nav({ scrolled }: NavProps) { return (