Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/client/DATABASE_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ The app currently uses **localStorage** (web) for data persistence. Events are l
EXPO_PUBLIC_API_URL=https://your-api-url.com
```

## Migrations

Run these in the Supabase SQL editor, in order:

1. `supabase/migrations/001_initial_schema.sql` — events, profiles, RLS
2. `supabase/migrations/002_handle_new_user.sql` — profile row on signup
3. `supabase/migrations/003_event_rsvps.sql` — per-user RSVPs + aggregate trigger
4. `supabase/migrations/004_event_messages.sql` — event chat and host
announcements, readable and writable only by people going (or the host)

## Testing Without Database

With no Supabase credentials the app runs in offline demo mode against
Expand Down
57 changes: 44 additions & 13 deletions apps/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ A comprehensive cross-platform event aggregation and discovery application built
- Capacity limits
- Real-time validation

- **My Events & ratings**
- Every event you RSVP'd to, pinned, or host, in one timeline
- Opens on past events so you can rate them 1–5 stars with an optional note
- "Not yet rated" filter, plus search across past events

- **Event chat & announcements**
- Per-event thread for the people going, gated to attendees and the host
- Hosts can post announcements, which pin above the conversation
- Backed by Supabase (`event_messages` + RLS) with a device-local fallback

- **Recommendations Feed**
- Personalized based on user interests
- Random selection from upcoming events
Expand Down Expand Up @@ -180,24 +190,45 @@ bundled mock events and keeps all changes in local state.

## 🎨 Design System

Tokens live in `constants/design.ts` and reach components through
`useAppTheme()`. Screens compose from the scale rather than inventing values —
that consistency is what makes unrelated screens read as one product.

### Colors

- **Primary**: `#FF6B6B` (Coral Red)
- **Secondary**: `#8B7FFF` (Purple)
- **Accent**: `#FF6BA8` (Pink)
- **Background**: `#F8F9FA` (Light Gray)
- **Text**: `#1F2937` (Dark Gray)
Semantic roles, not raw hex: `background`, `surface`, `surfaceAlt`, `border`,
`textPrimary/Secondary/Tertiary`, `primary`, `onPrimary`, plus status colours.
Every role is defined for light, dark, and both high-contrast variants in
`constants/theme.ts`. Emphasis comes from the three text roles, so no screen
needs a bespoke grey.

### Typography

- **Headers**: Bold, 24-32px
- **Body**: Regular, 14-16px
- **Small**: Regular, 12-14px

### Spacing

- Base unit: 8px
- Small: 8px, Medium: 16px, Large: 24px, XLarge: 32px
A fixed scale modelled on Apple's HIG text styles, each step carrying its own
weight, line height and tracking, multiplied by the user's font-size setting:

| Token | Size / line height | Weight | Used for |
| --- | --- | --- | --- |
| `display` | 34 / 40 | 800 | Landing hero |
| `title1` | 28 / 34 | 800 | Screen titles |
| `title2` | 22 / 28 | 700 | Section titles |
| `title3` | 20 / 26 | 700 | Card titles, empty states |
| `headline` | 17 / 23 | 700 | List item titles |
| `body` | 16 / 24 | 400 | Long-form text |
| `callout` | 15 / 21 | 400 | Supporting copy |
| `subhead` | 14 / 20 | 600 | Labels, buttons |
| `footnote` | 13 / 18 | 400 | Metadata |
| `caption` | 12 / 16 | 600 | Counts, timestamps |
| `overline` | 11 / 14 | 700, uppercase | Eyebrows, chips |

### Spacing, radius, elevation

- 8pt grid with 4pt half-steps: `xs 4, sm 8, md 12, lg 16, xl 24, xxl 32, xxxl 48`
- Radii: `sm 8, md 12, lg 16, xl 24, pill`
- Elevation: a three-step shadow ramp in light mode; dark mode returns flat
styles and separates surfaces with stepped backgrounds and hairlines, because
shadows read as dirt on dark backgrounds
- Minimum tap target: 44pt

## 📱 Responsive Breakpoints

Expand Down
33 changes: 26 additions & 7 deletions apps/client/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { useAppTheme } from '@/hooks/useAppTheme';
import { useAuth } from '@/contexts/AuthContext';
import { ActivityIndicator, View } from 'react-native';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Spacing } from '@/constants/design';
import { useResponsive } from '@/hooks/useResponsive';
import { DesktopNav } from '@/components/layout/DesktopNav';

