Compare commits

..

20 Commits

Author SHA1 Message Date
Yaël Perret
3bc1149f05 feat : début de EventCard 2026-09-10 20:56:51 +02:00
Yaël Perret
4685e39985 feat : Avatar avec Stack et résumé 2026-09-10 18:41:23 +02:00
Yaël Perret
b6d6c02d0a feat : Slider et RangeSlider 2026-09-10 11:55:46 +02:00
Yaël Perret
cf59438c25 feat : Switch 2026-09-10 11:40:54 +02:00
Yaël Perret
845a9509b7 feat : Différents inputs 2026-09-08 21:44:07 +02:00
Yaël Perret
8ed48a6cd3 feat : BottomBar + Base component + Clean Nativewind 2026-09-08 19:50:28 +02:00
Yaël Perret
51a522eafc feat : Première navigation 2026-09-02 16:03:32 +02:00
Yaël Perret
4f8596881c feat : Structure de base 2026-09-02 11:31:58 +02:00
Yaël Perret
68d7ea3f9a feat : Création de l'api, chargement des mockups 2026-09-01 18:48:41 +02:00
Yaël Perret
fde710b607 feat : Readme 2026-09-01 17:48:44 +02:00
Yaël Perret
bba2e8f51e feat : Ajout de données d'exemples pour travailler sans API 2026-09-01 17:44:03 +02:00
Yaël Perret
d8aa9956d0 feat : Création de toutes les entités 2026-09-01 17:18:28 +02:00
Yaël Perret
d4246aa2c8 fix : Pas besoin de theming 2026-09-01 16:45:11 +02:00
Yaël Perret
6affbef244 fix : Retrait de React Navigation, pas utile pour le projet 2026-09-01 15:49:13 +02:00
Yaël Perret
4290351ca5 feat : ParticipationSegment 2026-09-01 15:22:42 +02:00
Yaël Perret
5a865d5635 feat : Badge et Chip 2026-09-01 14:43:51 +02:00
Yaël Perret
4c74a27f57 feat : Composant Bouton 2026-09-01 14:16:23 +02:00
Yaël Perret
6a060418b4 feat : Librairie de base 2026-08-31 19:17:46 +02:00
Yaël Perret
21e9e0a593 Merge branch 'main' of git.softyps.com:Bahla-Team/Bahla-Frontend
Merge to correct
2026-08-31 15:48:51 +02:00
Yaël Perret
c710444749 Initial commit 2026-08-31 15:41:06 +02:00
188 changed files with 19398 additions and 0 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders
/ios
/android

1
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": []
}

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

3
AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,34 @@
# Bahla - On sort où ? Bah là !
Ne râtez plus les meilleurs événement prêt de chez vous !
Bahla vous propose différents moyens de vous divertir avec vos potes ou en famille.
## Organisation
### Stratégie d'image
- Utilisation de `expo-image`
- Un placeholder sombre avec des points généré aléatoirement avec un blur
- Dimensions données par le backend
- Image toujours en couverture (object-fit)
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).

48
app.json Normal file
View File

@@ -0,0 +1,48 @@
{
"expo": {
"name": "bahla",
"slug": "bahla",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "bahla",
"userInterfaceStyle": "dark",
"ios": {
"icon": "./assets/expo.icon"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.bahla"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#208AEF",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"expo-image",
"expo-font",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/images/expo-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

BIN
assets/images/logo-glow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
java_pid23812.hprof Normal file

Binary file not shown.

10634
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "bahla_front",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~56.0.14",
"@react-navigation/bottom-tabs": "^7.18.18",
"@tanstack/react-query": "^5.102.8",
"axios": "^1.20.0",
"expo": "~56.0.5",
"expo-blur": "~56.0.4",
"expo-constants": "~56.0.16",
"expo-dev-client": "~56.0.16",
"expo-device": "~56.0.4",
"expo-font": "~56.0.7",
"expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.12",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
"expo-router": "~56.2.7",
"expo-secure-store": "~56.0.4",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.5",
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"lucide-react": "^1.38.0",
"lucide-react-native": "^1.38.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"zod": "^4.5.4",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/react": "~19.2.2",
"eslint": "^10.9.1",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"private": true
}

11
src/api/client.ts Normal file
View File

@@ -0,0 +1,11 @@
import axios from 'axios';
import { API_BASE_URL } from './config';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});

4
src/api/config.ts Normal file
View File

@@ -0,0 +1,4 @@
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:3000';
// Toggle once here to switch all API functions between mocks and real backend.
export const API_USE_MOCKS = (process.env.EXPO_PUBLIC_API_USE_MOCKS ?? 'true') === 'true';

View File

