Compare commits
20 Commits
04d7e5c009
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bc1149f05 | ||
|
|
4685e39985 | ||
|
|
b6d6c02d0a | ||
|
|
cf59438c25 | ||
|
|
845a9509b7 | ||
|
|
8ed48a6cd3 | ||
|
|
51a522eafc | ||
|
|
4f8596881c | ||
|
|
68d7ea3f9a | ||
|
|
fde710b607 | ||
|
|
bba2e8f51e | ||
|
|
d8aa9956d0 | ||
|
|
d4246aa2c8 | ||
|
|
6affbef244 | ||
|
|
4290351ca5 | ||
|
|
5a865d5635 | ||
|
|
4c74a27f57 | ||
|
|
6a060418b4 | ||
|
|
21e9e0a593 | ||
|
|
c710444749 |
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
43
.gitignore
vendored
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
7
.vscode/launch.json
vendored
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
AGENTS.md
Normal 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.
|
||||||
21
LICENSE
Normal 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.
|
||||||
34
README.md
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
assets/expo.icon/Assets/expo-symbol 2.svg
Normal 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 |
BIN
assets/expo.icon/Assets/grid.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
40
assets/expo.icon/icon.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-badge-white.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-badge.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
assets/images/logo-glow.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/images/tabIcons/explore.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
assets/images/tabIcons/explore@2x.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
assets/images/tabIcons/explore@3x.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
assets/images/tabIcons/home.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
assets/images/tabIcons/home@2x.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
assets/images/tabIcons/home@3x.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
assets/images/tutorial-web.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
java_pid23812.hprof
Normal file
10634
package-lock.json
generated
Normal file
60
package.json
Normal 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
@@ -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
@@ -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';
|
||||||
9
src/api/entities/animators.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/comment_likes.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/comments.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/event_external_links.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/event_hashtags.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/event_images.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/event_lineup.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/event_stats_snapshots.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/event_themes.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/events.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/hashtags.ts
Normal 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
@@ -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';
|
||||||
|
|
||||||
9
src/api/entities/organizer_members.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/organizer_subscriptions.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/organizers.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/plans.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/post_likes.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/post_media.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/posts.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/search_history.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/social_auth_providers.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/themes.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/tickets.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/user_animator_follows.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/user_event_interactions.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/user_event_shares.ts
Normal 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,
|
||||||
|
});
|
||||||
9
src/api/entities/user_organizer_follows.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/user_theme_preferences.ts
Normal 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}`,
|
||||||
|
});
|
||||||
9
src/api/entities/users.ts
Normal 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
@@ -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
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
66
src/api/resourceFactory.ts
Normal 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
@@ -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>;
|
||||||
|
};
|
||||||
13
src/app/(auth)/_layout.tsx
Normal 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}} />;
|
||||||
|
}
|
||||||
32
src/app/(auth)/interets.tsx
Normal 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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
src/app/(auth)/onboarding.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
52
src/app/(tabs)/_layout.tsx
Normal 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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
39
src/app/(tabs)/my-events.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
src/app/(tabs)/profile.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
373
src/components/ParticipationSegment.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
132
src/components/ThemeChip.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
110
src/components/form/FormField.tsx
Normal 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;
|
||||||