diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fae8e3d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/App.tsx b/App.tsx index 5f153c8..83ece26 100644 --- a/App.tsx +++ b/App.tsx @@ -1,209 +1,19 @@ -import React, { useState, useCallback, useRef } from 'react'; -import { View, StyleSheet, StatusBar, Platform } from 'react-native'; -import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; - -import AppHeader from './src/components/AppHeader'; -import LoadingOverlay from './src/components/LoadingOverlay'; -import BottomNav from './src/components/BottomNav'; - -import SplashScreen from './src/screens/SplashScreen'; -import AuthScreen from './src/screens/AuthScreen'; -import HomeScreen from './src/screens/HomeScreen'; -import CalendarScreen from './src/screens/CalendarScreen'; -import ChatsScreen from './src/screens/ChatsScreen'; -import ChatDetailScreen from './src/screens/ChatDetailScreen'; -import GlobalChatScreen from './src/screens/GlobalChatScreen'; -import EventDetailScreen from './src/screens/EventDetailScreen'; -import ProfileScreen from './src/screens/ProfileScreen'; -import SettingsScreen from './src/screens/SettingsScreen'; -import MyEventsScreen from './src/screens/MyEventsScreen'; +import { ThemeProvider } from './src/contexts/ThemeContext'; +import AppNavigator from './src/navigation/AppNavigator'; const queryClient = new QueryClient(); -type Tab = 'Home' | 'Calendar' | 'Chats' | 'Profile'; - -type Screen = - | 'main' - | 'eventDetail' - | 'chatDetail' - | 'globalChat' - | 'settings' - | 'myEvents'; - -const AppContent = () => { - const { colors, theme } = useTheme(); - const [showSplash, setShowSplash] = useState(true); - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [activeTab, setActiveTab] = useState('Home'); - const [currentScreen, setCurrentScreen] = useState('main'); - const [currentEventId, setCurrentEventId] = useState(null); - const [currentChatId, setCurrentChatId] = useState(null); - const [loading, setLoading] = useState(false); - const loadingTimer = useRef | null>(null); - - const navigate = useCallback((fn: () => void) => { - setLoading(true); - if (loadingTimer.current) clearTimeout(loadingTimer.current); - loadingTimer.current = setTimeout(() => { - fn(); - setLoading(false); - }, 400); - }, []); - - if (showSplash) { - return ( - <> - - setShowSplash(false)} /> - - ); - } - - if (!isLoggedIn) { - return ( - <> - - - setIsLoggedIn(true)} /> - - - ); - } - - const showHeader = - currentScreen === 'main' || currentScreen === 'eventDetail'; - const showNav = currentScreen === 'main'; - - const renderScreen = () => { - switch (currentScreen) { - case 'eventDetail': - return ( - setCurrentScreen('main')} - /> - ); - case 'chatDetail': - return ( - setCurrentScreen('main')} - /> - ); - case 'globalChat': - return ( - setCurrentScreen('main')} /> - ); - case 'settings': - return ( - setCurrentScreen('main')} /> - ); - case 'myEvents': - return ( - setCurrentScreen('main')} - onEventPress={(id) => { - setCurrentEventId(id); - setCurrentScreen('eventDetail'); - }} - /> - ); - case 'main': - default: - switch (activeTab) { - case 'Home': - return ( - { - setCurrentEventId(id); - setCurrentScreen('eventDetail'); - }} - /> - ); - case 'Calendar': - return ( - { - setCurrentEventId(id); - setCurrentScreen('eventDetail'); - }} - /> - ); - case 'Chats': - return ( - { - setCurrentChatId(id); - setCurrentScreen('chatDetail'); - }} - onGlobalChatPress={() => setCurrentScreen('globalChat')} - /> - ); - case 'Profile': - return ( - { - setCurrentScreen('myEvents'); - }} - onSettings={() => { - setCurrentScreen('settings'); - }} - onLogout={() => { - setIsLoggedIn(false); - setActiveTab('Home'); - setCurrentScreen('main'); - }} - /> - ); - default: - return null; - } - } - }; - - return ( - <> - - - {showHeader && } - {renderScreen()} - {showNav && ( - - { - setActiveTab(tab); - setCurrentScreen('main'); - }} - /> - - )} - - - ); -}; - export default function App() { return ( - + ); } - -const styles = StyleSheet.create({}); diff --git a/PR_REVIEW_TR.md b/PR_REVIEW_TR.md new file mode 100644 index 0000000..4c993f1 --- /dev/null +++ b/PR_REVIEW_TR.md @@ -0,0 +1,285 @@ +# Review + +## Yapılan iş + +Bu PR ile projedeki manuel ekran geçişi yapısı kaldırılarak gerçek bir navigation mimarisine geçildi. Buna ek olarak: + +- Event detail ekranına kalan süre göstergesi eklendi +- Profile alanına FAQ ekranı eklendi +- Bu yeni yapılar gerçek API verisine geçiş kolay olacak şekilde düzenlendi + +## Navigator Yapısı Ne İşe Yarıyor + +Önceden ekran geçişleri `App.tsx` içinde `useState` ile yönetiliyordu. Bu yaklaşım küçük yapılarda çalışsa da proje büyüdükçe: + +- geri gitme davranışı zorlaşır +- ekranlar arası parametre taşıma dağılır +- aynı detay ekranını birden fazla yerden açmak karmaşıklaşır +- yeni ekran eklemek zorlaşır + +Bu PR ile navigation aşağıdaki şekilde düzenlendi: + +- `src/navigation/AppNavigator.tsx` +- `src/navigation/MainTabNavigator.tsx` +- `src/navigation/ProfileStackNavigator.tsx` +- `src/navigation/types.ts` + +### Root navigator + +`AppNavigator.tsx` uygulamanın ana girişini yönetir. + +Örnek: + +```tsx + + {() => setIsLoggedIn(false)} />} + + + + +``` + +Burada: + +- giriş yapılmamışsa auth akışı gösterilir +- giriş yapılmışsa tab yapısı açılır +- tab içinden açılan ortak detay ekranları root seviyesinde tutulur + +Bu bizim proje için doğru çünkü: + +- `EventDetail` hem Home hem Calendar hem My Events içinden açılıyor +- `ChatDetail` Chats alanından açılıyor +- `GlobalChat` ayrı bir ortak ekran + +### Bottom tab navigator + +`MainTabNavigator.tsx` içinde ana sekmeler tanımlanır: + +```tsx + + + + + {() => } + +``` + +Bu yapı sayesinde: + +- Home +- Calendar +- Chats +- Profile + +alanları alt tab olarak düzgün ayrılmış oldu. + +Örnek olarak `Home` tab içinde event detaya geçiş şu şekilde yapılıyor: + +```tsx + navigation.navigate('EventDetail', { eventId })} +/> +``` + +Yani artık ekran geçişi state değiştirerek değil, gerçek navigation ile yapılıyor. + +### Profile stack + +`ProfileStackNavigator.tsx` profile altındaki ekranları yönetiyor: + +```tsx + + {(props) => } + + + + +``` + +Bu yapı sayesinde profile altında yeni ekran eklemek çok kolay hale geldi. + +Örnek: + +- `My Events` +- `App Settings` +- `FAQ` + +gibi alanlar artık tek ekranda modal benzeri yönetilmek yerine gerçek stack ekranı olarak çalışıyor. + +## Event Altındaki Remaining Bar Nasıl Çalışıyor + +Bu PR ile event detail ekranına kalan süre bilgisi eklendi. + +İlgili dosyalar: + +- `src/screens/EventDetailScreen.tsx` +- `src/hooks/useEventCountdown.ts` +- `src/utils/eventDate.ts` +- `src/data/events.ts` + +### Şu an nasıl çalışıyor + +Şu anda event verisi mock data üzerinden geliyor. Event objesi içinde mevcut olarak: + +- `date` +- `time` + +alanları var. + +Ayrıca gerçek veriye hazırlık için opsiyonel `startsAt` alanı eklendi: + +```ts +export interface Event { + id: string; + title: string; + startsAt?: string; + date: string; + time: string; +} +``` + +Countdown mantığı şu: + +1. Önce event başlangıç tarihi çözülür +2. Eğer `startsAt` varsa doğrudan onu kullanır +3. Yoksa mevcut mock verideki `date + time` alanlarını parse eder +4. Her saniye kalan süre tekrar hesaplanır + +Tarih çözümleme örneği: + +```ts +if (event.startsAt) { + const isoDate = new Date(event.startsAt); + if (isValid(isoDate)) return isoDate; +} + +return parse(`${event.date} ${event.time}`, EVENT_DATE_TIME_FORMAT, new Date()); +``` + +Event detail ekrana bağlanışı: + +```tsx +const { label: countdownLabel, isPast } = useEventCountdown(event); +``` + +UI tarafında: + +- event gelecekteyse örnek çıktı: `2d 4h 10m` +- saat bazında yaklaştıysa örnek çıktı: `4h 17m 53s` +- event başladıysa: `Event started` + +### Gerçek data gelince nasıl değişecek + +Bu yapı özellikle kolay geçiş için yazıldı. + +Yarın gerçek API geldiğinde ideal veri şöyle olabilir: + +```ts +{ + id: '1', + title: 'Coffee & Connect at Kibe Mahala', + startsAt: '2026-03-25T18:00:00+01:00' +} +``` + +Bu durumda ekran tarafında hiçbir şey değişmeyecek. + +Sadece API mapper içinde gelen alan app formatına dönüştürülecek: + +```ts +const mappedEvent = { + id: apiEvent.id, + title: apiEvent.title, + startsAt: apiEvent.startsAt, + date: formatDate(apiEvent.startsAt), + time: formatTime(apiEvent.startsAt), + location: apiEvent.location, +}; +``` + +Yani: + +- countdown hook değişmeyecek +- UI değişmeyecek +- sadece veri mapleme değişecek + +Bu da mock datadan gerçek dataya geçişi çok kolaylaştırıyor. + +## FAQ Kısmı Nasıl Yapıldı + +İlgili dosyalar: + +- `src/screens/FAQScreen.tsx` +- `src/data/faqs.ts` +- `src/screens/ProfileScreen.tsx` +- `src/navigation/ProfileStackNavigator.tsx` + +Profile içine yeni bir `FAQ` menü maddesi eklendi ve ayrı bir ekran açacak şekilde stack’e bağlandı. + +Örnek: + +```tsx +{ + icon: 'help-circle-outline' as const, + label: 'FAQ', + onPress: onFAQ, + danger: false, +} +``` + +Ve stack tarafında: + +```tsx + +``` + +### Şu an nasıl çalışıyor + +`https://sarajevoexpats.com/qaas` sayfası kontrol edildi. Şu an yayında soru listesi görünmüyor ve sayfada `No questions and answers found.` bilgisi yer alıyor. + +Bu nedenle ekran şu an: + +- FAQ kaynağını gösteriyor +- source page linki sunuyor +- henüz soru olmadığı için boş-state gösteriyor + +Veri ayrı dosyada tutuluyor: + +```ts +export const faqs: FaqItem[] = []; +``` + +Bu bilinçli yapıldı; çünkü gerçek data geldiğinde UI’yi değil yalnızca veri kaynağını değiştirmek istiyoruz. + +### Gerçek data gelince nasıl değişecek + +Gerçek FAQ endpoint’i geldiğinde iki seçenek var: + +1. `src/data/faqs.ts` yerine servis katmanından veri çekmek +2. gelen veriyi `FaqItem[]` formatına mapleyip aynı ekranı kullanmak + +Örnek: + +```ts +const mappedFaqs = apiFaqs.map((item) => ({ + id: item.id, + question: item.question, + answer: item.answer, +})); +``` + +Sonrasında `FAQScreen` aynı kalır; sadece statik `faqs` yerine servis verisi kullanır. + +Bu da yine mocktan gerçeğe geçişte ekranı bozmadan ilerlememizi sağlar. + +## Sonuç + +Bu PR ile: + +- manuel navigation yerine gerçek ve sürdürülebilir navigation mimarisi kuruldu +- event detayına gerçek veriye hazır countdown yapısı eklendi +- profile alanına gerçek FAQ verisine hazır FAQ ekranı eklendi + +En önemli nokta şu: + +Bu değişiklikler sadece bugünü çözmüyor; yarın Sarajevo Expats token ve gerçek verileri geldiğinde minimum kod değişikliğiyle entegrasyon yapabilmemizi sağlıyor. diff --git a/index.js b/index.js index 5fd059f..8cc849c 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ +import 'react-native-gesture-handler'; import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/package-lock.json b/package-lock.json index 86cfd09..cd69e33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "@expo/metro-runtime": "~5.0.5", "@expo/vector-icons": "^14.0.4", "@react-native-async-storage/async-storage": "2.1.2", + "@react-navigation/bottom-tabs": "^7.15.6", + "@react-navigation/native": "^7.1.34", + "@react-navigation/native-stack": "^7.14.6", "@tanstack/react-query": "^5.0.0", "axios": "^1.13.6", "date-fns": "^3.6.0", @@ -2628,6 +2631,117 @@ } } }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.15.6", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.6.tgz", + "integrity": "sha512-olB+s0ApMzWN9t5Bk5Mj6ntSlVRz3B8v+1LtwGS/29lyC311G5es0kgxyzpGKE9gy6Ef8W526QH5cIka2jh0kQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.11", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.34", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.16.2", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.16.2.tgz", + "integrity": "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/core/node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.11.tgz", + "integrity": "sha512-O5KiwaVCcEVuqZgQ77xiBFSl1sha77rNMTFlLWYnom33ZHPDarV3bM9WNyVnMZxU8ZVTi02X3+ZhO0fSn5QYyg==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.1.34", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.34.tgz", + "integrity": "sha512-zzQ0mKAhLsjTIsaoLfILKZVMObJzE0F+bOi0hl2Glt+1Rd2GtaWJ1Z024c3yLmX+Oc79pqoCQLBXpyxtrZu9NQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.16.2", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.6.tgz", + "integrity": "sha512-VRlC5mLanRPHK0E15Cild6U01Z5TDPBlmt5YcXRBc+hQTAMbMT9XcSTobf3sJXNY0zzDD1IpSs3Ynex/GU225g==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.11", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.34", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -3630,6 +3744,19 @@ "node": ">=0.8" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3648,6 +3775,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3890,6 +4027,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4373,6 +4519,12 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4430,6 +4582,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -6877,6 +7038,24 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -7648,6 +7827,15 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7716,6 +7904,21 @@ "node": ">= 5.10.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7805,6 +8008,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7868,6 +8080,15 @@ "node": ">= 0.10.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8408,6 +8629,24 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 8448822..5968a9b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "@expo/metro-runtime": "~5.0.5", "@expo/vector-icons": "^14.0.4", "@react-native-async-storage/async-storage": "2.1.2", + "@react-navigation/bottom-tabs": "^7.15.6", + "@react-navigation/native": "^7.1.34", + "@react-navigation/native-stack": "^7.14.6", "@tanstack/react-query": "^5.0.0", "axios": "^1.13.6", "date-fns": "^3.6.0", diff --git a/src/data/events.ts b/src/data/events.ts index 3b322ae..7757e1d 100644 --- a/src/data/events.ts +++ b/src/data/events.ts @@ -1,6 +1,8 @@ export interface Event { id: string; title: string; + // Prefer this field once the real API is connected. + startsAt?: string; date: string; time: string; location: string; diff --git a/src/data/faqs.ts b/src/data/faqs.ts new file mode 100644 index 0000000..43838f1 --- /dev/null +++ b/src/data/faqs.ts @@ -0,0 +1,12 @@ +export interface FaqItem { + id: string; + question: string; + answer: string; +} + +export const FAQ_SOURCE_URL = 'https://sarajevoexpats.com/qaas'; + +// The current Sarajevo Expats Q&A page has no published questions yet. +// Keep this shape aligned with the future API response so we can swap the +// source without changing the screen component. +export const faqs: FaqItem[] = []; diff --git a/src/hooks/useEventCountdown.ts b/src/hooks/useEventCountdown.ts new file mode 100644 index 0000000..90d4386 --- /dev/null +++ b/src/hooks/useEventCountdown.ts @@ -0,0 +1,23 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { formatCountdownLabel, getEventStartDate } from '../utils/eventDate'; +import type { Event } from '../data/events'; + +export function useEventCountdown(event: Pick) { + const targetDate = useMemo(() => getEventStartDate(event), [event]); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const interval = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(interval); + }, []); + + return useMemo( + () => ({ + targetDate, + label: formatCountdownLabel(targetDate, now), + isPast: targetDate.getTime() <= now.getTime(), + }), + [now, targetDate] + ); +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx new file mode 100644 index 0000000..dfbba2b --- /dev/null +++ b/src/navigation/AppNavigator.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { StatusBar } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import { useTheme } from '../contexts/ThemeContext'; +import AuthScreen from '../screens/AuthScreen'; +import ChatDetailScreen from '../screens/ChatDetailScreen'; +import EventDetailScreen from '../screens/EventDetailScreen'; +import GlobalChatScreen from '../screens/GlobalChatScreen'; +import SplashScreen from '../screens/SplashScreen'; +import MainTabNavigator from './MainTabNavigator'; +import RootScreenLayout from './RootScreenLayout'; +import { RootNavProps, RootStackParamList } from './types'; + +const RootStack = createNativeStackNavigator(); + +const AuthRoute = ({ onLogin }: { onLogin: () => void }) => { + const { colors } = useTheme(); + + return ( + <> + + + + + + ); +}; + +const EventDetailRoute = ({ route, navigation }: RootNavProps<'EventDetail'>) => ( + + navigation.goBack()} + /> + +); + +const ChatDetailRoute = ({ route, navigation }: RootNavProps<'ChatDetail'>) => ( + + navigation.goBack()} + /> + +); + +const GlobalChatRoute = ({ navigation }: RootNavProps<'GlobalChat'>) => ( + + navigation.goBack()} /> + +); + +export default function AppNavigator() { + const { colors } = useTheme(); + const [showSplash, setShowSplash] = useState(true); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + if (showSplash) { + return ( + <> + + setShowSplash(false)} /> + + ); + } + + return ( + + + {isLoggedIn ? ( + <> + + {() => setIsLoggedIn(false)} />} + + + + + + ) : ( + + {() => setIsLoggedIn(true)} />} + + )} + + + ); +} diff --git a/src/navigation/MainTabNavigator.tsx b/src/navigation/MainTabNavigator.tsx new file mode 100644 index 0000000..0b9c56a --- /dev/null +++ b/src/navigation/MainTabNavigator.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { + BottomTabBarProps, + createBottomTabNavigator, +} from '@react-navigation/bottom-tabs'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import BottomNav from '../components/BottomNav'; +import { useTheme } from '../contexts/ThemeContext'; +import CalendarScreen from '../screens/CalendarScreen'; +import ChatsScreen from '../screens/ChatsScreen'; +import HomeScreen from '../screens/HomeScreen'; +import ProfileStackNavigator from './ProfileStackNavigator'; +import RootScreenLayout from './RootScreenLayout'; +import { MainTabParamList, RootStackParamList } from './types'; + +const Tab = createBottomTabNavigator(); + +const HomeTabRoute = () => { + const navigation = useNavigation>(); + + return ( + + navigation.navigate('EventDetail', { eventId })} + /> + + ); +}; + +const CalendarTabRoute = () => { + const navigation = useNavigation>(); + + return ( + + navigation.navigate('EventDetail', { eventId })} + /> + + ); +}; + +const ChatsTabRoute = () => { + const navigation = useNavigation>(); + + return ( + + navigation.navigate('ChatDetail', { chatId })} + onGlobalChatPress={() => navigation.navigate('GlobalChat')} + /> + + ); +}; + +const MainTabBar = ({ state, navigation }: BottomTabBarProps) => { + const { colors } = useTheme(); + + return ( + + navigation.navigate(tab)} + /> + + ); +}; + +export default function MainTabNavigator({ + onLogout, +}: { + onLogout: () => void; +}) { + return ( + } + > + + + + + {() => } + + + ); +} diff --git a/src/navigation/ProfileStackNavigator.tsx b/src/navigation/ProfileStackNavigator.tsx new file mode 100644 index 0000000..6b93dab --- /dev/null +++ b/src/navigation/ProfileStackNavigator.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { NavigationProp } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import MyEventsScreen from '../screens/MyEventsScreen'; +import ProfileScreen from '../screens/ProfileScreen'; +import SettingsScreen from '../screens/SettingsScreen'; +import FAQScreen from '../screens/FAQScreen'; +import RootScreenLayout from './RootScreenLayout'; +import { + ProfileNavProps, + ProfileStackParamList, + RootStackParamList, +} from './types'; + +const ProfileStack = createNativeStackNavigator(); + +const ProfileMainRoute = ({ + navigation, + onLogout, +}: ProfileNavProps<'ProfileMain'> & { onLogout: () => void }) => ( + + navigation.navigate('MyEvents')} + onSettings={() => navigation.navigate('Settings')} + onFAQ={() => navigation.navigate('FAQ')} + onLogout={onLogout} + /> + +); + +const MyEventsRoute = ({ navigation }: ProfileNavProps<'MyEvents'>) => { + const rootNavigation = + navigation.getParent>(); + + return ( + + navigation.goBack()} + onEventPress={(eventId) => + rootNavigation?.navigate('EventDetail', { eventId }) + } + /> + + ); +}; + +const SettingsRoute = ({ navigation }: ProfileNavProps<'Settings'>) => ( + + navigation.goBack()} /> + +); + +const FAQRoute = ({ navigation }: ProfileNavProps<'FAQ'>) => ( + + navigation.goBack()} /> + +); + +export default function ProfileStackNavigator({ + onLogout, +}: { + onLogout: () => void; +}) { + return ( + + + {(props) => } + + + + + + ); +} diff --git a/src/navigation/RootScreenLayout.tsx b/src/navigation/RootScreenLayout.tsx new file mode 100644 index 0000000..aa2250e --- /dev/null +++ b/src/navigation/RootScreenLayout.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import AppHeader from '../components/AppHeader'; +import { useTheme } from '../contexts/ThemeContext'; + +interface RootScreenLayoutProps { + children: React.ReactNode; + showHeader?: boolean; +} + +export default function RootScreenLayout({ + children, + showHeader = false, +}: RootScreenLayoutProps) { + const { colors } = useTheme(); + + return ( + + {showHeader && } + {children} + + ); +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts new file mode 100644 index 0000000..0db7915 --- /dev/null +++ b/src/navigation/types.ts @@ -0,0 +1,30 @@ +import { NavigatorScreenParams } from '@react-navigation/native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + +export type RootStackParamList = { + Auth: undefined; + MainTabs: undefined; + EventDetail: { eventId: string }; + ChatDetail: { chatId: string }; + GlobalChat: undefined; +}; + +export type ProfileStackParamList = { + ProfileMain: undefined; + MyEvents: undefined; + Settings: undefined; + FAQ: undefined; +}; + +export type MainTabParamList = { + Home: undefined; + Calendar: undefined; + Chats: undefined; + Profile: NavigatorScreenParams | undefined; +}; + +export type RootNavProps = + NativeStackScreenProps; + +export type ProfileNavProps = + NativeStackScreenProps; diff --git a/src/screens/EventDetailScreen.tsx b/src/screens/EventDetailScreen.tsx index 609d917..8ee8958 100644 --- a/src/screens/EventDetailScreen.tsx +++ b/src/screens/EventDetailScreen.tsx @@ -15,6 +15,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { events } from '../data/events'; +import { useEventCountdown } from '../hooks/useEventCountdown'; const categoryColors: Record = { Social: '#f97316', @@ -71,6 +72,7 @@ const EventDetailScreen = ({ eventId, onBack }: EventDetailScreenProps) => { const isFull = event.filled >= event.capacity; const attendees = mockAttendees.slice(0, event.filled); const catColor = categoryColors[event.category] ?? colors.primary; + const { label: countdownLabel, isPast } = useEventCountdown(event); const perPerson = totalAmount && Number(totalAmount) > 0 @@ -181,6 +183,29 @@ const EventDetailScreen = ({ eventId, onBack }: EventDetailScreenProps) => { {/* Bottom Join Button */} + + + + {countdownLabel} + + void; +} + +export default function FAQScreen({ onBack }: FAQScreenProps) { + const { colors } = useTheme(); + const [openId, setOpenId] = useState(null); + + return ( + + + + + + FAQ + + + + + + + + + + + + Sarajevo Expats Q&A + + + This section is wired to mirror the website FAQ structure once live data is available. + + + + + Linking.openURL(FAQ_SOURCE_URL)} + > + + Open source page + + + + + + {faqs.length === 0 ? ( + + + + + + No published questions yet + + + The current Sarajevo Expats Q&A page does not list any questions yet. As soon as the real API is connected, this screen can render them without changing the UI structure. + + + ) : ( + faqs.map((item) => { + const isOpen = openId === item.id; + + return ( + + setOpenId(isOpen ? null : item.id)} + activeOpacity={0.8} + > + + {item.question} + + + + + {isOpen && ( + + {item.answer} + + )} + + ); + }) + )} + + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: 1, + }, + backBtn: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { flex: 1, textAlign: 'center', fontSize: 17, fontWeight: '700' }, + sourceCard: { + borderWidth: 1, + borderRadius: 18, + padding: 16, + gap: 14, + }, + sourceTop: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + }, + sourceIcon: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + sourceTitle: { + fontSize: 16, + fontWeight: '700', + }, + sourceDesc: { + fontSize: 13, + lineHeight: 19, + marginTop: 4, + }, + sourceLink: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + }, + sourceLinkText: { + fontSize: 13, + fontWeight: '600', + }, + emptyCard: { + borderWidth: 1, + borderRadius: 18, + padding: 24, + alignItems: 'center', + }, + emptyIcon: { + width: 54, + height: 54, + borderRadius: 27, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 14, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 8, + }, + emptyDesc: { + textAlign: 'center', + fontSize: 14, + lineHeight: 22, + }, + faqCard: { + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + }, + faqTrigger: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + faqQuestion: { + flex: 1, + fontSize: 15, + fontWeight: '600', + lineHeight: 22, + }, + faqAnswer: { + marginTop: 10, + fontSize: 14, + lineHeight: 22, + }, +}); diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 9016e20..935d136 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -16,10 +16,11 @@ const allInterests = ['Sports', 'Culture', 'Food & Drink', 'Tech', 'Networking', interface ProfileScreenProps { onMyEvents: () => void; onSettings: () => void; + onFAQ: () => void; onLogout: () => void; } -const ProfileScreen = ({ onMyEvents, onSettings, onLogout }: ProfileScreenProps) => { +const ProfileScreen = ({ onMyEvents, onSettings, onFAQ, onLogout }: ProfileScreenProps) => { const { colors } = useTheme(); const [showInterests, setShowInterests] = useState(false); const [selectedInterests, setSelectedInterests] = useState(['Sports', 'Food & Drink']); @@ -44,6 +45,12 @@ const ProfileScreen = ({ onMyEvents, onSettings, onLogout }: ProfileScreenProps) onPress: onSettings, danger: false, }, + { + icon: 'help-circle-outline' as const, + label: 'FAQ', + onPress: onFAQ, + danger: false, + }, ]; return ( diff --git a/src/services/socketService.ts b/src/services/socketService.ts new file mode 100644 index 0000000..c64d5af --- /dev/null +++ b/src/services/socketService.ts @@ -0,0 +1,26 @@ +import { io, Socket } from 'socket.io-client'; + +const SOCKET_URL = process.env.EXPO_PUBLIC_SOCKET_URL ?? 'http://localhost:3030'; + +class SocketService { + private socket: Socket | null = null; + + connect(): Socket { + if (!this.socket) { + this.socket = io(SOCKET_URL, { + transports: ['websocket'], + }); + } + + return this.socket; + } + + disconnect(): void { + if (this.socket) { + this.socket.disconnect(); + this.socket = null; + } + } +} + +export const socketService = new SocketService(); diff --git a/src/utils/eventDate.ts b/src/utils/eventDate.ts new file mode 100644 index 0000000..9420263 --- /dev/null +++ b/src/utils/eventDate.ts @@ -0,0 +1,38 @@ +import { parse, isValid } from 'date-fns'; + +import type { Event } from '../data/events'; + +const EVENT_DATE_TIME_FORMAT = 'MMM d, yyyy h:mm a'; + +export function getEventStartDate(event: Pick) { + if (event.startsAt) { + const isoDate = new Date(event.startsAt); + if (isValid(isoDate)) return isoDate; + } + + return parse(`${event.date} ${event.time}`, EVENT_DATE_TIME_FORMAT, new Date()); +} + +export function getCountdownParts(targetDate: Date, now = new Date()) { + const diffMs = targetDate.getTime() - now.getTime(); + const isPast = diffMs <= 0; + const safeDiffMs = Math.max(diffMs, 0); + + const totalSeconds = Math.floor(safeDiffMs / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return { isPast, days, hours, minutes, seconds }; +} + +export function formatCountdownLabel(targetDate: Date, now = new Date()) { + const { isPast, days, hours, minutes, seconds } = getCountdownParts(targetDate, now); + + if (isPast) return 'Event started'; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} diff --git a/tsconfig.json b/tsconfig.json index 2b28227..e3be37e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { - "extends": "expo/tsconfig.base", + "extends": "expo/tsconfig.base.json", "compilerOptions": { "strict": true, + "baseUrl": ".", "paths": { "@/*": [ "./src/*" @@ -12,5 +13,9 @@ "**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts" + ], + "exclude": [ + "backend", + "node_modules" ] }