export default function TabLayout() {
const { colors } = useAppTheme();
const { isAuthenticated, isLoading } = useAuth();
const { isDesktop } = useResponsive();
const insets = useSafeAreaInsets();

useEffect(() => {
if (!isLoading && !isAuthenticated) {
Expand Down Expand Up @@ -41,41 +44,57 @@ export default function TabLayout() {
tabBarInactiveTintColor: colors.textTertiary,
headerShown: false,
tabBarButton: HapticTab,
tabBarStyle: isDesktop ? { display: 'none' } : { backgroundColor: colors.surface },
tabBarStyle: isDesktop
? { display: 'none' }
: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
height: 64 + insets.bottom,
paddingTop: Spacing.sm,
paddingHorizontal: Spacing.sm,
paddingBottom: Math.max(insets.bottom, Spacing.sm),
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.1,
},
tabBarItemStyle: { paddingVertical: 0 },
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={26} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: 'Calendar',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="calendar" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={26} name="calendar" color={color} />,
}}
/>
<Tabs.Screen
name="find"
options={{
title: 'Find',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="magnifyingglass" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={26} name="magnifyingglass" color={color} />,
}}
/>
<Tabs.Screen
name="create"
options={{
title: 'Create',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="plus.circle.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={26} name="plus.circle.fill" color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="person.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={26} name="person.fill" color={color} />,
}}
/>
</Tabs>
Expand Down
38 changes: 30 additions & 8 deletions apps/client/app/(tabs)/find.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useEvents } from '@/contexts/EventsContext';
Expand All @@ -7,6 +7,7 @@ import { useSettings } from '@/contexts/SettingsContext';
import { FilterProvider, useFilters } from '@/contexts/FilterContext';
import { SearchBar } from '@/components/ui/SearchBar';
import { CategoryPill } from '@/components/ui/CategoryPill';
import { Ionicons } from '@expo/vector-icons';
import { EventCard } from '@/components/events/EventCard';
import { EventDetailSidebar } from '@/components/events/EventDetailSidebar';
import { FilterDrawer } from '@/components/layout/FilterDrawer';
Expand Down Expand Up @@ -49,10 +50,24 @@ function FindScreenContent() {
}, [params.filterMyEvents]);

// Filter for my events
const displayEvents = showMyEventsOnly
const visibleEvents = showMyEventsOnly
? filteredEvents.filter((event) => event.organizer.id === currentUser?.id)
: filteredEvents;

// Browsing leads with what you can still go to; events that already happened
// stay findable, just after the upcoming ones.
const displayEvents = useMemo(() => {
const now = Date.now();
const upcoming: Event[] = [];
const past: Event[] = [];
for (const event of visibleEvents) {
(new Date(event.endTime).getTime() >= now ? upcoming : past).push(event);
}
const byStart = (a: Event, b: Event) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
return [...upcoming.sort(byStart), ...past.sort((a, b) => byStart(b, a))];
}, [visibleEvents]);

const numColumns = isMobile ? 1 : viewMode === 'grid' ? 3 : 1;