@@ -0,0 +1,9 @@
import type { Animator } from '@/models';
import { mockAnimators } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const animatorsApi = createResourceApi<Animator>({
endpoint: 'animators',
mockData: mockAnimators,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockCommentLikes } from '@/mocks';
import type { CommentLike } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const comment_likesApi = createResourceApi<CommentLike>({
endpoint: 'comment_likes',
mockData: mockCommentLikes,
resolveMockKey: (item) => `${item.user_id}:${item.comment_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { Comment } from '@/models';
import { mockComments } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const commentsApi = createResourceApi<Comment>({
endpoint: 'comments',
mockData: mockComments,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { EventExternalLink } from '@/models';
import { mockEventExternalLinks } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const event_external_linksApi = createResourceApi<EventExternalLink>({
endpoint: 'event_external_links',
mockData: mockEventExternalLinks,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockEventHashtags } from '@/mocks';
import type { EventHashtag } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const event_hashtagsApi = createResourceApi<EventHashtag>({
endpoint: 'event_hashtags',
mockData: mockEventHashtags,
resolveMockKey: (item) => `${item.event_id}:${item.hashtag_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { EventImage } from '@/models';
import { mockEventImages } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const event_imagesApi = createResourceApi<EventImage>({
endpoint: 'event_images',
mockData: mockEventImages,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { EventLineup } from '@/models';
import { mockEventLineup } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const event_lineupApi = createResourceApi<EventLineup>({
endpoint: 'event_lineup',
mockData: mockEventLineup,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { EventStatsSnapshot } from '@/models';
import { mockEventStatsSnapshots } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const event_stats_snapshotsApi = createResourceApi<EventStatsSnapshot>({
endpoint: 'event_stats_snapshots',
mockData: mockEventStatsSnapshots,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockEventThemes } from '@/mocks';
import type { EventTheme } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const event_themesApi = createResourceApi<EventTheme>({
endpoint: 'event_themes',
mockData: mockEventThemes,
resolveMockKey: (item) => `${item.event_id}:${item.theme_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { Event } from '@/models';
import { mockEvents } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const eventsApi = createResourceApi<Event>({
endpoint: 'events',
mockData: mockEvents,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Hashtag } from '@/models';
import { mockHashtags } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const hashtagsApi = createResourceApi<Hashtag>({
endpoint: 'hashtags',
mockData: mockHashtags,
resolveMockKey: (item) => item.id,
});

29
src/api/entities/index.ts Normal file
View File

@@ -0,0 +1,29 @@
export * from './animators';
export * from './comment_likes';
export * from './comments';
export * from './event_external_links';
export * from './event_hashtags';
export * from './event_images';
export * from './event_lineup';
export * from './event_stats_snapshots';
export * from './event_themes';
export * from './events';
export * from './hashtags';
export * from './organizer_members';
export * from './organizer_subscriptions';
export * from './organizers';
export * from './plans';
export * from './post_likes';
export * from './post_media';
export * from './posts';
export * from './search_history';
export * from './social_auth_providers';
export * from './themes';
export * from './tickets';
export * from './user_animator_follows';
export * from './user_event_interactions';
export * from './user_event_shares';
export * from './user_organizer_follows';
export * from './user_theme_preferences';
export * from './users';

View File

@@ -0,0 +1,9 @@
import type { OrganizerMember } from '@/models';
import { mockOrganizerMembers } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const organizer_membersApi = createResourceApi<OrganizerMember>({
endpoint: 'organizer_members',
mockData: mockOrganizerMembers,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { OrganizerSubscription } from '@/models';
import { mockOrganizerSubscriptions } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const organizer_subscriptionsApi = createResourceApi<OrganizerSubscription>({
endpoint: 'organizer_subscriptions',
mockData: mockOrganizerSubscriptions,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Organizer } from '@/models';
import { mockOrganizers } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const organizersApi = createResourceApi<Organizer>({
endpoint: 'organizers',
mockData: mockOrganizers,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Plan } from '@/models';
import { mockPlans } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const plansApi = createResourceApi<Plan>({
endpoint: 'plans',
mockData: mockPlans,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockPostLikes } from '@/mocks';
import type { PostLike } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const post_likesApi = createResourceApi<PostLike>({
endpoint: 'post_likes',
mockData: mockPostLikes,
resolveMockKey: (item) => `${item.user_id}:${item.post_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { PostMedia } from '@/models';
import { mockPostMedia } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const post_mediaApi = createResourceApi<PostMedia>({
endpoint: 'post_media',
mockData: mockPostMedia,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Post } from '@/models';
import { mockPosts } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const postsApi = createResourceApi<Post>({
endpoint: 'posts',
mockData: mockPosts,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { SearchHistory } from '@/models';
import { mockSearchHistory } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const search_historyApi = createResourceApi<SearchHistory>({
endpoint: 'search_history',
mockData: mockSearchHistory,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { SocialAuthProvider } from '@/models';
import { mockSocialAuthProviders } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const social_auth_providersApi = createResourceApi<SocialAuthProvider>({
endpoint: 'social_auth_providers',
mockData: mockSocialAuthProviders,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Theme } from '@/models';
import { mockThemes } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const themesApi = createResourceApi<Theme>({
endpoint: 'themes',
mockData: mockThemes,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import type { Ticket } from '@/models';
import { mockTickets } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const ticketsApi = createResourceApi<Ticket>({
endpoint: 'tickets',
mockData: mockTickets,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockUserAnimatorFollows } from '@/mocks';
import type { UserAnimatorFollow } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const user_animator_followsApi = createResourceApi<UserAnimatorFollow>({
endpoint: 'user_animator_follows',
mockData: mockUserAnimatorFollows,
resolveMockKey: (item) => `${item.user_id}:${item.animator_id}`,
});

View File

@@ -0,0 +1,9 @@
import { mockUserEventInteractions } from '@/mocks';
import type { UserEventInteraction } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const user_event_interactionsApi = createResourceApi<UserEventInteraction>({
endpoint: 'user_event_interactions',
mockData: mockUserEventInteractions,
resolveMockKey: (item) => `${item.user_id}:${item.event_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { UserEventShare } from '@/models';
import { mockUserEventShares } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const user_event_sharesApi = createResourceApi<UserEventShare>({
endpoint: 'user_event_shares',
mockData: mockUserEventShares,
resolveMockKey: (item) => item.id,
});

View File

@@ -0,0 +1,9 @@
import { mockUserOrganizerFollows } from '@/mocks';
import type { UserOrganizerFollow } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const user_organizer_followsApi = createResourceApi<UserOrganizerFollow>({
endpoint: 'user_organizer_follows',
mockData: mockUserOrganizerFollows,
resolveMockKey: (item) => `${item.user_id}:${item.organizer_id}`,
});

View File

@@ -0,0 +1,9 @@
import { mockUserThemePreferences } from '@/mocks';
import type { UserThemePreference } from '@/models';
import { createResourceApi } from '../resourceFactory';
export const user_theme_preferencesApi = createResourceApi<UserThemePreference>({
endpoint: 'user_theme_preferences',
mockData: mockUserThemePreferences,
resolveMockKey: (item) => `${item.user_id}:${item.theme_id}`,
});

View File

@@ -0,0 +1,9 @@
import type { User } from '@/models';
import { mockUsers } from '@/mocks';
import { createResourceApi } from '../resourceFactory';
export const usersApi = createResourceApi<User>({
endpoint: 'users',
mockData: mockUsers,
resolveMockKey: (item) => item.id,
});

33
src/api/index.ts Normal file
View File

@@ -0,0 +1,33 @@
export * from './client';
export * from './config';
export * from './resourceFactory';
export * from './types';
export * from './entities/animators';
export * from './entities/comment_likes';
export * from './entities/comments';
export * from './entities/event_external_links';
export * from './entities/event_hashtags';
export * from './entities/event_images';
export * from './entities/event_lineup';
export * from './entities/event_stats_snapshots';
export * from './entities/event_themes';
export * from './entities/events';
export * from './entities/hashtags';
export * from './entities/organizer_members';
export * from './entities/organizer_subscriptions';
export * from './entities/organizers';
export * from './entities/plans';
export * from './entities/post_likes';
export * from './entities/post_media';
export * from './entities/posts';
export * from './entities/search_history';
export * from './entities/social_auth_providers';
export * from './entities/themes';
export * from './entities/tickets';
export * from './entities/user_animator_follows';
export * from './entities/user_event_interactions';
export * from './entities/user_event_shares';
export * from './entities/user_organizer_follows';
export * from './entities/user_theme_preferences';
export * from './entities/users';

90
src/api/mockStore.ts Normal file
View File

@@ -0,0 +1,90 @@
type KeyResolver<T> = (item: T) => string;
type MockStoreOptions<T> = {
data: T[];
resolveKey: KeyResolver<T>;
};
const clone = <T>(value: T): T => {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const createMockId = (): string => {
const random = Math.random().toString(16).slice(2, 10).padEnd(8, '0');
return `00000000-0000-4000-8000-${random}`;
};
const ensureHasId = <T>(payload: Partial<T>): Partial<T> => {
if ('id' in payload && typeof payload.id === 'string' && payload.id.length > 0) {
return payload;
}
return {
...payload,
id: createMockId(),
};
};
export const createMockStore = <T>(options: MockStoreOptions<T>) => {
const { resolveKey } = options;
const records = [...options.data];
const findIndex = (id: string): number => records.findIndex((item) => resolveKey(item) === id);
return {
list: async (): Promise<T[]> => clone(records),
get: async (id: string): Promise<T> => {
const index = findIndex(id);
if (index === -1) {
throw new Error(`Mock record not found for id "${id}"`);
}
return clone(records[index]);
},
create: async (payload: Partial<T>): Promise<T> => {
const next = ensureHasId(payload) as T;
records.push(next);
return clone(next);
},
update: async (id: string, payload: Partial<T>): Promise<T> => {
const index = findIndex(id);
if (index === -1) {
throw new Error(`Mock record not found for id "${id}"`);
}
const next = {
...records[index],
...payload,
};
records[index] = next;
return clone(next);
},
patch: async (id: string, payload: Partial<T>): Promise<T> => {
const index = findIndex(id);
if (index === -1) {
throw new Error(`Mock record not found for id "${id}"`);
}
const next = {
...records[index],
...payload,
};
records[index] = next;
return clone(next);
},
remove: async (id: string): Promise<void> => {
const index = findIndex(id);
if (index === -1) {
throw new Error(`Mock record not found for id "${id}"`);
}
records.splice(index, 1);
},
};
};

View File

@@ -0,0 +1,66 @@
import type { AxiosInstance } from 'axios';
import { apiClient } from './client';
import { API_USE_MOCKS } from './config';
import { createMockStore } from './mockStore';
import type { CreatePayload, CrudApi, UpdatePayload } from './types';
type CreateResourceApiOptions<T> = {
endpoint: string;
mockData: T[];
resolveMockKey: (item: T) => string;
client?: AxiosInstance;
useMocks?: boolean;
};
export const createResourceApi = <T>(options: CreateResourceApiOptions<T>): CrudApi<T> => {
const {
endpoint,
mockData,
resolveMockKey,
client = apiClient,
useMocks = API_USE_MOCKS,
} = options;
const mockStore = createMockStore<T>({
data: mockData,
resolveKey: resolveMockKey,
});
if (useMocks) {
return {
list: () => mockStore.list(),
get: (id) => mockStore.get(id),
create: (payload) => mockStore.create(payload),
update: (id, payload) => mockStore.update(id, payload),
patch: (id, payload) => mockStore.patch(id, payload),
remove: (id) => mockStore.remove(id),
};
}
return {
list: async () => {
const response = await client.get<T[]>(`/${endpoint}`);
return response.data;
},
get: async (id: string) => {
const response = await client.get<T>(`/${endpoint}/${id}`);
return response.data;
},
create: async (payload: CreatePayload<T>) => {
const response = await client.post<T>(`/${endpoint}`, payload);
return response.data;
},
update: async (id: string, payload: UpdatePayload<T>) => {
const response = await client.put<T>(`/${endpoint}/${id}`, payload);
return response.data;
},
patch: async (id: string, payload: UpdatePayload<T>) => {
const response = await client.patch<T>(`/${endpoint}/${id}`, payload);
return response.data;
},
remove: async (id: string) => {
await client.delete(`/${endpoint}/${id}`);
},
};
};

13
src/api/types.ts Normal file
View File

@@ -0,0 +1,13 @@
export type EntityWithId = { id: string };
export type CreatePayload<T> = Partial<T>;
export type UpdatePayload<T> = Partial<T>;
export type CrudApi<T> = {
list: () => Promise<T[]>;
get: (id: string) => Promise<T>;
create: (payload: CreatePayload<T>) => Promise<T>;
update: (id: string, payload: UpdatePayload<T>) => Promise<T>;
patch: (id: string, payload: UpdatePayload<T>) => Promise<T>;
remove: (id: string) => Promise<void>;
};

View File

@@ -0,0 +1,13 @@
import { useAuth } from '@/stores/authStore';
import { Redirect, Stack } from 'expo-router';
export default function AuthLayout() {
const session = useAuth((state) => state.session);
if (session) {
const href = (session.role === 'org' ? '/(org)' : '/(tabs)') as any;
return <Redirect href={href} />;
}
return <Stack screenOptions={{ headerShown: false}} />;
}

View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function InteretsScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Interets</Text>
<Text style={styles.description}>Base ecran interets</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/(auth)/login.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function LoginScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Login</Text>
<Text style={styles.description}>Base ecran login</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function OnboardingScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Onboarding</Text>
<Text style={styles.description}>Base ecran onboarding</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

78
src/app/(org)/_layout.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { BahlaTabBar } from '@/components/navigation/BahlaTabBar';
import { CreateActionSheet } from '@/components/organizer/CreateActionSheet';
import { useAuth } from '@/stores/authStore';
import { colors } from '@/theme/colors';
import { Redirect, Tabs, router } from 'expo-router';
import { CalendarDays, ChartColumn, LayoutDashboard, UserRound } from 'lucide-react-native';
import { useState } from 'react';
export default function OrgLayout() {
const session = useAuth((state) => state.session);
const [createOpen, setCreateOpen] = useState(false);
if (!session) return <Redirect href="../(auth)/login" />;
if (session.role !== 'org') return <Redirect href="../(tabs)" />;
return (
<>
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.deepBackground },
}}
tabBar={(props) => <BahlaTabBar {...props}
fabRouteName='create'
fabAccessibilityLabel='Créer un événement ou un post'
onFabPress={() => setCreateOpen(true)} />}
>
<Tabs.Screen
name="index"
options={{
title: 'Tableau',
tabBarIcon: ({ color, size }) => <LayoutDashboard color={color} size={size} />,
}}
/>
<Tabs.Screen
name="events"
options={{
title: 'Events',
tabBarIcon: ({ color, size }) => <CalendarDays color={color} size={size} />,
}}
/>
<Tabs.Screen
name="create"
options={{ title: 'Créer' }}
listeners={{ tabPress: (e) => e.preventDefault() }}
/>
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ color, size }) => <ChartColumn color={color} size={size} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profil',
tabBarIcon: ({ color, size }) => <UserRound color={color} size={size} />,
}}
/>
</Tabs>
<CreateActionSheet
visible={createOpen}
onClose={() => setCreateOpen(false)}
onCreateEvent={() => {
setCreateOpen(false);
router.push('/create/event');
}}
onCreatePost={() => {
setCreateOpen(false);
router.push('/create/post');
}}
/>
</>
);
}

7
src/app/(org)/create.tsx Normal file
View File

@@ -0,0 +1,7 @@
/* ------------------------------------------------------------------ *
* Route factice : réserve l'emplacement central de la tab bar.
* Elle n'est jamais atteinte — le listener de (org)/_layout la bloque.
* ------------------------------------------------------------------ */
export default function CreatePlaceholder() {
return null;
}

32
src/app/(org)/events.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function OrgEventsScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Org Events</Text>
<Text style={styles.description}>Base ecran events organisateur</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/(org)/index.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function OrgHomeScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Org Home</Text>
<Text style={styles.description}>Base ecran organisation</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/(org)/profile.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function OrgProfileScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Org Profile</Text>
<Text style={styles.description}>Base ecran profil organisateur</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/(org)/stats.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function OrgStatsScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Org Stats</Text>
<Text style={styles.description}>Base ecran statistiques organisateur</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

View File

@@ -0,0 +1,52 @@
import { BahlaTabBar } from '@/components/navigation/BahlaTabBar';
import { useAuth } from '@/stores/authStore';
import { colors } from '@/theme/colors';
import { Redirect, Tabs } from 'expo-router';
import { Compass, Map, Ticket, UserRound } from 'lucide-react-native';
export default function TabsLayout() {
const session = useAuth((state) => state.session);
if (!session) return <Redirect href="../(auth)/login" />;
if (session.role === 'org') return <Redirect href="../(org)" />;
return (
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.deepBackground },
}}
tabBar={(props) => <BahlaTabBar {...props} />}
>
<Tabs.Screen
name="index"
options={{
title: 'Découvrir',
tabBarIcon: ({ color, size }) => <Compass color={color} size={size} />,
}}
/>
<Tabs.Screen
name="map"
options={{
title: 'Carte',
tabBarIcon: ({ color, size }) => <Map color={color} size={size} />,
}}
/>
<Tabs.Screen
name="my-events"
options={{
title: 'Mes events',
tabBarIcon: ({ color, size }) => <Ticket color={color} size={size} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profil',
tabBarIcon: ({ color, size }) => <UserRound color={color} size={size} />,
}}
/>
</Tabs>
);
}

60
src/app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { useRouter } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/Button';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function HomeTabScreen() {
const router = useRouter();
return (
<View style={styles.container}>
<Text style={styles.title}>Accueil</Text>
<Text style={styles.description}>Base ecran accueil tab</Text>
<View style={styles.actions}>
<Button
label="Voir la démo des inputs"
variant="secondary"
onPress={() => router.push('/index-old')}
accessibilityLabel="Ouvrir la page d'exemple des champs"
/>
<Button
label="Voir la démo du switch"
variant="secondary"
onPress={() => router.push('/switch-demo')}
accessibilityLabel="Ouvrir la page de démonstration du switch"
/>
<Button
label="Voir la démo des cards"
variant="secondary"
onPress={() => router.push('/card-demo')}
accessibilityLabel="Ouvrir la page de présentation des cards"
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
actions: {
marginTop: spacing[16],
gap: spacing[8],
},
});

32
src/app/(tabs)/map.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function MapTabScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Map</Text>
<Text style={styles.description}>Base ecran carte</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

View File

@@ -0,0 +1,39 @@
import { StyleSheet, Text, View } from 'react-native';
import { Header } from '@/components/ui/Header';
import { Screen } from '@/components/ui/Screen';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function MyEventsTabScreen() {
return (
<Screen>
<Header variant="large" overline="12 événements" title="Mes events" />
<View style={styles.container}>
<Text style={styles.title}>My Events</Text>
<Text style={styles.description}>Base ecran mes events</Text>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function ProfileTabScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Profile</Text>
<Text style={styles.description}>Base ecran profil tab</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

62
src/app/_layout.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as SystemUI from 'expo-system-ui';
import { useEffect } from 'react';
import { StatusBar } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { queryClient } from '../hooks/queryClient';
import { useAppFonts } from '../theme/typography';
import { useAuth } from '@/stores/authStore';
import colors from '@/theme/colors';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded] = useAppFonts();
const hydrated = useAuth((state) => state.hydrated);
const hydrate = useAuth((state) => state.hydrate);
useEffect(() => {
hydrate();
}, [hydrate]);
const ready = hydrated && fontsLoaded;
useEffect(() => {
if (ready) SplashScreen.hideAsync();
}, [ready]);
useEffect(() => {
SystemUI.setBackgroundColorAsync(colors.deepBackground);
}, []);
if (!ready) return null;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<StatusBar
barStyle="light-content"
backgroundColor={colors.deepBackground}
translucent={false}
/>
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.deepBackground } }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(org)" />
<Stack.Screen name="switch-demo" />
<Stack.Screen name="card-demo" />
<Stack.Screen name="event/[id]" />
<Stack.Screen name="create/event" options={{ presentation: 'modal' }} />
<Stack.Screen name="create/post" options={{ presentation: 'modal' }} />
</Stack>
</SafeAreaProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

80
src/app/card-demo.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { StyleSheet, Text, View } from 'react-native';
import Card from '@/components/Card';
import { Screen } from '@/components/ui/Screen';
import { mockEvents } from '@/mocks';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
const cardParticipants = [
{
id: 'lea',
source:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80',
name: 'Lea Martin',
},
{
id: 'yanis',
source:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80',
name: 'Yanis Belaid',
},
{
id: 'omar',
source:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=240&q=80',
name: 'Omar Khelifi',
},
];
export default function CardDemoScreen() {
const demoEvent = mockEvents[0];
if (!demoEvent) {
return (
<Screen contentContainerStyle={styles.content}>
<Text style={styles.emptyState}>Aucun événement de démo disponible.</Text>
</Screen>
);
}
return (
<Screen contentContainerStyle={styles.content}>
<View style={styles.header}>
<Text style={styles.kicker}>DESIGN SYSTEM · CARD</Text>
<Text style={styles.title}>Page de présentation Card</Text>
</View>
<Card
variant="hero"
event={demoEvent}
avatarStack={cardParticipants}
onPress={() => undefined}
/>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
gap: spacing[16],
paddingTop: spacing[24],
},
header: {
gap: spacing[8],
marginBottom: spacing[8],
},
kicker: {
...typography.sectionLabel,
color: colors.textSecondary,
},
title: {
...typography.body,
color: colors.textPrimary,
},
emptyState: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/create/event.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function CreateEventScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Create Event</Text>
<Text style={styles.description}>Base ecran creation event</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

32
src/app/create/post.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function CreatePostScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Create Post</Text>
<Text style={styles.description}>Base ecran creation post</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
});

41
src/app/event/[id].tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function EventDetailScreen() {
const { id } = useLocalSearchParams<{ id?: string | string[] }>();
const eventId = Array.isArray(id) ? id[0] : id;
return (
<View style={styles.container}>
<Text style={styles.title}>Event Detail</Text>
<Text style={styles.description}>Base ecran detail event</Text>
<Text style={styles.meta}>ID: {eventId ?? 'non defini'}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.deepBackground,
padding: spacing[24],
justifyContent: 'center',
gap: spacing[8],
},
title: {
...typography.screenTitle,
color: colors.textPrimary,
},
description: {
...typography.body,
color: colors.textSecondary,
},
meta: {
...typography.metadata,
color: colors.textStrong,
},
});

144
src/app/index-old.tsx Normal file
View File

@@ -0,0 +1,144 @@
import { Mail } from 'lucide-react-native';
import { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { FormField, Input, PasswordInput, SearchInput, TextArea } from '@/components/form';
import { Screen } from '@/components/ui/Screen';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
function computePasswordStrength(value: string): 0 | 1 | 2 | 3 {
if (!value) {
return 0;
}
const hasLower = /[a-z]/.test(value);
const hasUpper = /[A-Z]/.test(value);
const hasNumber = /\d/.test(value);
const hasSpecial = /[^A-Za-z0-9]/.test(value);
const criteria = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean)
.length;
if (value.length < 6 || criteria <= 1) {
return 1;
}
if (value.length >= 10 && criteria >= 3) {
return 3;
}
return 2;
}
export default function InputExamplesScreen() {
const [email, setEmail] = useState('lea.mercier@example.ch');
const [password, setPassword] = useState('');
const [search, setSearch] = useState('néon');
const [debouncedSearch, setDebouncedSearch] = useState('néon');
const [description, setDescription] = useState('');
const emailError = useMemo(() => {
if (email.length === 0) {
return 'Adresse e-mail requise';
}
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return isValid ? undefined : 'Adresse e-mail invalide';
}, [email]);
const passwordStrength = useMemo(() => computePasswordStrength(password), [password]);
return (
<Screen contentContainerStyle={styles.content}>
<View style={styles.header}>
<Text style={styles.kicker}>DESIGN SYSTEM · FORM INPUTS</Text>
<Text style={styles.title}>Page d'exemple des champs</Text>
</View>
<FormField
label="E-mail"
required
error={emailError}
counter={{ value: email.length, max: 120 }}
>
<Input
accessibilityLabel="E-mail"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
state={emailError ? 'error' : 'default'}
placeholder="lea.mercier@example.ch"
left={<Mail color={colors.textSecondary} size={20} />}
/>
</FormField>
<FormField label="Mot de passe" helper="6 caractères minimum">
<PasswordInput
accessibilityLabel="Mot de passe"
value={password}
onChangeText={setPassword}
placeholder="••••••••"
strength={passwordStrength}
/>
</FormField>
<FormField label="Recherche" helper={`Terme debouncé: ${debouncedSearch || '—'}`}>
<SearchInput
accessibilityLabel="Recherche"
value={search}
onChangeText={setSearch}
onDebouncedChange={setDebouncedSearch}
debounceMs={300}
placeholder="techno la chaux"
/>
</FormField>
<FormField
label="Description d'événement"
helper="Ajoute le contexte, l'entrée et les horaires"
counter={{ value: description.length, max: 280 }}
>
<TextArea
accessibilityLabel="Description d'événement"
value={description}
onChangeText={setDescription}
rows={4}
maxLength={280}
placeholder="Il reste 40 places en prévente pour ce soir..."
/>
</FormField>
<FormField label="Champ désactivé" helper="Exemple d'état non éditable">
<Input
accessibilityLabel="Champ désactivé"
editable={false}
value="Néon session"
onChangeText={() => undefined}
/>
</FormField>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
gap: spacing[16],
paddingTop: spacing[24],
},
header: {
gap: spacing[8],
marginBottom: spacing[8],
},
kicker: {
...typography.sectionLabel,
color: colors.textSecondary,
},
title: {
...typography.body,
color: colors.textPrimary,
},
});

5
src/app/index-old2.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Redirect } from 'expo-router';
export default function IndexRoute() {
return <Redirect href="/(tabs)" />;
}

342
src/app/switch-demo.tsx Normal file
View File

@@ -0,0 +1,342 @@
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/Button';
import { FormField, Switch } from '@/components/form';
import { AttendeeSummary, Avatar, AvatarStack, RangeSlider, Screen, Slider } from '@/components/ui';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function SwitchDemoScreen() {
const [pushEnabled, setPushEnabled] = useState(true);
const [weeklyDigestEnabled, setWeeklyDigestEnabled] = useState(false);
const [locationEnabled, setLocationEnabled] = useState(true);
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false);
const [radiusKm, setRadiusKm] = useState(25);
const [resultCount, setResultCount] = useState(128);
const [priceRange, setPriceRange] = useState<[number, number]>([0, 65]);
const attendees = [
{
id: 'lea',
source:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80',
name: 'Lea Martin',
},
{
id: 'yanis',
source:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80',
name: 'Yanis Belaid',
},
{
id: 'omar',
source:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=240&q=80',
name: 'Omar Khelifi',
},
];
const attendeesWithFallbacks = [
{ id: 'nora', source: null, name: 'Nora Salem' },
{ id: 'idris', source: null, name: 'Idris Khan' },
{
id: 'mila',
source:
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=240&q=80',
name: 'Mila Chen',
},
];
const formatKm = (v: number) => `${v} km`;
const formatChf = (v: number) => `${v} CHF`;
const formatPriceRange = (minValue: number, maxValue: number) => {
const left = minValue === 0 ? 'Gratuit' : `${minValue} CHF`;
return `${left} - ${maxValue} CHF`;
};
return (
<Screen contentContainerStyle={styles.content}>
<View style={styles.header}>
<Text style={styles.kicker}>DESIGN SYSTEM · SWITCH & SLIDER</Text>
<Text style={styles.title}>Demos phase 4</Text>
</View>
<FormField
label="Notifications push"
helper="Switch custom basé sur Expo/React Native"
>
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Activer les alertes de nouveaux événements</Text>
<Text style={styles.switchHint}>
{pushEnabled ? 'Activé' : 'Désactivé'}
</Text>
</View>
<Switch
accessibilityLabel="Activer les notifications push"
value={pushEnabled}
onValueChange={setPushEnabled}
/>
</View>
</FormField>
<FormField
label="Digest hebdo"
helper="Exemple de taille large"
>
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Recevoir un recap chaque lundi</Text>
<Text style={styles.switchHint}>
{weeklyDigestEnabled ? 'Activé' : 'Désactivé'}
</Text>
</View>
<Switch
accessibilityLabel="Activer le digest hebdomadaire"
value={weeklyDigestEnabled}
onValueChange={setWeeklyDigestEnabled}
size="lg"
/>
</View>
</FormField>
<FormField label="Etat desactive" helper="Le switch est non interactif">
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Mode maintenance</Text>
<Text style={styles.switchHint}>Indisponible</Text>
</View>
<Switch
accessibilityLabel="Switch desactive"
value
disabled
/>
</View>
</FormField>
<FormField
label="Exemples rapides"
helper="Cas d'usage supplementaires"
>
<View style={styles.switchStack}>
<View style={styles.switchRowCompact}>
<Text style={styles.switchLabel}>Localisation precise</Text>
<Switch
accessibilityLabel="Activer la localisation precise"
value={locationEnabled}
onValueChange={setLocationEnabled}
/>
</View>
<View style={styles.switchRowCompact}>
<Text style={styles.switchLabel}>Lecture auto des extraits</Text>
<Switch
accessibilityLabel="Activer la lecture automatique"
value={autoPlayEnabled}
onValueChange={setAutoPlayEnabled}
size="lg"
/>
</View>
<View style={styles.inlineActions}>
<Button
label="Reset switches"
variant="secondary"
onPress={() => {
setPushEnabled(true);
setWeeklyDigestEnabled(false);
setLocationEnabled(true);
setAutoPlayEnabled(false);
}}
/>
</View>
</View>
</FormField>
<View style={styles.sectionDivider} />
<FormField
label="B3 · Rayon de recherche"
helper={`Resultats: ${resultCount}`}
>
<Slider
label="Distance"
value={radiusKm}
onChange={setRadiusKm}
onChangeEnd={(v) => {
const nextCount = Math.max(12, 220 - v * 4);
setResultCount(nextCount);
}}
min={1}
max={50}
step={1}
formatValue={formatKm}
showBounds
ticks={[1, 10, 25, 50]}
/>
</FormField>
<FormField label="B3 · Fourchette de prix" helper="0 a 100 CHF">
<RangeSlider
label="Prix"
value={priceRange}
onChange={setPriceRange}
min={0}
max={100}
step={1}
minGap={1}
formatValue={formatChf}
formatRange={formatPriceRange}
showBounds
ticks={[0, 25, 50, 75, 100]}
/>
</FormField>
<FormField label="Curseur desactive" helper="Exemple disabled">
<Slider
label="Quota"
value={40}
onChange={() => undefined}
min={0}
max={100}
step={1}
formatValue={(v) => `${v}%`}
disabled
showBounds
/>
</FormField>
<View style={styles.sectionDivider} />
<FormField
label="Avatar / Stack / Summary"
helper="Design system phase 4"
>
<View style={styles.avatarStack}>
<View style={styles.exampleRow}>
<Text style={styles.exampleLabel}>1) B1 · Stack 3 sur 12 (+9)</Text>
<AvatarStack items={attendees} max={3} total={12} size="sm" />
</View>
<View style={styles.exampleRow}>
<Text style={styles.exampleLabel}>2) B1 · Meme stack sans bulle</Text>
<AvatarStack items={attendees} max={3} total={12} size="sm" showOverflow={false} />
</View>
<View style={styles.exampleRow}>
<Text style={styles.exampleLabel}>3) C3 · Avatar XL gradient + live</Text>
<Avatar
size="xl"
ring="border"
shape='rounded'
ringColor={colors.primary}
badge="online"
source="https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=300&q=80"
name="Studio Bahla"
/>
</View>
<View style={styles.exampleRow}>
<Text style={styles.exampleLabel}>4) Summary 0 puis 142 dont 3 amis</Text>
<View style={styles.summaryStack}>
<AttendeeSummary items={[]} total={0} size="sm" />
<AttendeeSummary
items={attendees}
total={142}
max={3}
size="sm"
highlight="dont 3 amis"
/>
</View>
</View>
<View style={styles.exampleRow}>
<Text style={styles.exampleLabel}>5) Stack avec fallback initiales</Text>
<AvatarStack items={attendeesWithFallbacks} max={3} total={3} size="sm" />
</View>
</View>
</FormField>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
gap: spacing[16],
paddingTop: spacing[24],
},
header: {
gap: spacing[8],
marginBottom: spacing[8],
},
kicker: {
...typography.sectionLabel,
color: colors.textSecondary,
},
title: {
...typography.body,
color: colors.textPrimary,
},
switchRow: {
minHeight: 56,
borderWidth: 1,
borderColor: colors.inputBorder,
borderRadius: 14,
backgroundColor: colors.inputBackground,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[12],
},
switchLabelWrap: {
flex: 1,
gap: 2,
},
switchLabel: {
...typography.body,
color: colors.textPrimary,
},
switchHint: {
...typography.caption,
color: colors.textSecondary,
},
switchStack: {
gap: spacing[8],
},
switchRowCompact: {
minHeight: 56,
borderWidth: 1,
borderColor: colors.inputBorder,
borderRadius: 14,
backgroundColor: colors.inputBackground,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[12],
},
inlineActions: {
marginTop: spacing[4],
},
sectionDivider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing[4],
},
avatarStack: {
gap: spacing[12],
},
exampleRow: {
gap: spacing[8],
},
exampleLabel: {
...typography.metadata,
color: colors.textSecondary,
},
summaryStack: {
gap: spacing[8],
},
});

245
src/components/Badge.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { Check, type LucideIcon } from 'lucide-react-native';
import { useEffect, useMemo, useRef } from 'react';
import {
Animated,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import { colors } from '@/theme/colors';
import { borderRadius } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export type BadgeVariant = 'verified' | 'tonight' | 'free' | 'complete' | 'draft' | 'pro' | 'date';
export type BadgeDate = {
day: string;
month: string;
};
type BadgeProps = {
variant?: BadgeVariant;
label?: string;
icon?: LucideIcon;
date?: BadgeDate;
compact?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
};
const DEFAULT_LABELS: Record<BadgeVariant, string> = {
verified: 'Vérifié',
tonight: 'Ce soir',
free: 'Gratuit',
complete: 'Complet',
draft: 'Brouillon',
pro: 'Pro',
date: 'Date',
};
export function Badge({
variant = 'verified',
label,
icon,
date,
compact = true,
style,
textStyle,
}: BadgeProps) {
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (variant !== 'tonight') {
return;
}
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1.04,
duration: 640,
useNativeDriver: true,
}),
Animated.timing(pulse, {
toValue: 0.98,
duration: 640,
useNativeDriver: true,
}),
]),
);
animation.start();
return () => animation.stop();
}, [pulse, variant]);
const config = useMemo(() => {
if (variant === 'verified') {
return {
backgroundColor: 'rgba(40, 224, 240, 0.12)',
borderColor: 'rgba(40, 224, 240, 0.6)',
textColor: '#A9F4FF',
};
}
if (variant === 'tonight') {
return {
backgroundColor: 'rgba(255, 46, 136, 0.18)',
borderColor: 'rgba(255, 46, 136, 0.7)',
textColor: '#FFD0E3',
};
}
if (variant === 'free') {
return {
backgroundColor: colors.successSoft,
borderColor: colors.successBorder,
textColor: '#A6F5CD',
};
}
if (variant === 'complete') {
return {
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderColor: 'rgba(255, 255, 255, 0.12)',
textColor: '#F4F5FA',
};
}
if (variant === 'draft') {
return {
backgroundColor: colors.warningSoft,
borderColor: 'rgba(255, 196, 120, 0.55)',
textColor: '#FFE0AE',
};
}
if (variant === 'pro') {
return {
backgroundColor: colors.secondarySoft,
borderColor: 'rgba(123, 61, 255, 0.55)',
textColor: '#DCCBFF',
};
}
return {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
borderColor: 'rgba(255, 255, 255, 0.12)',
textColor: colors.textPrimary,
};
}, [colors, variant]);
if (variant === 'date' || date) {
const day = date?.day ?? '04';
const month = date?.month ?? 'sept';
return (
<View
style={[
styles.dateBadge,
{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderColor: 'rgba(255, 255, 255, 0.12)',
},
style,
]}
>
<Text
style={[
styles.dateDay,
{
color: '#F5F7FF',
},
]}
>
{day}
</Text>
<Text
style={[
styles.dateMonth,
{
color: '#DFE3F2',
},
]}
>
{month}
</Text>
</View>
);
}
const Icon = icon ?? (variant === 'verified' ? Check : undefined);
const resolvedLabel = (label ?? DEFAULT_LABELS[variant] ?? '').toUpperCase();
const badgeStyle: ViewStyle = {
backgroundColor: config.backgroundColor,
borderColor: config.borderColor,
borderWidth: 1,
borderRadius: borderRadius.chip,
paddingHorizontal: compact ? 12 : 16,
paddingVertical: compact ? 8 : 10,
minHeight: compact ? 30 : 34,
alignSelf: 'flex-start',
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
...(variant === 'tonight'
? { shadowColor: '#FF2E88', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.45, shadowRadius: 12, elevation: 6 }
: {}),
};
const textStyleResolved: TextStyle = {
color: config.textColor,
...typography.badge,
letterSpacing: 1.45,
opacity: 1,
fontWeight: '600',
};
return (
<Animated.View
style={[
badgeStyle,
variant === 'tonight' ? { transform: [{ scale: pulse }] } : null,
style,
]}
>
{Icon ? <Icon color={config.textColor} size={12} strokeWidth={2.5} /> : null}
<Text style={[textStyleResolved, textStyle]}>{resolvedLabel}</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
dateBadge: {
width: 54,
aspectRatio: 1,
borderRadius: 14,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
paddingVertical: 6,
},
dateDay: {
fontFamily: 'SpaceGrotesk_700Bold',
fontSize: 18,
lineHeight: 20,
letterSpacing: -0.24,
textAlign: 'center',
},
dateMonth: {
fontFamily: 'JetBrainsMono_500Medium',
fontSize: 9,
lineHeight: 10,
letterSpacing: 1.2,
textAlign: 'center',
textTransform: 'uppercase',
marginTop: 1,
},
});

263
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,263 @@
import { LinearGradient } from 'expo-linear-gradient';
import type { LucideIcon } from 'lucide-react-native';
import { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
type GestureResponderEvent,
Pressable,
type StyleProp,
StyleSheet,
Text,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { colors, gradientDirection, gradients } from '@/theme/colors';
import { borderRadius } from '@/theme/spacing';
import { typography } from '@/theme/typography';
const BUTTON_HEIGHT = 52;
const PRESSED_SCALE = 0.97;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type IconOnlyShape = 'round' | 'default';
type ButtonProps = {
label?: string;
variant?: ButtonVariant;
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
autoLoading?: boolean;
iconOnly?: boolean;
iconOnlyShape?: IconOnlyShape;
icon?: LucideIcon;
leftIcon?: LucideIcon;
rightIcon?: LucideIcon;
iconSize?: number;
iconStrokeWidth?: number;
fullWidth?: boolean;
onPress?: (event: GestureResponderEvent) => void | Promise<void>;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
accessibilityLabel?: string;
testID?: string;
};
export function Button({
label,
variant = 'primary',
disabled = false,
loading = false,
loadingLabel,
autoLoading = false,
iconOnly = false,
iconOnlyShape = 'round',
icon,
leftIcon,
rightIcon,
iconSize = 20,
iconStrokeWidth = 2,
fullWidth = true,
onPress,
style,
textStyle,
accessibilityLabel,
testID,
}: ButtonProps) {
const [internalLoading, setInternalLoading] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isLoading = loading || internalLoading;
const isDisabled = disabled || isLoading;
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = useCallback(() => {
if (isDisabled) {
return;
}
setIsPressed(true);
scale.value = withTiming(PRESSED_SCALE, { duration: 120 });
}, [isDisabled, scale]);
const handlePressOut = useCallback(() => {
setIsPressed(false);
scale.value = withTiming(1, { duration: 140 });
}, [scale]);
const handlePress = useCallback(
async (event: GestureResponderEvent) => {
if (!onPress || isDisabled) {
return;
}
const result = onPress(event);
if (!autoLoading || !(result instanceof Promise)) {
return;
}
setInternalLoading(true);
try {
await result;
} finally {
setInternalLoading(false);
}
},
[autoLoading, isDisabled, onPress],
);
const labelColor = useMemo(() => {
if (isDisabled) {
return colors.textDisabled;
}
if (variant === 'primary') {
return colors.textPrimary;
}
if (variant === 'destructive') {
return colors.danger;
}
if (variant === 'ghost') {
return colors.tertiary;
}
return colors.textPrimary;
}, [colors, isDisabled, variant]);
const loadingColor = variant === 'primary' && !isDisabled ? colors.textPrimary : labelColor;
const iconOnlyRadius = iconOnlyShape === 'round' ? BUTTON_HEIGHT / 2 : borderRadius.button;
const sideIconColor = !isDisabled && variant === 'secondary' ? colors.tertiary : labelColor;
const iconOnlyColor = labelColor;
const IconOnly = icon;
const LeftIcon = leftIcon;
const RightIcon = rightIcon;
const containerStyle: ViewStyle = {
width: fullWidth && !iconOnly ? '100%' : BUTTON_HEIGHT,
height: BUTTON_HEIGHT,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
overflow: 'hidden',
};
const baseFillStyle: ViewStyle = {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
};
const variantFillStyle: ViewStyle = (() => {
if (isDisabled) {
return {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'secondary') {
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'ghost') {
return {
backgroundColor: 'transparent',
};
}
if (variant === 'destructive') {
return {
backgroundColor: 'rgba(255, 77, 109, 0.08)',
borderWidth: 1,
borderColor: colors.danger,
};
}
return {
backgroundColor: colors.surface,
};
})();
const contentPaddingStyle: ViewStyle = {
paddingHorizontal: iconOnly ? 0 : 24,
};
const textStyles: TextStyle = {
...typography.button,
color: labelColor,
};
const content = (
<>
{isLoading ? <ActivityIndicator color={loadingColor} size="small" /> : null}
{!isLoading && iconOnly && IconOnly ? (
<IconOnly color={iconOnlyColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && !isLoading && LeftIcon ? (
<LeftIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && label ? <Text style={[textStyles, textStyle]}>{isLoading && loadingLabel ? loadingLabel : label}</Text> : null}
{!iconOnly && !isLoading && RightIcon ? (
<RightIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
</>
);
const fillNode =
variant === 'primary' && !isDisabled ? (
<LinearGradient
colors={isPressed ? gradients.signaturePressed : gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[baseFillStyle, contentPaddingStyle]}
>
{content}
</LinearGradient>
) : (
<View style={[baseFillStyle, contentPaddingStyle, variantFillStyle]}>{content}</View>
);
return (
<AnimatedPressable
testID={testID}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: isLoading }}
accessibilityLabel={accessibilityLabel ?? label}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[styles.root, containerStyle, animatedStyle, style]}
>
{fillNode}
</AnimatedPressable>
);
}
export default Button;
const styles = StyleSheet.create({
root: {
alignSelf: 'stretch',
},
});

88
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { Event } from '@/models/events';
import { StyleSheet, Text, View } from 'react-native';
import { Badge } from './Badge';
import { Button } from './Button';
import { AvatarStack, AvatarStackItem } from './ui/AvatarStack';
import { typography } from '@/theme';
import { colors } from '@/theme/colors';
import { borderRadius } from '@/theme/spacing';
import { Heart, MapPin } from 'lucide-react-native';
export type CardVariant = 'hero' | 'list' | 'compact';
export type CardProps = {
variant: CardVariant;
event: Event;
avatarStack?: AvatarStackItem[];
onPress?: () => void;
};
function Card({
variant,
event,
avatarStack,
onPress
}: CardProps) {
const eventDate = event.start_date;
const badgeDate = {
day: eventDate.getDate().toString().padStart(2, '0'),
month: new Intl.DateTimeFormat('fr-FR', { month: 'short' })
.format(eventDate)
.replace('.', ''),
};
return (
<View
style={styles.card}
>
<View style={styles.header}>
<Badge variant='date' date={badgeDate} />
<Button variant='ghost' icon={Heart} iconOnly onPress={onPress} />
</View>
<View style={styles.content}>
<Badge variant='tonight' label='CE SOIR' />
<Text style={styles.title}>
{event.name}
</Text>
<Text style={styles.subtitle}>
<MapPin size={16} color={colors.textBody} />{' '}
{event.address}, {event.city} - 18 km
</Text>
{avatarStack && <AvatarStack items={avatarStack} />}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: borderRadius.card,
backgroundColor: colors.surface,
height: 230,
padding: 16,
},
header: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
},
content: {
flex: 3,
justifyContent: 'flex-end',
},
title: {
...typography.cardTitle,
color: colors.textPrimary,
},
subtitle: {
...typography.label,
color: colors.textBody
}
});
export default Card;

View File

@@ -0,0 +1,373 @@
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { Check, Heart } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { colors, glow, gradientDirection, gradients } from '@/theme/colors';
import { borderRadius } from '@/theme/spacing';
import { typography } from '@/theme/typography';
type ParticipationValue = {
going: boolean;
interested: boolean;
};
type ParticipationSegmentProps = {
value?: ParticipationValue;
defaultValue?: Partial<ParticipationValue>;
onChange?: (nextValue: ParticipationValue) => void;
onToggleGoing?: (nextGoing: boolean) => void;
onToggleInterested?: (nextInterested: boolean) => void;
disabled?: boolean;
loadingGoing?: boolean;
loadingInterested?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
const BUTTON_HEIGHT = 52;
const TRANSITION_DURATION = 180;
const ICON_SIZE = 16;
const SPINNER_SIZE = 18;
const AnimatedView = Animated.createAnimatedComponent(View);
export function ParticipationSegment({
value,
defaultValue,
onChange,
onToggleGoing,
onToggleInterested,
disabled = false,
loadingGoing = false,
loadingInterested = false,
style,
testID,
}: ParticipationSegmentProps) {
const [internalValue, setInternalValue] = useState<ParticipationValue>({
going: defaultValue?.going ?? false,
interested: defaultValue?.interested ?? false,
});
const resolvedValue = value ?? internalValue;
const goingProgress = useSharedValue(resolvedValue.going ? 1 : 0);
const interestedProgress = useSharedValue(resolvedValue.interested ? 1 : 0);
useEffect(() => {
goingProgress.value = withTiming(resolvedValue.going ? 1 : 0, {
duration: TRANSITION_DURATION,
easing: Easing.out(Easing.cubic),
});
}, [goingProgress, resolvedValue.going]);
useEffect(() => {
interestedProgress.value = withTiming(resolvedValue.interested ? 1 : 0, {
duration: TRANSITION_DURATION,
easing: Easing.out(Easing.cubic),
});
}, [interestedProgress, resolvedValue.interested]);
const commitChange = useCallback(
(nextValue: ParticipationValue) => {
if (!value) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
},
[onChange, value],
);
const emitHaptic = useCallback(async (nextSelected: boolean) => {
try {
if (nextSelected) {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
return;
}
await Haptics.selectionAsync();
} catch {
// Haptics can fail on unsupported devices, the toggle should still work.
}
}, []);
const handleToggleGoing = useCallback(async () => {
if (disabled || loadingGoing) {
return;
}
const nextGoing = !resolvedValue.going;
const nextValue = {
...resolvedValue,
going: nextGoing,
};
await emitHaptic(nextGoing);
commitChange(nextValue);
onToggleGoing?.(nextGoing);
}, [commitChange, disabled, emitHaptic, loadingGoing, onToggleGoing, resolvedValue]);
const handleToggleInterested = useCallback(async () => {
if (disabled || loadingInterested) {
return;
}
const nextInterested = !resolvedValue.interested;
const nextValue = {
...resolvedValue,
interested: nextInterested,
};
await emitHaptic(nextInterested);
commitChange(nextValue);
onToggleInterested?.(nextInterested);
}, [commitChange, disabled, emitHaptic, loadingInterested, onToggleInterested, resolvedValue]);
const goingGlowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(goingProgress.value, [0, 1], [glow.primary.shadowOpacity ?? 0, 0]),
elevation: interpolate(goingProgress.value, [0, 1], [glow.primary.elevation ?? 0, 0]),
}));
const goingIdleLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(goingProgress.value, [0, 1], [1, 0]),
}));
const goingSelectedLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(goingProgress.value, [0, 1], [0, 1]),
}));
const interestedIdleLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(interestedProgress.value, [0, 1], [1, 0]),
}));
const interestedSelectedLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(interestedProgress.value, [0, 1], [0, 1]),
}));
const goingAccessibilityLabel = useMemo(
() => (resolvedValue.going ? 'Se desinscrire de la participation' : 'Participer a cet evenement'),
[resolvedValue.going],
);
const interestedAccessibilityLabel = useMemo(
() =>
resolvedValue.interested
? 'Retirer ce marqueur interesse'
: 'Marquer cet evenement comme interesse',
[resolvedValue.interested],
);
const buttonRadius = borderRadius.button;
return (
<View style={[styles.row, style]} testID={testID}>
<AnimatedView
style={[
styles.buttonShell,
{
borderRadius: buttonRadius,
shadowColor: glow.primary.shadowColor,
shadowOffset: glow.primary.shadowOffset,
shadowRadius: glow.primary.shadowRadius,
},
!disabled ? goingGlowStyle : null,
]}
>
<Pressable
onPress={handleToggleGoing}
disabled={disabled || loadingGoing}
accessibilityRole="button"
accessibilityState={{ selected: resolvedValue.going, disabled: disabled || loadingGoing, busy: loadingGoing }}
accessibilityLabel={goingAccessibilityLabel}
style={styles.pressable}
>
{disabled ? (
<View
style={[
styles.layer,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderColor: colors.border,
borderWidth: 1.5,
opacity: 0.45,
},
]}
>
<Check color={colors.textDisabled} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.textDisabled }]}>Je participe</Text>
</View>
) : (
<>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, goingIdleLayerStyle]}>
<LinearGradient
colors={gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[styles.fill, { borderRadius: buttonRadius }]}
>
{loadingGoing ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.textPrimary} />
) : (
<>
<Check color={colors.textPrimary} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.textPrimary }]}>Je participe</Text>
</>
)}
</LinearGradient>
</AnimatedView>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, goingSelectedLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.successSoft,
borderWidth: 1.5,
borderColor: colors.successBorder,
},
]}
>
{loadingGoing ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.success} />
) : (
<>
<Check color={colors.success} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.success }]}>Inscrit</Text>
</>
)}
</View>
</AnimatedView>
</>
)}
</Pressable>
</AnimatedView>
<View style={[styles.buttonShell, { borderRadius: buttonRadius }]}>
<Pressable
onPress={handleToggleInterested}
disabled={disabled || loadingInterested}
accessibilityRole="button"
accessibilityState={{ selected: resolvedValue.interested, disabled: disabled || loadingInterested, busy: loadingInterested }}
accessibilityLabel={interestedAccessibilityLabel}
style={styles.pressable}
>
{disabled ? (
<View
style={[
styles.layer,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderColor: colors.border,
borderWidth: 1.5,
opacity: 0.45,
},
]}
>
<Heart color={colors.textDisabled} size={ICON_SIZE} strokeWidth={1.7} />
<Text style={[typography.button, { color: colors.textDisabled }]}>Interesse</Text>
</View>
) : (
<>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, interestedIdleLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderWidth: 1.5,
borderColor: colors.border,
},
]}
>
{loadingInterested ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.textPrimary} />
) : (
<>
<Heart color={colors.textPrimary} size={ICON_SIZE} strokeWidth={1.7} fill="none" />
<Text style={[typography.button, { color: colors.textPrimary }]}>Interesse</Text>
</>
)}
</View>
</AnimatedView>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, interestedSelectedLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.primarySoft,
borderWidth: 1.5,
borderColor: colors.primaryBorder,
},
]}
>
{loadingInterested ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.primary} />
) : (
<>
<Heart color={colors.primary} size={ICON_SIZE} strokeWidth={0} fill={colors.primary} />
<Text style={[typography.button, { color: colors.primary }]}>Interesse</Text>
</>
)}
</View>
</AnimatedView>
</>
)}
</Pressable>
</View>
</View>
);
}
export type { ParticipationSegmentProps, ParticipationValue };
export default ParticipationSegment;
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
buttonShell: {
flex: 1,
height: BUTTON_HEIGHT,
overflow: 'hidden',
},
pressable: {
flex: 1,
},
layer: {
...StyleSheet.absoluteFill,
},
fill: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
});

View File

@@ -0,0 +1,132 @@
import { LinearGradient } from 'expo-linear-gradient';
import { X } from 'lucide-react-native';
import { useCallback } from 'react';
import {
Pressable,
type StyleProp,
StyleSheet,
Text,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
import { colors, gradientDirection, gradients } from '@/theme/colors';
import { borderRadius } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export type ThemeChipSelectedVariant = 'gradient' | 'accent';
export type ThemeChipProps = {
label: string;
value?: string;
selected?: boolean;
selectedVariant?: ThemeChipSelectedVariant;
disabled?: boolean;
showCross?: boolean;
showRemoveIcon?: boolean;
onSelect?: (value: string, nextSelected: boolean) => void;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
accessibilityLabel?: string;
};
const CHIP_HEIGHT = 34;
export function ThemeChip({
label,
value,
selected = false,
selectedVariant = 'accent',
disabled = false,
showCross,
showRemoveIcon = true,
onSelect,
style,
textStyle,
testID,
accessibilityLabel,
}: ThemeChipProps) {
const resolvedValue = value ?? label;
const isGradient = selected && selectedVariant === 'gradient';
const showSelectedIcon = selected && (showCross ?? showRemoveIcon);
const handlePress = useCallback(() => {
if (disabled) {
return;
}
onSelect?.(resolvedValue, !selected);
}, [disabled, onSelect, resolvedValue, selected]);
const containerStyle: ViewStyle = {
minHeight: CHIP_HEIGHT,
borderRadius: borderRadius.chip,
paddingHorizontal: 14,
borderWidth: 1,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
opacity: disabled ? 0.45 : 1,
};
const accentSelectedStyle: ViewStyle = {
backgroundColor: colors.primarySoft,
borderColor: colors.primary,
};
const idleStyle: ViewStyle = {
backgroundColor: colors.surface,
borderColor: '#2A2A3C',
};
const textColor = isGradient
? colors.textPrimary
: selected
? colors.textOnPrimarySoft
: colors.textPrimary;
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={handlePress}
accessibilityRole="button"
accessibilityState={{ disabled, selected }}
accessibilityLabel={accessibilityLabel ?? label}
style={[styles.root, style]}
>
{isGradient ? (
<LinearGradient
colors={gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[containerStyle, { borderColor: 'transparent' }]}
>
<Text style={[styles.label, typography.chip, { color: textColor }, textStyle]}>{label}</Text>
{showSelectedIcon ? <X color={textColor} size={12} strokeWidth={2.3} /> : null}
</LinearGradient>
) : (
<View style={[containerStyle, selected ? accentSelectedStyle : idleStyle]}>
<Text style={[styles.label, typography.chip, { color: textColor }, textStyle]}>{label}</Text>
{showSelectedIcon ? <X color={textColor} size={12} strokeWidth={2.3} /> : null}
</View>
)}
</Pressable>
);
}
export default ThemeChip;
const styles = StyleSheet.create({
root: {
alignSelf: 'flex-start',
},
label: {
fontWeight: '600',
},
});

View File

@@ -0,0 +1,110 @@
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { fontFamily, typography } from '@/theme/typography';
export type FieldCounter = {
value: number;
max: number;
};
export type FormFieldProps = {
label?: string;
required?: boolean;
helper?: string;
error?: string;
counter?: FieldCounter;
children: ReactNode;
};
const FormField = ({
label,
required = false,
helper,
error,
counter,
children,
}: FormFieldProps) => {
const message = error ?? helper;
const isCounterOverflow =
typeof counter?.value === 'number' && typeof counter?.max === 'number'
? counter.value > counter.max
: false;
return (
<View style={styles.container}>
{label ? (
<Text style={styles.label}>
{label}
{required ? <Text style={styles.required}> *</Text> : null}
</Text>
) : null}
{children}
{message || counter ? (
<View style={styles.bottomRow}>
{message ? (
<Text
style={[styles.message, error ? styles.errorMessage : null]}
accessibilityLiveRegion={error ? 'polite' : undefined}
>
{message}
</Text>
) : (
<View style={styles.messageSpacer} />
)}
{counter ? (
<Text
style={[styles.counter, isCounterOverflow ? styles.counterError : null]}
>
{counter.value} / {counter.max}
</Text>
) : null}
</View>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 8,
},
label: {
...typography.inputLabel,
color: colors.textBody,
},
required: {
color: colors.primary,
},
bottomRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
message: {
...typography.helper,
color: colors.textSecondary,
flex: 1,
},
errorMessage: {
color: colors.danger,
},
messageSpacer: {
flex: 1,
},
counter: {
...typography.metadataSm,
color: colors.textSecondary,
fontFamily: fontFamily.mono,
},
counterError: {
color: colors.danger,
},
});
export default FormField;

Some files were not shown because too many files have changed in this diff Show More