return (
Expand All @@ -75,13 +90,13 @@ function FindScreenContent() {
style={[styles.viewButton, viewMode === 'grid' && styles.viewButtonActive]}
onPress={() => setViewMode('grid')}
>
<Text style={styles.viewIcon}>▦</Text>
<Ionicons name="grid-outline" size={16} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.viewButton, viewMode === 'list' && styles.viewButtonActive]}
onPress={() => setViewMode('list')}
>
<Text style={styles.viewIcon}>☰</Text>
<Ionicons name="list-outline" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
)}
Expand All @@ -93,7 +108,7 @@ function FindScreenContent() {
style={styles.filterButton}
onPress={() => setShowFilters(true)}
>
<Text style={styles.filterIcon}>⚙</Text>
<Ionicons name="options-outline" size={16} color={colors.textSecondary} />
<Text style={styles.filterButtonText}>Filters</Text>
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
Expand Down Expand Up @@ -152,7 +167,9 @@ function FindScreenContent() {
{/* Events List */}
{displayEvents.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>🔍</Text>
<View style={styles.emptyIconWrap}>
<Ionicons name="search-outline" size={26} color={colors.textTertiary} />
</View>
<Text style={styles.emptyTitle}>No events found</Text>
<Text style={styles.emptyText}>
Try adjusting your filters or search query
Expand Down Expand Up @@ -338,8 +355,13 @@ const createStyles = (colors: AppPalette, fontScale: number) =>
alignItems: 'center',
padding: 32,
},
emptyIcon: {
fontSize: 64,
emptyIconWrap: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: colors.surfaceAlt,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyTitle: {
Expand Down
9 changes: 6 additions & 3 deletions apps/client/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DevModeProvider } from '@/contexts/DevModeContext';
import { GoogleAuthProvider } from '@/contexts/GoogleAuthContext';
import { GoogleCalendarProvider } from '@/contexts/GoogleCalendarContext';
import { EventsProvider } from '@/contexts/EventsContext';
import { RatingsProvider } from '@/contexts/RatingsContext';
import { SettingsProvider, useSettings } from '@/contexts/SettingsContext';
import { SlackProvider } from '@/contexts/SlackContext';

Expand Down Expand Up @@ -48,9 +49,11 @@ export default function RootLayout() {
<GoogleCalendarProvider>
<SettingsProvider>
<EventsProvider>
<SlackProvider>
<ThemedStack />
</SlackProvider>
<RatingsProvider>
<SlackProvider>
<ThemedStack />
</SlackProvider>
</RatingsProvider>
</EventsProvider>
</SettingsProvider>
</GoogleCalendarProvider>
Expand Down
45 changes: 39 additions & 6 deletions apps/client/app/event/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { Button } from '@/components/ui/Button';
import { Event, RSVPStatus } from '@/types/event';
import { fetchEventAPI } from '@/lib/api';
import { formatDate, formatFullDate, formatTimeRange } from '@/utils/dateHelpers';
import { getAvailableSpots } from '@/utils/eventHelpers';
import { EventThread } from '@/components/events/EventThread';
import { RateEventRow } from '@/components/events/RateEventRow';
import { useRatings } from '@/contexts/RatingsContext';
import { googleCalendarUrl, downloadIcs, shareEvent } from '@/utils/calendarLinks';
import { useAppTheme } from '@/hooks/useAppTheme';
import { AppPalette } from '@/constants/theme';
Expand Down Expand Up @@ -67,6 +71,7 @@ function InfoRow({
export default function EventDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { getEventById, updateRSVP, getRSVPStatus } = useEvents();
const { ratingFor, rateEvent } = useRatings();
const { currentUser } = useAuth();
const { colors, fontScale, reduceMotion } = useAppTheme();
const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]);
Expand Down Expand Up @@ -203,9 +208,8 @@ export default function EventDetailScreen() {
};

const recurrence = recurrenceLabel(event);
const spotsLeft = event.capacity
? Math.max(event.capacity - (event.rsvpCounts.going + event.rsvpCounts.maybe), 0)
: null;
const spotsLeft = getAvailableSpots(event);
const hasEnded = new Date(event.endTime).getTime() < Date.now();

const barAnimStyle = {
opacity: barAnim,
Expand Down Expand Up @@ -281,9 +285,11 @@ export default function EventDetailScreen() {
icon="people-outline"
primary={`${event.rsvpCounts.going} going · ${event.rsvpCounts.maybe} maybe`}
secondary={
spotsLeft !== null
? `${spotsLeft} of ${event.capacity} spots left`
: undefined
spotsLeft === null
? undefined
: spotsLeft === 0
? `Full · ${event.capacity} spots`
: `${spotsLeft} of ${event.capacity} spots left`
}
styles={styles}
colors={colors}
Expand Down Expand Up @@ -345,6 +351,24 @@ export default function EventDetailScreen() {
</View>
</>
)}

{/* Rate it, once it's over */}
{hasEnded && currentUser ? (
<View style={styles.rateSection}>
<Text style={styles.sectionTitle}>Rate this event</Text>
<View style={styles.rateCard}>
<RateEventRow
eventTitle={event.title}
rating={ratingFor(event.id)}
onRate={(stars, note) => rateEvent(event.id, stars, note)}
onClear={() => rateEvent(event.id, null)}
/>
</View>
</View>
) : null}

{/* Attendee chat + host announcements */}
<EventThread event={event} rsvpStatus={userRSVP} />
</View>
</View>
</ScrollView>
Expand Down Expand Up @@ -626,6 +650,15 @@ const createStyles = (colors: AppPalette, fontScale: number) =>
marginTop: 22,
marginBottom: 8,
},
rateSection: {
marginBottom: 4,
},
rateCard: {
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: 16,
marginBottom: 8,
},
description: {
fontSize: 15 * fontScale,
lineHeight: 22,
Expand Down
Loading
Loading