refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events) - Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests) - Phase 2: Remove all `any` types from production code with proper typed replacements - Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility - Phase 4: Verify .DS_Store housekeeping (already clean) 0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
576
.claude/skills/design_system/SKILL.md
Normal file
576
.claude/skills/design_system/SKILL.md
Normal file
@@ -0,0 +1,576 @@
|
||||
---
|
||||
name: tabata-kine-design-system
|
||||
description: >
|
||||
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
|
||||
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
|
||||
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
|
||||
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
|
||||
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
|
||||
input), une couleur, une typographie, une animation, un espacement, ou demande
|
||||
à coder un élément UI. Ce skill contient les règles non négociables du design
|
||||
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
|
||||
---
|
||||
|
||||
# Design System — Tabata Kiné
|
||||
|
||||
## Principe directeur : Dark Medical
|
||||
|
||||
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
|
||||
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
|
||||
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
|
||||
expertise kiné visible à chaque écran.
|
||||
|
||||
**Règles absolues :**
|
||||
- Pas de mode clair. Dark only, sans exception.
|
||||
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
|
||||
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
|
||||
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
|
||||
- Touch target minimum : 44×44px pour tous les éléments interactifs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens de couleur
|
||||
|
||||
### Fonds — Navy
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
|
||||
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
|
||||
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
|
||||
| `navy-600` | `#243C5E` | Bordures actives |
|
||||
|
||||
### Vert Kiné — action & santé
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
|
||||
| `green-600` | `#00A67C` | État hover / pressed |
|
||||
| `green-700` | `#00875F` | État active deep |
|
||||
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
|
||||
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
|
||||
|
||||
### Texte & bordures
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `white-100` | `#E6F1FF` | Texte primaire |
|
||||
| `slate-300` | `#A8B2D8` | Texte secondaire |
|
||||
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
|
||||
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
|
||||
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
|
||||
|
||||
### Orange — conseils kiné uniquement
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
|
||||
| `orange-600` | `#E06A3C` | Hover orange |
|
||||
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
|
||||
|
||||
### Sémantique
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
|
||||
|
||||
---
|
||||
|
||||
## 2. Typographie
|
||||
|
||||
### Familles
|
||||
|
||||
| Rôle | Famille | Notes |
|
||||
|------|---------|-------|
|
||||
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
|
||||
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
|
||||
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
|
||||
|
||||
### Échelle
|
||||
|
||||
| Style | Famille | Taille | Poids | Usage |
|
||||
|-------|---------|--------|-------|-------|
|
||||
| `display` | Serif italic | 28–32px | 400 | Fin de séance, titres forts |
|
||||
| `heading-1` | Serif | 22–24px | 500 | Titres de section |
|
||||
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
|
||||
| `body` | Sans | 15–16px | 400 | Corps, conseil kiné |
|
||||
| `label` | Mono | 11–13px | 500 | Tags, metadata, uppercase tracking |
|
||||
| `timer` | Mono | **80–100px** | 500 | Timer séance — lisible à 2 mètres |
|
||||
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
|
||||
|
||||
**Règle typographie :** La taille du timer est la décision de design la plus
|
||||
importante de l'écran séance. Tout se dimensionne autour de lui.
|
||||
|
||||
---
|
||||
|
||||
## 3. Espacement
|
||||
|
||||
Base : **4px**
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `space-1` | 4px | Gap minimal entre éléments liés |
|
||||
| `space-2` | 8px | Gap interne composant |
|
||||
| `space-3` | 12px | Gap entre composants proches |
|
||||
| `space-4` | 16px | Padding carte, gap standard |
|
||||
| `space-6` | 24px | Espacement sections |
|
||||
| `space-8` | 32px | Padding écran horizontal |
|
||||
| `space-12` | 48px | Espacement majeur |
|
||||
| `space-16` | 64px | Espacement entre blocs screens |
|
||||
|
||||
---
|
||||
|
||||
## 4. Border Radius
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `radius-sm` | 4px | Badge, chip, tag |
|
||||
| `radius-md` | 8px | Bouton, input, tip card |
|
||||
| `radius-lg` | 12px | Carte programme standard |
|
||||
| `radius-xl` | 16px | Carte large, modal |
|
||||
| `radius-pill` | 9999px | Pill, toggle, progress bar |
|
||||
| `radius-circle` | 50% | Icon button, avatar, streak dot |
|
||||
|
||||
---
|
||||
|
||||
## 5. Système d'élévation (surfaces)
|
||||
|
||||
```
|
||||
Fond (navy-900)
|
||||
└── Surface 1 (navy-800) — cartes par défaut
|
||||
└── Surface 2 (navy-700) — cartes surélevées / hover
|
||||
└── Surface active (navy-800 + border green-500 1.5px)
|
||||
```
|
||||
|
||||
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
|
||||
ombres portées (box-shadow : non). La bordure active verte est le seul signal
|
||||
d'état sélectionné.
|
||||
|
||||
---
|
||||
|
||||
## 6. Composants
|
||||
|
||||
### Boutons
|
||||
|
||||
```
|
||||
PrimaryButton
|
||||
background: green-500
|
||||
color: navy-900
|
||||
padding: 14px 24px
|
||||
height: 52–56px
|
||||
border-radius: radius-md
|
||||
font: sans 15px 500
|
||||
width: 100% (full-width dans les screens)
|
||||
hover: background green-600
|
||||
active: background green-700 + scale(0.98)
|
||||
|
||||
SecondaryButton
|
||||
background: transparent
|
||||
color: green-500
|
||||
border: 1.5px solid green-500
|
||||
padding: 13px 24px
|
||||
hover: background green-dim
|
||||
|
||||
GhostButton
|
||||
background: transparent
|
||||
color: slate-300
|
||||
no border
|
||||
usage: actions secondaires (Passer, Annuler)
|
||||
|
||||
DangerButton
|
||||
background: rgba(255,68,68,0.12)
|
||||
color: #FF6B6B
|
||||
border: 1px solid rgba(255,68,68,0.3)
|
||||
usage: Quitter la séance UNIQUEMENT
|
||||
|
||||
IconButton
|
||||
width: 44px
|
||||
height: 44px
|
||||
border-radius: 50%
|
||||
background: rgba(168,178,216,0.10)
|
||||
color: slate-300
|
||||
JAMAIS en dessous de 44×44px (accessibilité)
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```
|
||||
TextField
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-md
|
||||
padding: 12px 16px
|
||||
color: white-100
|
||||
font: sans 15px 400
|
||||
focus: border green-500
|
||||
error: border red-500
|
||||
height: 48px
|
||||
```
|
||||
|
||||
### Badges & Pills
|
||||
|
||||
```
|
||||
Badge (tier)
|
||||
font: mono 11px 500
|
||||
padding: 3px 10px
|
||||
border-radius: radius-sm
|
||||
UPPERCASE + letter-spacing: 0.08em
|
||||
|
||||
.free: background green-dim, color green-500
|
||||
.premium: background orange-dim, color orange-500
|
||||
.kine: background rgba(168,178,216,0.12), color slate-300
|
||||
|
||||
Pill (metadata)
|
||||
font: sans 12px 400
|
||||
padding: 4px 12px
|
||||
border-radius: radius-pill
|
||||
border: 1px solid (couleur correspondante à 0.3 opacity)
|
||||
```
|
||||
|
||||
### Cartes
|
||||
|
||||
```
|
||||
CardDefault
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-lg
|
||||
padding: 16px
|
||||
|
||||
CardAccent (CTA, prochaine séance)
|
||||
background: rgba(0,200,150,0.05)
|
||||
border: 1.5px solid green-border
|
||||
border-radius: radius-lg
|
||||
|
||||
CardTip (conseil kiné)
|
||||
background: orange-dim
|
||||
border-left: 3px solid orange-500
|
||||
border-radius: 0 radius-lg radius-lg 0
|
||||
NE PAS arrondir le côté gauche (border-left unique)
|
||||
Structure: icône 💡 + texte + signature "— Prénom, kiné"
|
||||
|
||||
CardProgram
|
||||
border-radius: radius-xl
|
||||
overflow: hidden
|
||||
Thumbnail: 120px height, gradient navy-700→navy-600
|
||||
Body: padding 14px
|
||||
Toujours afficher: progression bar + "X/12 séances"
|
||||
```
|
||||
|
||||
### Timer
|
||||
|
||||
```
|
||||
Timer (composant le plus critique de l'app)
|
||||
font: mono 80–100px 500
|
||||
text-align: center
|
||||
|
||||
État effort normal (>10s):
|
||||
color: green-500
|
||||
|
||||
État urgence (<10s):
|
||||
color: red-500
|
||||
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
|
||||
|
||||
Label sous le chiffre:
|
||||
font: mono 14px 400
|
||||
color: slate-400
|
||||
letter-spacing: 0.1em
|
||||
text: "SECONDES"
|
||||
|
||||
Contexte repos:
|
||||
color: slate-300 (pas de vert, signal visuel de repos)
|
||||
```
|
||||
|
||||
### Progress Bar
|
||||
|
||||
```
|
||||
ProgressBar
|
||||
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
|
||||
fill: background green-500, border-radius pill
|
||||
|
||||
Variante séance (épaisseur réduite):
|
||||
height: 3px
|
||||
|
||||
Variante programme:
|
||||
height: 4px
|
||||
Afficher le % à droite en mono 11px green-500
|
||||
|
||||
Animation: transition width 300ms ease
|
||||
```
|
||||
|
||||
### Feedback ressenti
|
||||
|
||||
```
|
||||
FeedbackButton
|
||||
width: flex (3 boutons égaux)
|
||||
height: 72px
|
||||
border-radius: radius-lg
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
|
||||
Emoji: 28px
|
||||
Label: sans 12px slate-400
|
||||
|
||||
État sélectionné:
|
||||
border: 1.5px solid green-500
|
||||
background: green-dim
|
||||
```
|
||||
|
||||
### Streak hebdomadaire
|
||||
|
||||
```
|
||||
StreakDot
|
||||
width: 32px
|
||||
height: 32px
|
||||
border-radius: 50%
|
||||
|
||||
.done: background rgba(0,200,150,0.15) → afficher ✓
|
||||
.today: background green-500 → afficher ✓
|
||||
.empty: background rgba(168,178,216,0.06), border 1px border-dim
|
||||
|
||||
Label jour: mono 10px slate-400, centré sous chaque dot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Écran séance — règles spéciales
|
||||
|
||||
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
|
||||
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
|
||||
passer ce test.
|
||||
|
||||
### Architecture visuelle
|
||||
|
||||
```
|
||||
[Vidéo plein écran en boucle — fond de tout l'écran]
|
||||
↓ Gradient top navy→transparent (40% opacité, 100px height)
|
||||
→ Contrôles pause/audio en overlay
|
||||
→ Indicateur exercice X/8 centré
|
||||
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
|
||||
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
|
||||
→ Timer géant centré
|
||||
→ Progress bar
|
||||
→ Tip card conseil kiné
|
||||
```
|
||||
|
||||
### Transitions séance
|
||||
|
||||
```
|
||||
Effort → Repos:
|
||||
Fond passe de vidéo plein écran → navy-800 uni
|
||||
Transition: fade 300ms
|
||||
Vibration haptique légère (si disponible)
|
||||
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
|
||||
|
||||
Repos → Effort:
|
||||
Countdown audio "3... 2... 1..."
|
||||
Vibration haptique + transition fade
|
||||
|
||||
Exercice suivant pendant le repos:
|
||||
Afficher un thumbnail 56×56px du prochain exercice
|
||||
Nom en sans 14px 500
|
||||
Label "PROCHAIN EXERCICE" en mono 11px slate-400
|
||||
```
|
||||
|
||||
### Phase repos
|
||||
|
||||
L'écran repos doit être **visuellement différent** de l'écran effort.
|
||||
|
||||
- Fond : `navy-800` uni (plus de vidéo plein écran)
|
||||
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
|
||||
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
|
||||
- Aperçu prochain exercice centré
|
||||
|
||||
---
|
||||
|
||||
## 8. Navigation
|
||||
|
||||
```
|
||||
Tab Bar (5 onglets, fixé en bas)
|
||||
height: 56px + safe area inset
|
||||
background: navy-800
|
||||
border-top: 1px solid border-dim
|
||||
|
||||
Onglets:
|
||||
- Accueil (home icon)
|
||||
- Programmes (grid icon)
|
||||
- Minuteur (timer icon)
|
||||
- Progression (chart icon)
|
||||
- Profil (person icon)
|
||||
|
||||
Onglet actif: icône green-500 + label green-500
|
||||
Onglet inactif: icône slate-400 + label slate-400
|
||||
|
||||
Font label: sans 11px 400
|
||||
Icon size: 22×22px
|
||||
Touch target: 44×44px minimum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Animations & micro-interactions
|
||||
|
||||
```
|
||||
FadeIn:
|
||||
opacity: 0 → 1
|
||||
duration: 300ms
|
||||
easing: ease
|
||||
|
||||
SlideUp (bottom sheet, modal):
|
||||
translateY(100%) → 0
|
||||
duration: 400ms
|
||||
easing: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
|
||||
Pulse (CTA bouton, timer urgence):
|
||||
scale: 1 → 1.02 → 1
|
||||
duration: 2s
|
||||
infinite, ease-in-out
|
||||
|
||||
Bounce (célébration fin de séance):
|
||||
scale: 0.5 → 1.05 → 0.98 → 1
|
||||
duration: 600ms
|
||||
easing: spring
|
||||
|
||||
StaggerList (items qui apparaissent en séquence):
|
||||
Délai: 100ms entre chaque item
|
||||
Chaque item: FadeIn + translateY(12px→0)
|
||||
|
||||
ScalePress (tous les boutons):
|
||||
active: scale(0.97)
|
||||
duration: 100ms
|
||||
```
|
||||
|
||||
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
|
||||
vaut mieux que des micro-interactions dispersées partout.
|
||||
|
||||
---
|
||||
|
||||
## 10. Paywall — règles de design conversion
|
||||
|
||||
```
|
||||
Structure obligatoire du paywall:
|
||||
1. Célébration des accomplissements (TOUJOURS en premier)
|
||||
→ Font serif italic, emoji, stats concrètes
|
||||
2. Valeur du contenu débloqué (liste concrète)
|
||||
3. Pricing transparent (pas de dark patterns)
|
||||
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
|
||||
4. CTA principal (PrimaryButton full-width)
|
||||
5. Réassurance ("Annulation facile à tout moment")
|
||||
6. Alternative gratuite visible (GhostButton ou lien)
|
||||
|
||||
Couleur encadré pricing: CardAccent (vert)
|
||||
Bouton fermeture: TOUJOURS visible en haut à gauche
|
||||
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibilité
|
||||
|
||||
```
|
||||
Contraste texte:
|
||||
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
|
||||
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
|
||||
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
|
||||
Tous conformes WCAG AA (4.5:1 minimum requis)
|
||||
|
||||
Touch targets:
|
||||
Minimum 44×44px pour TOUS les éléments interactifs
|
||||
Espacement minimum 8px entre deux éléments interactifs adjacents
|
||||
|
||||
Timer:
|
||||
La couleur n'est pas le seul signal d'urgence
|
||||
Ajouter aussi: pulse animation + vibration haptique + signal audio
|
||||
|
||||
Audio:
|
||||
Toujours proposer une alternative visuelle à chaque signal audio
|
||||
Le toggle audio est accessible en 1 tap depuis l'écran séance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Tokens React Native / Expo
|
||||
|
||||
```typescript
|
||||
// design-tokens.ts
|
||||
export const colors = {
|
||||
// Navy
|
||||
navy900: '#0D1B2A',
|
||||
navy800: '#112240',
|
||||
navy700: '#1A3050',
|
||||
navy600: '#243C5E',
|
||||
|
||||
// Green
|
||||
green500: '#00C896',
|
||||
green600: '#00A67C',
|
||||
green700: '#00875F',
|
||||
greenDim: 'rgba(0,200,150,0.12)',
|
||||
greenBorder: 'rgba(0,200,150,0.35)',
|
||||
|
||||
// Text
|
||||
white100: '#E6F1FF',
|
||||
slate300: '#A8B2D8',
|
||||
slate400: '#8892B0',
|
||||
|
||||
// Borders
|
||||
borderDim: 'rgba(168,178,216,0.15)',
|
||||
borderHover: 'rgba(168,178,216,0.25)',
|
||||
|
||||
// Orange (tip/kine only)
|
||||
orange500: '#FF8A5C',
|
||||
orange600: '#E06A3C',
|
||||
orangeDim: 'rgba(255,138,92,0.12)',
|
||||
|
||||
// Semantic
|
||||
red500: '#FF4444', // timer urgence ONLY
|
||||
} as const
|
||||
|
||||
export const spacing = {
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
} as const
|
||||
|
||||
export const radius = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
pill: 9999,
|
||||
} as const
|
||||
|
||||
export const fontSizes = {
|
||||
caption: 12,
|
||||
label: 13,
|
||||
body: 15,
|
||||
heading2: 18,
|
||||
heading1: 22,
|
||||
display: 28,
|
||||
timer: 88, // taille par défaut du timer
|
||||
} as const
|
||||
|
||||
export const timerThreshold = 10 // secondes — passage vert → rouge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant livraison d'un écran
|
||||
|
||||
- [ ] Fond `navy-900` utilisé comme base
|
||||
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
|
||||
- [ ] Tous les touch targets ≥ 44×44px
|
||||
- [ ] Le vert n'est utilisé que pour des actions ou validations
|
||||
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
|
||||
- [ ] Le rouge n'apparaît que sur le timer en urgence
|
||||
- [ ] Timer ≥ 80px de haut sur l'écran séance
|
||||
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
|
||||
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
|
||||
- [ ] Phase repos visuellement différente de la phase effort
|
||||
- [ ] Paywall : célébration en premier, alternative gratuite visible
|
||||
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données
|
||||
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gitnexus-cli
|
||||
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
```
|
||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
```
|
||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: gitnexus-guide
|
||||
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
- "Who uses this code?"
|
||||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
2. READ gitnexus://repo/my-app/processes
|
||||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
```
|
||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
```
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,3 +52,4 @@ coverage/
|
||||
|
||||
# Node compile cache
|
||||
node-compile-cache/
|
||||
.gitnexus
|
||||
|
||||
102
AGENTS.md
102
AGENTS.md
@@ -445,3 +445,105 @@ Search results can flood context. Use `context-mode_ctx_execute(language: "shell
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/tabatago/clusters` | All functional areas |
|
||||
| `gitnexus://repo/tabatago/processes` | All execution flows |
|
||||
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
108
CLAUDE.md
108
CLAUDE.md
@@ -192,11 +192,13 @@ COMPLETE: '#30D158' // Green
|
||||
|
||||
## 🚀 Commands
|
||||
|
||||
Use `rtk` (token-optimized CLI proxy) for all non-interactive commands.
|
||||
|
||||
```bash
|
||||
npx expo start # Development
|
||||
npx expo start # Development (interactive, no rtk)
|
||||
npx expo start --tunnel # If network issues
|
||||
npx expo start --clear # Clear cache
|
||||
npx tsc --noEmit # Type check
|
||||
rtk tsc --noEmit # Type check (grouped errors)
|
||||
eas build --profile dev # Dev build
|
||||
```
|
||||
|
||||
@@ -211,3 +213,105 @@ Voir `.claude/skills/` pour les guides spécialisés.
|
||||
*Document updated: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Project: TabataFit — Apple Fitness+ for Tabata*
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/tabatago/clusters` | All functional areas |
|
||||
| `gitnexus://repo/tabatago/processes` | All execution flows |
|
||||
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
1179
TabataKine_Guide_Complet.md
Normal file
1179
TabataKine_Guide_Complet.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
admin-login.png
BIN
admin-login.png
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
86
admin-web/app/api/ai/generate-avatar/route.ts
Normal file
86
admin-web/app/api/ai/generate-avatar/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { prompt, trainerId, filename } = await request.json()
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'GEMINI_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey })
|
||||
|
||||
const config = {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
},
|
||||
responseModalities: ['IMAGE', 'TEXT'] as string[],
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContentStream({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
config,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
})
|
||||
|
||||
const images: { buffer: Buffer; mimeType: string }[] = []
|
||||
|
||||
for await (const chunk of response) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No images generated' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Save first image to storage
|
||||
const image = images[0]
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, image.buffer, {
|
||||
contentType: image.mimeType,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to upload avatar: ${error.message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return NextResponse.json({ url: publicUrl })
|
||||
} catch (error) {
|
||||
console.error('Avatar generation failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
297
admin-web/app/api/ai/generate-video/route.ts
Normal file
297
admin-web/app/api/ai/generate-video/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { GoogleGenAI, VideoGenerationReferenceType } from '@google/genai'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
interface Exercise {
|
||||
name: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface GeneratedVideo {
|
||||
exerciseName: string
|
||||
videoType: 'exercise' | 'rest'
|
||||
videoUrl: string
|
||||
videoPath: string
|
||||
durationSeconds: number
|
||||
}
|
||||
|
||||
async function pollOperation(ai: GoogleGenAI, operation: any): Promise<any> {
|
||||
while (!operation.done) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
operation = await ai.operations.getVideosOperation({ operation })
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
async function uploadVideo(
|
||||
bucket: string,
|
||||
path: string,
|
||||
buffer: Buffer
|
||||
): Promise<string> {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, buffer, {
|
||||
contentType: 'video/mp4',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to upload video: ${error.message}`)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
async function getMimeTypeFromUrl(url: string): Promise<string> {
|
||||
try {
|
||||
const urlPath = new URL(url).pathname
|
||||
const extension = urlPath.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
default:
|
||||
return 'image/png'
|
||||
}
|
||||
} catch {
|
||||
return 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
async function generateVideoWithReference(
|
||||
ai: GoogleGenAI,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
avatarUrl: string,
|
||||
durationSeconds: number = 8
|
||||
): Promise<{ uri: string; buffer: Buffer } | null> {
|
||||
// Fetch avatar image from URL and convert to base64
|
||||
const avatarResponse = await fetch(avatarUrl)
|
||||
const avatarBuffer = await avatarResponse.arrayBuffer()
|
||||
const avatarBase64 = Buffer.from(avatarBuffer).toString('base64')
|
||||
const mimeType = await getMimeTypeFromUrl(avatarUrl)
|
||||
|
||||
const operation = await ai.models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
prompt,
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
aspectRatio: '16:9',
|
||||
resolution: '1080p',
|
||||
durationSeconds,
|
||||
referenceImages: [{
|
||||
image: {
|
||||
imageBytes: avatarBase64,
|
||||
mimeType,
|
||||
},
|
||||
referenceType: 'character' as any,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const completedOperation = await pollOperation(ai, operation)
|
||||
const videoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
|
||||
if (!videoUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(`${videoUri}&key=${apiKey}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
return { uri: videoUri, buffer: videoBuffer }
|
||||
}
|
||||
|
||||
async function extendVideo(
|
||||
ai: GoogleGenAI,
|
||||
apiKey: string,
|
||||
videoUri: string,
|
||||
prompt: string,
|
||||
durationSeconds: number = 8
|
||||
): Promise<{ uri: string; buffer: Buffer } | null> {
|
||||
const operation = await ai.models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
prompt,
|
||||
video: videoUri as any,
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
durationSeconds,
|
||||
},
|
||||
})
|
||||
|
||||
const completedOperation = await pollOperation(ai, operation)
|
||||
const newVideoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
|
||||
if (!newVideoUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(`${newVideoUri}&key=${apiKey}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
return { uri: newVideoUri, buffer: videoBuffer }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { workoutId, trainerId, trainerAvatarUrl, exercises } = await request.json()
|
||||
|
||||
if (!trainerAvatarUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Trainer avatar URL is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'GEMINI_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey })
|
||||
const generatedVideos: GeneratedVideo[] = []
|
||||
|
||||
for (const exercise of exercises) {
|
||||
const exerciseName = exercise.name
|
||||
|
||||
console.log(`Processing exercise: ${exerciseName}`)
|
||||
|
||||
const safeName = exerciseName.toLowerCase().replace(/[^a-z0-9]/g, '_')
|
||||
|
||||
// 1. Generate 8s exercise video with avatar reference
|
||||
console.log(`Generating base exercise video for: ${exerciseName}`)
|
||||
const exercisePrompt = `A fitness trainer demonstrating ${exerciseName} exercise with correct form and technique. The person should be energetic and perform the exercise properly.`
|
||||
|
||||
let exerciseVideo = await generateVideoWithReference(
|
||||
ai,
|
||||
apiKey,
|
||||
exercisePrompt,
|
||||
trainerAvatarUrl,
|
||||
8
|
||||
)
|
||||
|
||||
if (!exerciseVideo) {
|
||||
console.error(`Failed to generate base exercise video for: ${exerciseName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Extend exercise video to 22s (8s + 7s + 7s)
|
||||
console.log(`Extending exercise video for: ${exerciseName}`)
|
||||
|
||||
const extendPrompt1 = `Continue the ${exerciseName} exercise motion seamlessly`
|
||||
let extended1 = await extendVideo(ai, apiKey, exerciseVideo.uri, extendPrompt1, 8)
|
||||
|
||||
if (extended1) {
|
||||
const extendPrompt2 = `Continue the ${exerciseName} exercise motion seamlessly`
|
||||
const extended2 = await extendVideo(ai, apiKey, extended1.uri, extendPrompt2, 8)
|
||||
|
||||
if (extended2) {
|
||||
exerciseVideo = extended2
|
||||
}
|
||||
}
|
||||
|
||||
const exercisePath = `${trainerId}/${workoutId}/${safeName}.mp4`
|
||||
const exerciseUrl = await uploadVideo('trainer-videos', exercisePath, exerciseVideo.buffer)
|
||||
|
||||
generatedVideos.push({
|
||||
exerciseName,
|
||||
videoType: 'exercise',
|
||||
videoUrl: exerciseUrl,
|
||||
videoPath: exercisePath,
|
||||
durationSeconds: 22,
|
||||
})
|
||||
|
||||
console.log(`Exercise video uploaded: ${exerciseUrl}`)
|
||||
|
||||
// 3. Generate 8s rest video with avatar reference
|
||||
console.log(`Generating rest video for: ${exerciseName}`)
|
||||
const restPrompt = `A fitness trainer resting, standing still, breathing calmly with arms at sides. The person looks relaxed.`
|
||||
|
||||
const restVideo = await generateVideoWithReference(
|
||||
ai,
|
||||
apiKey,
|
||||
restPrompt,
|
||||
trainerAvatarUrl,
|
||||
8
|
||||
)
|
||||
|
||||
if (restVideo) {
|
||||
const restPath = `${trainerId}/${workoutId}/${safeName}_rest.mp4`
|
||||
const restUrl = await uploadVideo('trainer-videos', restPath, restVideo.buffer)
|
||||
|
||||
generatedVideos.push({
|
||||
exerciseName,
|
||||
videoType: 'rest',
|
||||
videoUrl: restUrl,
|
||||
videoPath: restPath,
|
||||
durationSeconds: 8,
|
||||
})
|
||||
|
||||
console.log(`Rest video uploaded: ${restUrl}`)
|
||||
} else {
|
||||
console.error(`Failed to generate rest video for: ${exerciseName}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedVideos.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No videos were generated' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Save all videos to exercise_videos table
|
||||
for (const video of generatedVideos) {
|
||||
await (supabase.from('exercise_videos') as any)
|
||||
.insert({
|
||||
workout_id: workoutId,
|
||||
trainer_id: trainerId,
|
||||
exercise_name: video.exerciseName,
|
||||
video_url: video.videoUrl,
|
||||
video_path: video.videoPath,
|
||||
video_type: video.videoType,
|
||||
duration_seconds: video.durationSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// Update workout with primary video_url (first exercise video)
|
||||
const primaryVideo = generatedVideos.find(v => v.videoType === 'exercise')
|
||||
if (primaryVideo) {
|
||||
await (supabase.from('workouts') as any)
|
||||
.update({ video_url: primaryVideo.videoUrl })
|
||||
.eq('id', workoutId)
|
||||
}
|
||||
|
||||
// Mark workout as generated in program_workouts if applicable
|
||||
await (supabase.from('program_workouts') as any)
|
||||
.update({ video_generated: true })
|
||||
.eq('workout_id', workoutId)
|
||||
|
||||
return NextResponse.json({
|
||||
workoutId,
|
||||
videos: generatedVideos,
|
||||
totalVideos: generatedVideos.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Video generation failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
11
admin-web/app/programs/CLAUDE.md
Normal file
11
admin-web/app/programs/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
11
admin-web/app/programs/[id]/CLAUDE.md
Normal file
11
admin-web/app/programs/[id]/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
81
admin-web/app/programs/[id]/edit/page.tsx
Normal file
81
admin-web/app/programs/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditProgramPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditProgramPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditProgramPage({ params }: EditProgramPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Program
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Edit Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Update the details for "{program.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm initialData={program} mode="edit" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
admin-web/app/programs/[id]/page.tsx
Normal file
292
admin-web/app/programs/[id]/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Zap, Timer, Target } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
const BODY_ZONE_COLORS: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProgramDetailPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const tabatas = program.program_tabatas || []
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-4 w-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-white">{program.title}</h1>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Badge className={BODY_ZONE_COLORS[program.body_zone] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<Badge className={LEVEL_COLORS[program.level] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<p className="text-neutral-400 mt-3 max-w-2xl">{program.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">Duration</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_duration} min</p>
|
||||
<p className="text-xs text-neutral-500">estimated total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Flame className="h-4 w-4" />
|
||||
<span className="text-sm">Calories</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_calories}</p>
|
||||
<p className="text-xs text-neutral-500">estimated burn</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Dumbbell className="h-4 w-4" />
|
||||
<span className="text-sm">Tabatas</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{tabatas.length}</p>
|
||||
<p className="text-xs text-neutral-500">exercise pairs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm">Sort Order</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.sort_order}</p>
|
||||
<p className="text-xs text-neutral-500">display position</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabatas */}
|
||||
<div className="space-y-6">
|
||||
{tabatas.map((tabata: any) => (
|
||||
<Card key={tabata.id} className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
|
||||
{tabata.position}
|
||||
</div>
|
||||
<CardTitle className="text-white">Tabata {tabata.position}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-neutral-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="h-3.5 w-3.5" />
|
||||
{tabata.rounds} rounds
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{tabata.work_time}s work
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{tabata.rest_time}s rest
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Exercise 1 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-500">
|
||||
Exercise 1
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_1_name}</p>
|
||||
{tabata.exercise_1_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_1_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_1_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_tip}</p>
|
||||
{tabata.exercise_1_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_modification}</p>
|
||||
{tabata.exercise_1_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_progression}</p>
|
||||
{tabata.exercise_1_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exercise 2 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-blue-500/20 text-blue-500">
|
||||
Exercise 2
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_2_name}</p>
|
||||
{tabata.exercise_2_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_2_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_2_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_tip}</p>
|
||||
{tabata.exercise_2_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_modification}</p>
|
||||
{tabata.exercise_2_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_progression}</p>
|
||||
{tabata.exercise_2_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
admin-web/app/programs/new/CLAUDE.md
Normal file
11
admin-web/app/programs/new/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
34
admin-web/app/programs/new/page.tsx
Normal file
34
admin-web/app/programs/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Program | TabataFit Admin",
|
||||
description: "Create a new workout program",
|
||||
}
|
||||
|
||||
export default function NewProgramPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Create New Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Fill in the details below to create a new workout program with 3 tabatas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm mode="create" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
318
admin-web/app/programs/page.tsx
Normal file
318
admin-web/app/programs/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Edit, Loader2, Eye } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Database } from "@/lib/supabase";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"];
|
||||
|
||||
const BODY_ZONE_OPTIONS = [
|
||||
{ value: "all", label: "All Zones" },
|
||||
{ value: "upper-body", label: "Upper Body" },
|
||||
{ value: "lower-body", label: "Lower Body" },
|
||||
{ value: "full-body", label: "Full Body" },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "all", label: "All Levels" },
|
||||
{ value: "Beginner", label: "Beginner" },
|
||||
{ value: "Intermediate", label: "Intermediate" },
|
||||
{ value: "Advanced", label: "Advanced" },
|
||||
]
|
||||
|
||||
export default function ProgramsPage() {
|
||||
const router = useRouter();
|
||||
const [programs, setPrograms] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [programToDelete, setProgramToDelete] = useState<WorkoutProgram | null>(null);
|
||||
const [filterZone, setFilterZone] = useState("all");
|
||||
const [filterLevel, setFilterLevel] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrograms();
|
||||
}, []);
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
try {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (id)
|
||||
`)
|
||||
.order("sort_order", { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setPrograms(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch programs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (program: WorkoutProgram) => {
|
||||
setProgramToDelete(program);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!programToDelete) return;
|
||||
|
||||
setDeletingId(programToDelete.id);
|
||||
try {
|
||||
// Delete tabatas first (foreign key constraint)
|
||||
const { error: tabataError } = await (supabase.from("program_tabatas") as any)
|
||||
.delete()
|
||||
.eq("program_id", programToDelete.id);
|
||||
if (tabataError) throw tabataError;
|
||||
|
||||
const { error } = await (supabase.from("workout_programs") as any)
|
||||
.delete()
|
||||
.eq("id", programToDelete.id);
|
||||
if (error) throw error;
|
||||
|
||||
setPrograms(programs.filter((p) => p.id !== programToDelete.id));
|
||||
toast.success("Program deleted", {
|
||||
description: "The program has been removed successfully."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete program:", error);
|
||||
toast.error("Failed to delete program");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteDialogOpen(false);
|
||||
setProgramToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getBodyZoneColor = (zone: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
};
|
||||
return colors[zone] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
};
|
||||
return colors[level] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const filteredPrograms = programs.filter((p) => {
|
||||
if (filterZone !== "all" && p.body_zone !== filterZone) return false;
|
||||
if (filterLevel !== "all" && p.level !== filterLevel) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Programs</h1>
|
||||
<p className="text-neutral-400">Manage your workout programs</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600" asChild>
|
||||
<Link href="/programs/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterZone}
|
||||
onValueChange={setFilterZone}
|
||||
options={BODY_ZONE_OPTIONS}
|
||||
placeholder="Filter by zone"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterLevel}
|
||||
onValueChange={setFilterLevel}
|
||||
options={LEVEL_OPTIONS}
|
||||
placeholder="Filter by level"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
) : filteredPrograms.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500">
|
||||
{programs.length === 0
|
||||
? "No programs yet. Create your first program to get started."
|
||||
: "No programs match your filters."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-neutral-800">
|
||||
<TableHead className="text-neutral-400">Title</TableHead>
|
||||
<TableHead className="text-neutral-400">Body Zone</TableHead>
|
||||
<TableHead className="text-neutral-400">Level</TableHead>
|
||||
<TableHead className="text-neutral-400">Access</TableHead>
|
||||
<TableHead className="text-neutral-400">Duration</TableHead>
|
||||
<TableHead className="text-neutral-400">Calories</TableHead>
|
||||
<TableHead className="text-neutral-400">Tabatas</TableHead>
|
||||
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPrograms.map((program) => (
|
||||
<TableRow
|
||||
key={program.id}
|
||||
className="border-neutral-800 cursor-pointer hover:bg-neutral-800/50 transition-colors"
|
||||
onClick={() => router.push(`/programs/${program.id}`)}
|
||||
>
|
||||
<TableCell className="text-white font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
{program.title}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getBodyZoneColor(program.body_zone)}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getLevelColor(program.level)}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_duration} min</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_calories}</TableCell>
|
||||
<TableCell className="text-neutral-300">
|
||||
{program.program_tabatas?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-red-500"
|
||||
onClick={() => openDeleteDialog(program)}
|
||||
disabled={deletingId === program.id}
|
||||
>
|
||||
{deletingId === program.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-neutral-900 border-neutral-800 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Program</DialogTitle>
|
||||
<DialogDescription className="text-neutral-400">
|
||||
Are you sure you want to delete "{programToDelete?.title}"? This will also delete all associated tabatas. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!!deletingId}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
{deletingId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
admin-web/app/trainers/[id]/edit/page.tsx
Normal file
66
admin-web/app/trainers/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import TrainerForm from "@/components/trainer-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditTrainerPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getTrainer(id: string) {
|
||||
const { data, error } = await (supabase.from("trainers") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditTrainerPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const trainer = await getTrainer(resolvedParams.id)
|
||||
|
||||
if (!trainer) {
|
||||
return {
|
||||
title: "Trainer Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${trainer.name} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditTrainerPage({ params }: EditTrainerPageProps) {
|
||||
const resolvedParams = await params
|
||||
const trainer = await getTrainer(resolvedParams.id)
|
||||
|
||||
if (!trainer) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/trainers">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trainers
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Edit Trainer</h1>
|
||||
|
||||
<TrainerForm initialData={trainer} mode="edit" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
admin-web/app/trainers/new/page.tsx
Normal file
10
admin-web/app/trainers/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import TrainerForm from "@/components/trainer-form"
|
||||
|
||||
export default function NewTrainerPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Add New Trainer</h1>
|
||||
<TrainerForm mode="create" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Plus, Trash2, Edit, Loader2, Users, AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import type { Database } from "@/lib/supabase";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
|
||||
|
||||
@@ -145,12 +146,21 @@ export default function TrainersPage() {
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
|
||||
style={{ backgroundColor: trainer.color }}
|
||||
>
|
||||
{trainer.name[0]}
|
||||
</div>
|
||||
{trainer.avatar_url ? (
|
||||
<img
|
||||
src={trainer.avatar_url}
|
||||
alt={trainer.name}
|
||||
className="w-12 h-12 rounded-full object-cover border-2"
|
||||
style={{ borderColor: trainer.color }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
|
||||
style={{ backgroundColor: trainer.color }}
|
||||
>
|
||||
{trainer.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
|
||||
<p className="text-neutral-400">{trainer.specialty}</p>
|
||||
@@ -161,9 +171,11 @@ export default function TrainersPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Link href={`/trainers/${trainer.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -14,8 +14,7 @@ interface EditWorkoutPageProps {
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
const { data, error } = await (supabase.from("workouts") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
283
admin-web/app/workouts/[id]/generate-video/page.tsx
Normal file
283
admin-web/app/workouts/[id]/generate-video/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Loader2, Play, CheckCircle, AlertCircle, ArrowLeft, Video } from "lucide-react"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { getWorkoutVideoStatus } from "@/lib/ai"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type Workout = {
|
||||
id: string
|
||||
title: string
|
||||
trainer_id: string
|
||||
exercises: { name: string; duration: number }[]
|
||||
video_url: string | null
|
||||
}
|
||||
|
||||
type Trainer = {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url: string | null
|
||||
specialty: string
|
||||
}
|
||||
|
||||
export default function GenerateVideoPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workoutId = params.id as string
|
||||
|
||||
const [workout, setWorkout] = useState<Workout | null>(null)
|
||||
const [trainer, setTrainer] = useState<Trainer | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState<"idle" | "generating" | "done" | "error">("idle")
|
||||
const [videoStatus, setVideoStatus] = useState<{
|
||||
hasVideo: boolean
|
||||
videoUrl: string | null
|
||||
inProgram: boolean
|
||||
videoGenerated: boolean
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (workoutId) {
|
||||
loadData(workoutId)
|
||||
}
|
||||
}, [workoutId])
|
||||
|
||||
const loadData = async (id: string) => {
|
||||
setLoading(true)
|
||||
|
||||
const { data: w } = await (supabase.from("workouts") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (!w) {
|
||||
toast.error("Workout not found")
|
||||
router.push("/workouts")
|
||||
return
|
||||
}
|
||||
setWorkout(w)
|
||||
|
||||
if (w.trainer_id) {
|
||||
const { data: t } = await (supabase.from("trainers") as any)
|
||||
.select("*")
|
||||
.eq("id", w.trainer_id)
|
||||
.single()
|
||||
setTrainer(t)
|
||||
}
|
||||
|
||||
const videoStatus = await getWorkoutVideoStatus(id)
|
||||
setVideoStatus(videoStatus)
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!workout || !trainer) return
|
||||
|
||||
setStatus("generating")
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress(p => Math.min(p + 5, 90))
|
||||
}, 3000)
|
||||
|
||||
const response = await fetch('/api/ai/generate-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workoutId: workout.id,
|
||||
trainerId: trainer.id,
|
||||
trainerAvatarUrl: trainer.avatar_url,
|
||||
exercises: workout.exercises,
|
||||
}),
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Generation failed')
|
||||
}
|
||||
|
||||
const { videos, videoCount } = await response.json()
|
||||
|
||||
setProgress(100)
|
||||
setVideoStatus(prev => prev ? { ...prev, hasVideo: true, videoUrl: videos[0]?.videoUrl } : null)
|
||||
|
||||
if (videoStatus?.inProgram) {
|
||||
await (supabase.from("program_workouts") as any)
|
||||
.update({ video_generated: true })
|
||||
.eq("workout_id", workout.id)
|
||||
}
|
||||
|
||||
setStatus("done")
|
||||
toast.success(`Generated ${videoCount} exercise video(s)!`)
|
||||
} catch (err) {
|
||||
console.error("Generation failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate video")
|
||||
setStatus("error")
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-neutral-400">Workout not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const needsGeneration = !videoStatus?.hasVideo
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/workouts/${workout.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workout
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Generate Workout Video</h1>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-white">{workout.title}</CardTitle>
|
||||
{videoStatus?.inProgram && (
|
||||
<Badge variant="outline" className="border-amber-500 text-amber-500">
|
||||
In Program
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-neutral-800">
|
||||
{trainer?.avatar_url && (
|
||||
<img
|
||||
src={trainer.avatar_url}
|
||||
alt={trainer.name}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-white">{trainer?.name}</p>
|
||||
<p className="text-sm text-neutral-400">{trainer?.specialty}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoStatus?.inProgram && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-amber-500 font-medium">Workout is in a program</p>
|
||||
<p className="text-neutral-400">
|
||||
The video_generated flag will be set to true after generation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Exercises</h3>
|
||||
<div className="space-y-2">
|
||||
{workout.exercises?.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-8 h-8 rounded-full bg-neutral-800 flex items-center justify-center text-neutral-400 text-xs">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-neutral-300">{ex.name}</span>
|
||||
<span className="text-neutral-500 ml-auto">{ex.duration}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoStatus?.hasVideo && videoStatus.videoUrl && !generating && (
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Generated Video</h3>
|
||||
<video
|
||||
src={videoStatus.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
className="w-full max-h-80 rounded-lg border border-neutral-700"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
Video loops seamlessly (8 seconds)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsGeneration && (
|
||||
<div className="pt-4 border-t border-neutral-800">
|
||||
{status === "generating" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
|
||||
<span className="text-white">Generating video with Veo 3.1...</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-neutral-800">
|
||||
<div
|
||||
className="h-full bg-purple-500 transition-all duration-1000"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||
<Video className="w-4 h-4" />
|
||||
<span>Creating 8-second looping video with {trainer?.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
This may take up to 2 minutes. Please wait...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
className="w-full bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Generate 8s Looping Video
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "done" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-500 font-medium">Video generated and saved!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-500">Video generation failed. Check console for details.</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music } from "lucide-react"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music, Video } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -23,8 +23,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
const { data, error } = await (supabase.from("workouts") as any)
|
||||
.select(`
|
||||
*,
|
||||
trainers (name, specialty, color)
|
||||
@@ -95,6 +94,12 @@ export default async function WorkoutDetailPage({ params }: WorkoutDetailPagePro
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/workouts/${workout.id}/generate-video`}>
|
||||
<Video className="mr-2 h-4 w-4" />
|
||||
Generate Video
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/workouts/${workout.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
|
||||
12
admin-web/components/CLAUDE.md
Normal file
12
admin-web/components/CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6321 | 9:38 PM | 🟣 | Created TabataEditor component for admin workout program management | ~327 |
|
||||
| #6325 | " | 🟣 | Added Programs navigation link to admin sidebar | ~260 |
|
||||
</claude-mem-context>
|
||||
433
admin-web/components/program-form.tsx
Normal file
433
admin-web/components/program-form.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2, Save, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import TabataEditor, { TabataData } from "@/components/tabata-editor"
|
||||
import { toast } from "sonner"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"]
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
|
||||
interface ProgramFormProps {
|
||||
initialData?: WorkoutProgram & { tabatas?: ProgramTabata[] }
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
const BODY_ZONE_OPTIONS = [
|
||||
{ value: "upper-body", label: "Upper Body" },
|
||||
{ value: "lower-body", label: "Lower Body" },
|
||||
{ value: "full-body", label: "Full Body" },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "Beginner", label: "Beginner" },
|
||||
{ value: "Intermediate", label: "Intermediate" },
|
||||
{ value: "Advanced", label: "Advanced" },
|
||||
]
|
||||
|
||||
export default function ProgramForm({ initialData, mode = "create" }: ProgramFormProps) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
||||
|
||||
// Basics state
|
||||
const [title, setTitle] = React.useState(initialData?.title || "")
|
||||
const [description, setDescription] = React.useState(initialData?.description || "")
|
||||
const [bodyZone, setBodyZone] = React.useState(initialData?.body_zone || "full-body")
|
||||
const [level, setLevel] = React.useState(initialData?.level || "Beginner")
|
||||
const [isFree, setIsFree] = React.useState(initialData?.is_free || false)
|
||||
const [estimatedCalories, setEstimatedCalories] = React.useState(
|
||||
String(initialData?.estimated_calories || "")
|
||||
)
|
||||
const [icon, setIcon] = React.useState(initialData?.icon || "")
|
||||
const [accentColor, setAccentColor] = React.useState(initialData?.accent_color || "")
|
||||
const [sortOrder, setSortOrder] = React.useState(
|
||||
String(initialData?.sort_order ?? "0")
|
||||
)
|
||||
|
||||
// Tabatas state
|
||||
const [tabatas, setTabatas] = React.useState<TabataData[]>(() => {
|
||||
if (initialData?.tabatas && initialData.tabatas.length > 0) {
|
||||
return initialData.tabatas
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((t) => ({
|
||||
position: t.position,
|
||||
exercise_1_name: t.exercise_1_name || "",
|
||||
exercise_1_name_en: t.exercise_1_name_en || "",
|
||||
exercise_1_tip: t.exercise_1_tip || "",
|
||||
exercise_1_tip_en: t.exercise_1_tip_en || "",
|
||||
exercise_1_modification: t.exercise_1_modification || "",
|
||||
exercise_1_modification_en: t.exercise_1_modification_en || "",
|
||||
exercise_1_progression: t.exercise_1_progression || "",
|
||||
exercise_1_progression_en: t.exercise_1_progression_en || "",
|
||||
exercise_2_name: t.exercise_2_name || "",
|
||||
exercise_2_name_en: t.exercise_2_name_en || "",
|
||||
exercise_2_tip: t.exercise_2_tip || "",
|
||||
exercise_2_tip_en: t.exercise_2_tip_en || "",
|
||||
exercise_2_modification: t.exercise_2_modification || "",
|
||||
exercise_2_modification_en: t.exercise_2_modification_en || "",
|
||||
exercise_2_progression: t.exercise_2_progression || "",
|
||||
exercise_2_progression_en: t.exercise_2_progression_en || "",
|
||||
rounds: t.rounds || 8,
|
||||
work_time: t.work_time || 20,
|
||||
rest_time: t.rest_time || 10,
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{ position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
]
|
||||
})
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!title.trim()) newErrors.title = "Title is required"
|
||||
|
||||
tabatas.forEach((tabata, i) => {
|
||||
if (!tabata.exercise_1_name.trim()) {
|
||||
newErrors[`tabata_${i + 1}_ex1`] = `Tabata ${i + 1}: Exercise 1 name is required`
|
||||
}
|
||||
if (!tabata.exercise_2_name.trim()) {
|
||||
newErrors[`tabata_${i + 1}_ex2`] = `Tabata ${i + 1}: Exercise 2 name is required`
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Calculate total estimated duration from tabatas
|
||||
const totalSeconds = tabatas.reduce(
|
||||
(sum, t) => sum + t.rounds * (t.work_time + t.rest_time),
|
||||
0
|
||||
)
|
||||
const estimatedDuration = Math.ceil(totalSeconds / 60)
|
||||
|
||||
const programData = {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
body_zone: bodyZone as WorkoutProgram["body_zone"],
|
||||
level: level as WorkoutProgram["level"],
|
||||
is_free: isFree,
|
||||
estimated_duration: estimatedDuration,
|
||||
estimated_calories: parseInt(estimatedCalories) || 0,
|
||||
icon: icon.trim() || null,
|
||||
accent_color: accentColor.trim() || null,
|
||||
sort_order: parseInt(sortOrder) || 0,
|
||||
}
|
||||
|
||||
let programId: string
|
||||
|
||||
if (mode === "edit" && initialData) {
|
||||
const result = await (supabase.from("workout_programs") as any)
|
||||
.update(programData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (result.error) throw result.error
|
||||
programId = initialData.id
|
||||
} else {
|
||||
const result = await (supabase.from("workout_programs") as any)
|
||||
.insert(programData)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (result.error) throw result.error
|
||||
programId = result.data.id
|
||||
}
|
||||
|
||||
// Upsert tabatas
|
||||
for (const tabata of tabatas) {
|
||||
const tabataPayload = {
|
||||
program_id: programId,
|
||||
position: tabata.position,
|
||||
exercise_1_name: tabata.exercise_1_name.trim(),
|
||||
exercise_1_name_en: tabata.exercise_1_name_en.trim() || null,
|
||||
exercise_1_tip: tabata.exercise_1_tip.trim() || null,
|
||||
exercise_1_tip_en: tabata.exercise_1_tip_en.trim() || null,
|
||||
exercise_1_modification: tabata.exercise_1_modification.trim() || null,
|
||||
exercise_1_modification_en: tabata.exercise_1_modification_en.trim() || null,
|
||||
exercise_1_progression: tabata.exercise_1_progression.trim() || null,
|
||||
exercise_1_progression_en: tabata.exercise_1_progression_en.trim() || null,
|
||||
exercise_2_name: tabata.exercise_2_name.trim(),
|
||||
exercise_2_name_en: tabata.exercise_2_name_en.trim() || null,
|
||||
exercise_2_tip: tabata.exercise_2_tip.trim() || null,
|
||||
exercise_2_tip_en: tabata.exercise_2_tip_en.trim() || null,
|
||||
exercise_2_modification: tabata.exercise_2_modification.trim() || null,
|
||||
exercise_2_modification_en: tabata.exercise_2_modification_en.trim() || null,
|
||||
exercise_2_progression: tabata.exercise_2_progression.trim() || null,
|
||||
exercise_2_progression_en: tabata.exercise_2_progression_en.trim() || null,
|
||||
rounds: tabata.rounds,
|
||||
work_time: tabata.work_time,
|
||||
rest_time: tabata.rest_time,
|
||||
}
|
||||
|
||||
// In edit mode, check if tabata exists for this position
|
||||
if (mode === "edit") {
|
||||
const existing = initialData?.tabatas?.find((t) => t.position === tabata.position)
|
||||
if (existing) {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.update(tabataPayload)
|
||||
.eq("id", existing.id)
|
||||
if (error) throw error
|
||||
} else {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.insert(tabataPayload)
|
||||
if (error) throw error
|
||||
}
|
||||
} else {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.insert(tabataPayload)
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(mode === "edit" ? "Program updated" : "Program created", {
|
||||
description: `"${title}" has been ${mode === "edit" ? "updated" : "created"} successfully.`
|
||||
})
|
||||
|
||||
router.push(`/programs/${programId}`)
|
||||
} catch (err) {
|
||||
console.error("Failed to save program:", err)
|
||||
toast.error("Failed to save program. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (mode === "edit" && initialData) {
|
||||
router.push(`/programs/${initialData.id}`)
|
||||
} else {
|
||||
router.push("/programs")
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabataChange = (position: number, data: TabataData) => {
|
||||
setTabatas((prev) =>
|
||||
prev.map((t) => (t.position === position ? { ...data, position } : t))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Tabs defaultValue="basics" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 bg-neutral-900">
|
||||
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
|
||||
Basics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tabatas" className="data-[state=active]:bg-neutral-800">
|
||||
Tabatas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Basics */}
|
||||
<TabsContent value="basics" className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Program Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Full Body Blast"
|
||||
className={cn(errors.title && "border-red-500")}
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-500">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe this program..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bodyZone">Body Zone *</Label>
|
||||
<Select
|
||||
id="bodyZone"
|
||||
value={bodyZone}
|
||||
onValueChange={(value) => setBodyZone(value as typeof bodyZone)}
|
||||
options={BODY_ZONE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="level">Level *</Label>
|
||||
<Select
|
||||
id="level"
|
||||
value={level}
|
||||
onValueChange={(value) => setLevel(value as typeof level)}
|
||||
options={LEVEL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="isFree" className="text-base">
|
||||
Free Program
|
||||
</Label>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Make this program available to free users
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="isFree" checked={isFree} onCheckedChange={setIsFree} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimatedCalories">Estimated Calories</Label>
|
||||
<Input
|
||||
id="estimatedCalories"
|
||||
type="number"
|
||||
value={estimatedCalories}
|
||||
onChange={(e) => setEstimatedCalories(e.target.value)}
|
||||
min={0}
|
||||
placeholder="e.g., 120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
min={0}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">Icon Name</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="e.g., flame, dumbbell"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accentColor">Accent Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="accentColor"
|
||||
value={accentColor}
|
||||
onChange={(e) => setAccentColor(e.target.value)}
|
||||
placeholder="#FF6B35"
|
||||
className="flex-1"
|
||||
/>
|
||||
{accentColor && (
|
||||
<div
|
||||
className="h-10 w-10 rounded-md border border-neutral-700 flex-shrink-0"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Tabatas */}
|
||||
<TabsContent value="tabatas" className="space-y-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Each program has 3 tabatas (exercise pairs). Every tabata alternates between two exercises for the specified number of rounds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.tabata_1_ex1 && (
|
||||
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
|
||||
{errors.tabata_1_ex1}
|
||||
</p>
|
||||
)}
|
||||
{errors.tabata_1_ex2 && (
|
||||
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
|
||||
{errors.tabata_1_ex2}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{([1, 2, 3] as const).map((pos) => {
|
||||
const tabata = tabatas.find((t) => t.position === pos) || null
|
||||
return (
|
||||
<TabataEditor
|
||||
key={pos}
|
||||
tabata={tabata}
|
||||
position={pos}
|
||||
onChange={(data) => handleTabataChange(pos, data)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="border-neutral-700 text-neutral-400 hover:bg-neutral-800 hover:text-white"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{mode === "edit" ? "Update Program" : "Create Program"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
Music,
|
||||
LogOut,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/workouts", label: "Workouts", icon: Dumbbell },
|
||||
{ href: "/programs", label: "Programs", icon: LayoutGrid },
|
||||
{ href: "/trainers", label: "Trainers", icon: Users },
|
||||
{ href: "/collections", label: "Collections", icon: FolderOpen },
|
||||
{ href: "/media", label: "Media", icon: ImageIcon },
|
||||
|
||||
296
admin-web/components/tabata-editor.tsx
Normal file
296
admin-web/components/tabata-editor.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDown, ChevronRight, Clock, Dumbbell } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
|
||||
interface TabataData {
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en: string
|
||||
exercise_1_tip: string
|
||||
exercise_1_tip_en: string
|
||||
exercise_1_modification: string
|
||||
exercise_1_modification_en: string
|
||||
exercise_1_progression: string
|
||||
exercise_1_progression_en: string
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string
|
||||
exercise_2_tip: string
|
||||
exercise_2_tip_en: string
|
||||
exercise_2_modification: string
|
||||
exercise_2_modification_en: string
|
||||
exercise_2_progression: string
|
||||
exercise_2_progression_en: string
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
}
|
||||
|
||||
export type { TabataData }
|
||||
|
||||
interface TabataEditorProps {
|
||||
tabata: TabataData | null
|
||||
position: 1 | 2 | 3
|
||||
onChange: (data: TabataData) => void
|
||||
}
|
||||
|
||||
function getDefaultTabata(position: number): TabataData {
|
||||
return {
|
||||
position,
|
||||
exercise_1_name: "",
|
||||
exercise_1_name_en: "",
|
||||
exercise_1_tip: "",
|
||||
exercise_1_tip_en: "",
|
||||
exercise_1_modification: "",
|
||||
exercise_1_modification_en: "",
|
||||
exercise_1_progression: "",
|
||||
exercise_1_progression_en: "",
|
||||
exercise_2_name: "",
|
||||
exercise_2_name_en: "",
|
||||
exercise_2_tip: "",
|
||||
exercise_2_tip_en: "",
|
||||
exercise_2_modification: "",
|
||||
exercise_2_modification_en: "",
|
||||
exercise_2_progression: "",
|
||||
exercise_2_progression_en: "",
|
||||
rounds: 8,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
}
|
||||
}
|
||||
|
||||
interface ExerciseSectionProps {
|
||||
label: string
|
||||
number: 1 | 2
|
||||
data: TabataData
|
||||
onChange: (field: string, value: string) => void
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
function ExerciseSection({ label, number, data, onChange, errors }: ExerciseSectionProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(true)
|
||||
const prefix = `exercise_${number}` as const
|
||||
|
||||
const nameField = `${prefix}_name` as keyof TabataData
|
||||
const nameValue = data[nameField] as string
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 text-sm font-medium text-white hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 text-neutral-500" /> : <ChevronRight className="h-4 w-4 text-neutral-500" />}
|
||||
<Dumbbell className="h-4 w-4 text-orange-500" />
|
||||
<span>{label}</span>
|
||||
{nameValue && (
|
||||
<span className="text-neutral-500 font-normal">- {nameValue}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-neutral-800">
|
||||
{/* Name fields */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
|
||||
<Input
|
||||
value={data[nameField] as string}
|
||||
onChange={(e) => onChange(`${prefix}_name`, e.target.value)}
|
||||
placeholder="e.g., Squats"
|
||||
className={cn("h-9 text-sm", errors[`${prefix}_name`] && "border-red-500")}
|
||||
/>
|
||||
{errors[`${prefix}_name`] && <p className="text-xs text-red-500">{errors[`${prefix}_name`]}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_name_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_name_en`, e.target.value)}
|
||||
placeholder="e.g., Squats"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_tip` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_tip`, e.target.value)}
|
||||
placeholder="Conseil en français"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_tip_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_tip_en`, e.target.value)}
|
||||
placeholder="Tip in English"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modification fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Modification (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_modification` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_modification`, e.target.value)}
|
||||
placeholder="Version plus facile"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Modification (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_modification_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_modification_en`, e.target.value)}
|
||||
placeholder="Easier variation"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progression fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Progression (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_progression` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_progression`, e.target.value)}
|
||||
placeholder="Version plus difficile"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Progression (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_progression_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_progression_en`, e.target.value)}
|
||||
placeholder="Harder variation"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TabataEditor({ tabata, position, onChange }: TabataEditorProps) {
|
||||
const data = tabata || getDefaultTabata(position)
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const updated = { ...data, [field]: value }
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const handleTimingChange = (field: "rounds" | "work_time" | "rest_time", value: string) => {
|
||||
const numValue = parseInt(value) || 0
|
||||
const updated = { ...data, [field]: numValue }
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-700 bg-neutral-900 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-neutral-800/50 border-b border-neutral-700">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
|
||||
{position}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Tabata {position}</h3>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{data.exercise_1_name && data.exercise_2_name
|
||||
? `${data.exercise_1_name} / ${data.exercise_2_name}`
|
||||
: "Configure exercises and timing"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-neutral-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{data.rounds * (data.work_time + data.rest_time)}s total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Exercise sections */}
|
||||
<ExerciseSection
|
||||
label="Exercise 1"
|
||||
number={1}
|
||||
data={data}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<ExerciseSection
|
||||
label="Exercise 2"
|
||||
number={2}
|
||||
data={data}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
{/* Timing */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-white">Timing</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Rounds</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.rounds}
|
||||
onChange={(e) => handleTimingChange("rounds", e.target.value)}
|
||||
min={1}
|
||||
max={20}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Work (sec)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.work_time}
|
||||
onChange={(e) => handleTimingChange("work_time", e.target.value)}
|
||||
min={1}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Rest (sec)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.rest_time}
|
||||
onChange={(e) => handleTimingChange("rest_time", e.target.value)}
|
||||
min={0}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
352
admin-web/components/trainer-form.tsx
Normal file
352
admin-web/components/trainer-form.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2, Sparkles, Save, X, Upload } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type Trainer = Database["public"]["Tables"]["trainers"]["Row"]
|
||||
type TrainerInsert = Database["public"]["Tables"]["trainers"]["Insert"]
|
||||
type TrainerUpdate = Database["public"]["Tables"]["trainers"]["Update"]
|
||||
|
||||
interface TrainerFormProps {
|
||||
initialData?: Trainer
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
const SPECIALTY_OPTIONS = [
|
||||
{ value: "Core", label: "Core" },
|
||||
{ value: "Strength", label: "Strength" },
|
||||
{ value: "Cardio", label: "Cardio" },
|
||||
{ value: "Full Body", label: "Full Body" },
|
||||
{ value: "Recovery", label: "Recovery" },
|
||||
]
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#FF6B35", "#FFD60A", "#30D158", "#5AC8FA", "#BF5AF2",
|
||||
"#FF9500", "#FF2D55", "#5856D6", "#FF3B30", "#34C759",
|
||||
]
|
||||
|
||||
export default function TrainerForm({ initialData, mode = "create" }: TrainerFormProps) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [generatedImages, setGeneratedImages] = useState<{ buffer: Buffer; url?: string }[]>([])
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [generatingPrompt, setGeneratingPrompt] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
|
||||
const [name, setName] = useState(initialData?.name || "")
|
||||
const [specialty, setSpecialty] = useState(initialData?.specialty || "Core")
|
||||
const [color, setColor] = useState(initialData?.color || "#FF6B35")
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialData?.avatar_url || "")
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!name.trim()) newErrors.name = "Name is required"
|
||||
if (!specialty) newErrors.specialty = "Specialty is required"
|
||||
if (!color) newErrors.color = "Color is required"
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Please enter a name first")
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = generatingPrompt ||
|
||||
`Professional portrait photo of ${name}, fitness trainer, friendly smile, gym setting, high quality, facing camera directly, energetic and professional`
|
||||
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const trainerId = initialData?.id || "new-trainer"
|
||||
const filename = `avatar_${Date.now()}.png`
|
||||
|
||||
const response = await fetch('/api/ai/generate-avatar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, trainerId, filename }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Generation failed')
|
||||
}
|
||||
|
||||
const { url } = await response.json()
|
||||
|
||||
const savedImages = [{ buffer: Buffer.alloc(0), url }]
|
||||
setGeneratedImages(savedImages)
|
||||
setSelectedImage(url)
|
||||
setAvatarUrl(url)
|
||||
toast.success('Avatar generated')
|
||||
} catch (err) {
|
||||
console.error("Generation failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate images")
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectGenerated = (url: string) => {
|
||||
setSelectedImage(url)
|
||||
setAvatarUrl(url)
|
||||
setAvatarFile(null)
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setAvatarFile(file)
|
||||
setSelectedImage(null)
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const trainerId = initialData?.id || "new-trainer"
|
||||
const filename = `upload_${Date.now()}.png`
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, file, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
setAvatarUrl(publicUrl)
|
||||
setGeneratedImages([])
|
||||
toast.success("Avatar uploaded")
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload avatar")
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const trainerData = {
|
||||
name: name.trim(),
|
||||
specialty,
|
||||
color,
|
||||
avatar_url: avatarUrl || null,
|
||||
workout_count: initialData?.workout_count || 0,
|
||||
}
|
||||
|
||||
let result
|
||||
if (mode === "edit" && initialData) {
|
||||
result = await (supabase
|
||||
.from("trainers") as any)
|
||||
.update(trainerData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
} else {
|
||||
result = await (supabase
|
||||
.from("trainers") as any)
|
||||
.insert(trainerData)
|
||||
.select()
|
||||
.single()
|
||||
}
|
||||
|
||||
if (result.error) throw result.error
|
||||
|
||||
toast.success(mode === "edit" ? "Trainer updated" : "Trainer created")
|
||||
router.push("/trainers")
|
||||
} catch (err) {
|
||||
console.error("Failed to save:", err)
|
||||
toast.error("Failed to save trainer")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Basic Information</h2>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Félia"
|
||||
className={cn(errors.name && "border-red-500")}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="specialty">Specialty *</Label>
|
||||
<Select
|
||||
id="specialty"
|
||||
value={specialty}
|
||||
onValueChange={setSpecialty}
|
||||
options={SPECIALTY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full border-2 transition-all",
|
||||
color === c ? "border-white scale-110" : "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded-full cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Trainer Avatar</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Generate with AI (Nano Banana Pro)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={generatingPrompt}
|
||||
onChange={(e) => setGeneratingPrompt(e.target.value)}
|
||||
placeholder={`Portrait of ${name || 'trainer'}, fitness, friendly...`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerateImages}
|
||||
disabled={isGenerating || !name.trim()}
|
||||
className="bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Select Generated Image</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{generatedImages.map((img, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => img.url && handleSelectGenerated(img.url)}
|
||||
className={cn(
|
||||
"relative aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all",
|
||||
selectedImage === img.url ? "border-orange-500 scale-105" : "border-transparent hover:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{img.url && <img src={img.url} alt={`Generated ${i + 1}`} className="w-full h-full object-cover" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Or Upload Custom Image</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
||||
disabled={isUploading}
|
||||
className="border-neutral-700"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Choose File
|
||||
</Button>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{avatarFile && <span className="text-sm text-neutral-400">{avatarFile.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avatarUrl && (
|
||||
<div className="space-y-2">
|
||||
<Label>Current Avatar</Label>
|
||||
<div className="relative w-32 h-32">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Current avatar"
|
||||
className="w-full h-full object-cover rounded-full border-2 border-neutral-700"
|
||||
/>
|
||||
{selectedImage === avatarUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
|
||||
<Sparkles className="w-8 h-8 text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="border-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} className="bg-orange-500 hover:bg-orange-600">
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{mode === "edit" ? "Update Trainer" : "Create Trainer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -163,15 +163,13 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
|
||||
|
||||
let result
|
||||
if (mode === "edit" && initialData) {
|
||||
result = await supabase
|
||||
.from("workouts")
|
||||
result = await (supabase.from("workouts") as any)
|
||||
.update(workoutData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
} else {
|
||||
result = await supabase
|
||||
.from("workouts")
|
||||
result = await (supabase.from("workouts") as any)
|
||||
.insert(workoutData)
|
||||
.select()
|
||||
.single()
|
||||
@@ -199,8 +197,7 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await supabase
|
||||
.from("workouts")
|
||||
await (supabase.from("workouts") as any)
|
||||
.update(updateData)
|
||||
.eq("id", result.data.id)
|
||||
}
|
||||
|
||||
15
admin-web/lib/CLAUDE.md
Normal file
15
admin-web/lib/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6329 | 9:39 PM | 🔄 | Refactored admin AI library to remove legacy program_workouts table references | ~356 |
|
||||
| #6328 | " | 🔄 | Removed ProgramWorkoutRow type definition from admin AI integration | ~364 |
|
||||
| #6327 | 9:38 PM | 🔵 | Identified legacy database schema references in admin AI library | ~316 |
|
||||
| #6326 | " | 🔵 | Discovered AI integration file with legacy database schema references | ~336 |
|
||||
| #6324 | " | 🔄 | Updated Supabase database type definitions for workout program schema | ~457 |
|
||||
</claude-mem-context>
|
||||
171
admin-web/lib/ai.ts
Normal file
171
admin-web/lib/ai.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { supabase } from './supabase'
|
||||
import type { Database } from './supabase'
|
||||
|
||||
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
|
||||
|
||||
function getAI(): GoogleGenAI {
|
||||
const apiKey = process.env['GEMINI_API_KEY']
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is not set')
|
||||
}
|
||||
return new GoogleGenAI({ apiKey })
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VideoGenerationResult {
|
||||
workoutId: string
|
||||
trainerId: string
|
||||
videoUrl: string
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
export async function generateTrainerAvatar(
|
||||
prompt: string,
|
||||
_trainerName: string
|
||||
): Promise<GeneratedImage[]> {
|
||||
const config = {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
personGeneration: 'ALLOW_ADULT',
|
||||
},
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
}
|
||||
|
||||
const response = await getAI().models.generateContentStream({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
config,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
})
|
||||
|
||||
const images: GeneratedImage[] = []
|
||||
|
||||
for await (const chunk of response) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
export async function saveAvatarToStorage(
|
||||
trainerId: string,
|
||||
imageBuffer: Buffer,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, imageBuffer, {
|
||||
contentType: 'image/png',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload avatar: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
export async function generateWorkoutVideo(
|
||||
workoutId: string,
|
||||
trainerId: string,
|
||||
_trainerAvatarUrl: string,
|
||||
workoutTitle: string,
|
||||
exercises: { name: string; duration: number }[]
|
||||
): Promise<VideoGenerationResult> {
|
||||
const exerciseList = exercises.map(e => e.name).join(', ')
|
||||
|
||||
const prompt = `${workoutTitle}. Exercises: ${exerciseList}. The video must loop seamlessly - start and end in the exact same standing position with arms at sides. Person should appear energetic and properly demonstrate each exercise. 16:9 aspect ratio.`
|
||||
|
||||
const operation = await getAI().models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
source: { prompt },
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
aspectRatio: '16:9',
|
||||
resolution: '1080p',
|
||||
personGeneration: 'dont_allow',
|
||||
durationSeconds: 8,
|
||||
},
|
||||
})
|
||||
|
||||
let currentOperation = operation
|
||||
while (!currentOperation.done) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
currentOperation = await getAI().operations.getVideosOperation({ operation: currentOperation })
|
||||
}
|
||||
|
||||
const videoUri = currentOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
if (!videoUri) throw new Error('No video generated')
|
||||
|
||||
const response = await fetch(`${videoUri}&key=${process.env['GEMINI_API_KEY']}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const videoPath = `${trainerId}/${workoutId}.mp4`
|
||||
const { error } = await supabase.storage
|
||||
.from('trainers-videos')
|
||||
.upload(videoPath, videoBuffer, {
|
||||
contentType: 'video/mp4',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload video: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainers-videos')
|
||||
.getPublicUrl(videoPath)
|
||||
|
||||
return { workoutId, trainerId, videoUrl: publicUrl, videoPath }
|
||||
}
|
||||
|
||||
export async function isWorkoutInProgram(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — workout programs now use program_tabatas
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isVideoGeneratedForWorkout(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — video status tracked in workouts table
|
||||
return false
|
||||
}
|
||||
|
||||
export async function getWorkoutVideoStatus(workoutId: string): Promise<{
|
||||
hasVideo: boolean
|
||||
videoUrl: string | null
|
||||
inProgram: boolean
|
||||
videoGenerated: boolean
|
||||
}> {
|
||||
const workoutResult = await supabase
|
||||
.from('workouts')
|
||||
.select('video_url')
|
||||
.eq('id', workoutId)
|
||||
.single()
|
||||
|
||||
const workoutVideoUrl = (workoutResult.data as WorkoutRow | null)?.video_url ?? null
|
||||
|
||||
return {
|
||||
hasVideo: !!workoutVideoUrl,
|
||||
videoUrl: workoutVideoUrl,
|
||||
inProgram: false,
|
||||
videoGenerated: false,
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,138 @@ export interface Database {
|
||||
sort_order: number
|
||||
}
|
||||
}
|
||||
workout_programs: {
|
||||
Row: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free: boolean
|
||||
estimated_duration: number
|
||||
estimated_calories: number
|
||||
icon: string | null
|
||||
accent_color: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
title: string
|
||||
description?: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free?: boolean
|
||||
estimated_duration?: number
|
||||
estimated_calories?: number
|
||||
icon?: string | null
|
||||
accent_color?: string | null
|
||||
sort_order?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_programs']['Insert'], 'id'>>
|
||||
}
|
||||
program_tabatas: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en: string | null
|
||||
exercise_1_tip: string | null
|
||||
exercise_1_tip_en: string | null
|
||||
exercise_1_modification: string | null
|
||||
exercise_1_modification_en: string | null
|
||||
exercise_1_progression: string | null
|
||||
exercise_1_progression_en: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string | null
|
||||
exercise_2_tip: string | null
|
||||
exercise_2_tip_en: string | null
|
||||
exercise_2_modification: string | null
|
||||
exercise_2_modification_en: string | null
|
||||
exercise_2_progression: string | null
|
||||
exercise_2_progression_en: string | null
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en?: string | null
|
||||
exercise_1_tip?: string | null
|
||||
exercise_1_tip_en?: string | null
|
||||
exercise_1_modification?: string | null
|
||||
exercise_1_modification_en?: string | null
|
||||
exercise_1_progression?: string | null
|
||||
exercise_1_progression_en?: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en?: string | null
|
||||
exercise_2_tip?: string | null
|
||||
exercise_2_tip_en?: string | null
|
||||
exercise_2_modification?: string | null
|
||||
exercise_2_modification_en?: string | null
|
||||
exercise_2_progression?: string | null
|
||||
exercise_2_progression_en?: string | null
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
}
|
||||
}
|
||||
exercise_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type: 'exercise' | 'rest'
|
||||
duration_seconds: number | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
}
|
||||
admin_users: {
|
||||
Row: {
|
||||
id: string
|
||||
|
||||
80
admin-web/migrations/001_reset_trainers.sql
Normal file
80
admin-web/migrations/001_reset_trainers.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Migration Script: Reset trainers to Félia and Félix only
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
felia_id UUID;
|
||||
felix_id UUID;
|
||||
old_trainer_ids UUID[];
|
||||
BEGIN
|
||||
-- Step 1: Get IDs of trainers to be replaced
|
||||
SELECT ARRAY[
|
||||
(SELECT id FROM trainers WHERE name = 'Emma'),
|
||||
(SELECT id FROM trainers WHERE name = 'Jake'),
|
||||
(SELECT id FROM trainers WHERE name = 'Mia'),
|
||||
(SELECT id FROM trainers WHERE name = 'Alex'),
|
||||
(SELECT id FROM trainers WHERE name = 'Sofia')
|
||||
] INTO old_trainer_ids;
|
||||
|
||||
-- Step 2: Create Félia if not exists
|
||||
SELECT COALESCE(
|
||||
(SELECT id FROM trainers WHERE name = 'Félia' LIMIT 1),
|
||||
gen_random_uuid()
|
||||
) INTO felia_id;
|
||||
|
||||
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
|
||||
VALUES (felia_id, 'Félia', 'Core', '#30D158', 0, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = 'Félia',
|
||||
specialty = 'Core',
|
||||
color = '#30D158';
|
||||
|
||||
-- Step 3: Create Félix if not exists
|
||||
SELECT COALESCE(
|
||||
(SELECT id FROM trainers WHERE name = 'Félix' LIMIT 1),
|
||||
gen_random_uuid()
|
||||
) INTO felix_id;
|
||||
|
||||
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
|
||||
VALUES (felix_id, 'Félix', 'Strength', '#FFD60A', 0, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = 'Félix',
|
||||
specialty = 'Strength',
|
||||
color = '#FFD60A';
|
||||
|
||||
-- Step 4: Get the final trainer IDs
|
||||
SELECT id INTO felia_id FROM trainers WHERE name = 'Félia';
|
||||
SELECT id INTO felix_id FROM trainers WHERE name = 'Félix';
|
||||
|
||||
-- Step 5: Update workouts - female trainers → felia, male trainers → felix
|
||||
UPDATE workouts
|
||||
SET trainer_id = felia_id
|
||||
WHERE trainer_id IN (
|
||||
SELECT id FROM trainers WHERE name IN ('Emma', 'Mia', 'Sofia')
|
||||
);
|
||||
|
||||
UPDATE workouts
|
||||
SET trainer_id = felix_id
|
||||
WHERE trainer_id IN (
|
||||
SELECT id FROM trainers WHERE name IN ('Jake', 'Alex')
|
||||
);
|
||||
|
||||
-- Step 6: Update workout counts
|
||||
UPDATE trainers SET workout_count = (
|
||||
SELECT COUNT(*) FROM workouts WHERE trainer_id = felia_id
|
||||
) WHERE id = felia_id;
|
||||
|
||||
UPDATE trainers SET workout_count = (
|
||||
SELECT COUNT(*) FROM workouts WHERE trainer_id = felix_id
|
||||
) WHERE id = felix_id;
|
||||
|
||||
-- Step 7: Delete old trainers
|
||||
DELETE FROM trainers WHERE name IN ('Emma', 'Jake', 'Mia', 'Alex', 'Sofia');
|
||||
END $$;
|
||||
|
||||
-- Step 8: Verify
|
||||
SELECT 'Trainers:' AS result;
|
||||
SELECT id::text, name, specialty, color, workout_count FROM trainers ORDER BY name;
|
||||
|
||||
SELECT 'Workout trainer distribution:' AS result;
|
||||
SELECT trainer_id::text, COUNT(*) as workout_count FROM workouts GROUP BY trainer_id;
|
||||
60
admin-web/migrations/002_storage_policies.sql
Normal file
60
admin-web/migrations/002_storage_policies.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Storage RLS Policies for Trainer Assets
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
-- Enable RLS on storage buckets (if not already enabled)
|
||||
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Trainer Avatars Bucket Policies
|
||||
DROP POLICY IF EXISTS "Public read access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public upload access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public update access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public delete access" ON storage.objects;
|
||||
|
||||
-- Read access (anyone can view)
|
||||
CREATE POLICY "Public read access" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Upload access (anyone can upload)
|
||||
CREATE POLICY "Public upload access" ON storage.objects
|
||||
FOR INSERT WITH CHECK (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Update access (anyone can update)
|
||||
CREATE POLICY "Public update access" ON storage.objects
|
||||
FOR UPDATE USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Delete access (anyone can delete)
|
||||
CREATE POLICY "Public delete access" ON storage.objects
|
||||
FOR DELETE USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Trainer Videos Bucket Policies
|
||||
DROP POLICY IF EXISTS "Public read access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public upload access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public update access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public delete access videos" ON storage.objects;
|
||||
|
||||
-- Read access (anyone can view)
|
||||
CREATE POLICY "Public read access videos" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Upload access (anyone can upload)
|
||||
CREATE POLICY "Public upload access videos" ON storage.objects
|
||||
FOR INSERT WITH CHECK (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Update access (anyone can update)
|
||||
CREATE POLICY "Public update access videos" ON storage.objects
|
||||
FOR UPDATE USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Delete access (anyone can delete)
|
||||
CREATE POLICY "Public delete access videos" ON storage.objects
|
||||
FOR DELETE USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Exercise Videos Table Policies (if table exists)
|
||||
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
|
||||
|
||||
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
|
||||
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
|
||||
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);
|
||||
24
admin-web/migrations/003_create_exercise_videos.sql
Normal file
24
admin-web/migrations/003_create_exercise_videos.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Create exercise_videos table
|
||||
CREATE TABLE IF NOT EXISTS exercise_videos (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
workout_id UUID NOT NULL,
|
||||
trainer_id UUID NOT NULL,
|
||||
exercise_name TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
video_path TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE exercise_videos ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public policies
|
||||
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
|
||||
|
||||
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
|
||||
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
|
||||
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);
|
||||
11
admin-web/migrations/004_add_video_type_column.sql
Normal file
11
admin-web/migrations/004_add_video_type_column.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Add video_type and duration_seconds columns to exercise_videos table
|
||||
ALTER TABLE exercise_videos
|
||||
ADD COLUMN IF NOT EXISTS video_type TEXT DEFAULT 'exercise'
|
||||
CHECK (video_type IN ('exercise', 'rest'));
|
||||
|
||||
ALTER TABLE exercise_videos
|
||||
ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
|
||||
|
||||
-- Update existing rows to have appropriate values
|
||||
UPDATE exercise_videos SET video_type = 'exercise' WHERE video_type IS NULL;
|
||||
UPDATE exercise_videos SET duration_seconds = 8 WHERE duration_seconds IS NULL;
|
||||
127
admin-web/migrations/005_kine_programs.sql
Normal file
127
admin-web/migrations/005_kine_programs.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- Tabata Kine Programs Schema
|
||||
-- Migration 005: New kiné program system
|
||||
-- Replaces the old 3-program model with 4 kiné programs
|
||||
|
||||
-- ─── Kine Programs ──────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.kine_programs (
|
||||
id TEXT PRIMARY KEY, -- 'debutant', 'intermediaire', 'avance', 'bureau'
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
description_en TEXT NOT NULL,
|
||||
tier TEXT NOT NULL CHECK (tier IN ('free', 'premium')),
|
||||
accent_color TEXT NOT NULL DEFAULT '#30D158',
|
||||
icon TEXT NOT NULL DEFAULT 'seedling',
|
||||
duration_weeks INTEGER NOT NULL DEFAULT 4,
|
||||
sessions_per_week INTEGER NOT NULL,
|
||||
total_sessions INTEGER NOT NULL,
|
||||
equipment JSONB NOT NULL DEFAULT '{"required": [], "optional": []}',
|
||||
focus_areas TEXT[] DEFAULT '{}',
|
||||
focus_areas_en TEXT[] DEFAULT '{}',
|
||||
principles TEXT[] DEFAULT '{}',
|
||||
principles_en TEXT[] DEFAULT '{}',
|
||||
completion_criteria TEXT[] DEFAULT '{}',
|
||||
completion_criteria_en TEXT[] DEFAULT '{}',
|
||||
next_program_id TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Kine Sessions ──────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.kine_sessions (
|
||||
id TEXT PRIMARY KEY, -- 'deb-w1-s1', 'int-w2-s3', etc.
|
||||
program_id TEXT NOT NULL REFERENCES public.kine_programs(id) ON DELETE CASCADE,
|
||||
week_number INTEGER NOT NULL CHECK (week_number >= 1 AND week_number <= 6),
|
||||
day_number INTEGER NOT NULL CHECK (day_number >= 1 AND day_number <= 7),
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
description TEXT,
|
||||
description_en TEXT,
|
||||
focus TEXT[] DEFAULT '{}',
|
||||
focus_en TEXT[] DEFAULT '{}',
|
||||
warmup JSONB NOT NULL, -- WarmupPhase structure
|
||||
blocks JSONB NOT NULL, -- TabataBlock[] array
|
||||
cooldown JSONB NOT NULL, -- CooldownPhase structure
|
||||
equipment TEXT[] DEFAULT '{}',
|
||||
total_rounds INTEGER NOT NULL,
|
||||
total_duration INTEGER NOT NULL, -- minutes
|
||||
calories INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Kine Exercises Library ─────────────────────────────────
|
||||
-- Track individual exercises for video generation
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.kine_exercises (
|
||||
id TEXT PRIMARY KEY, -- slug: 'squat-classique', 'pont-fessier'
|
||||
name_fr TEXT NOT NULL,
|
||||
name_en TEXT NOT NULL,
|
||||
conseil_fr TEXT,
|
||||
conseil_en TEXT,
|
||||
modification TEXT,
|
||||
modification_en TEXT,
|
||||
progression TEXT,
|
||||
progression_en TEXT,
|
||||
video_url TEXT,
|
||||
video_generated BOOLEAN DEFAULT FALSE,
|
||||
video_generation_status TEXT CHECK (video_generation_status IN ('pending', 'generating', 'completed', 'failed')),
|
||||
video_generation_job_id TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Kine Weeks ─────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.kine_weeks (
|
||||
id TEXT PRIMARY KEY, -- 'deb-w1', 'int-w2', etc.
|
||||
program_id TEXT NOT NULL REFERENCES public.kine_programs(id) ON DELETE CASCADE,
|
||||
week_number INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
description TEXT,
|
||||
description_en TEXT,
|
||||
focus TEXT,
|
||||
focus_en TEXT,
|
||||
is_deload BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Indexes ────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kine_sessions_program ON public.kine_sessions(program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kine_sessions_week ON public.kine_sessions(program_id, week_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_kine_weeks_program ON public.kine_weeks(program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kine_exercises_generated ON public.kine_exercises(video_generated);
|
||||
|
||||
-- ─── Row Level Security ─────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.kine_programs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.kine_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.kine_exercises ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.kine_weeks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public read access
|
||||
CREATE POLICY "Public read kine_programs" ON public.kine_programs FOR SELECT USING (true);
|
||||
CREATE POLICY "Public read kine_sessions" ON public.kine_sessions FOR SELECT USING (true);
|
||||
CREATE POLICY "Public read kine_exercises" ON public.kine_exercises FOR SELECT USING (true);
|
||||
CREATE POLICY "Public read kine_weeks" ON public.kine_weeks FOR SELECT USING (true);
|
||||
|
||||
-- ─── Seed: Debutant Program ─────────────────────────────────
|
||||
|
||||
INSERT INTO public.kine_programs (id, title, title_en, description, description_en, tier, accent_color, icon, duration_weeks, sessions_per_week, total_sessions) VALUES
|
||||
('debutant', 'Débutant', 'Beginner',
|
||||
'Apprendre le protocole tabata, construire les bases techniques de chaque mouvement fondamental.',
|
||||
'Learn the tabata protocol, build technical foundations for each fundamental movement.',
|
||||
'free', '#30D158', 'seedling', 4, 3, 12)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Seed weeks
|
||||
INSERT INTO public.kine_weeks (id, program_id, week_number, title, title_en, description, description_en, focus, focus_en, is_deload) VALUES
|
||||
('deb-w1', 'debutant', 1, 'Découverte du rythme', 'Finding Your Rhythm', 'Un bloc tabata par séance (4 min) + échauffement + retour au calme.', 'One tabata block per session + warmup + cooldown.', 'Apprentissage du protocole 20/10', 'Learning the 20/10 protocol', false),
|
||||
('deb-w2', 'debutant', 2, 'Consolidation', 'Building Strength', '2 blocs tabata + 1 min récup entre les blocs.', '2 tabata blocks + 1 min recovery between blocks.', 'Consolidation des mouvements', 'Consolidating movements', false),
|
||||
('deb-w3', 'debutant', 3, 'Montée en intensité', 'Building Intensity', '3 blocs tabata + 1 min récupération entre chaque.', '3 tabata blocks + 1 min recovery between each.', 'Impacts très légers, volume augmenté', 'Very light impact, increased volume', false),
|
||||
('deb-w4', 'debutant', 4, 'Décharge & consolidation', 'Deload & Consolidation', 'Retour à 2 blocs. Volume réduit de 40%.', 'Back to 2 blocks. Volume reduced by 40%.', 'Technique parfaite, respiration', 'Perfect technique, breathing', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
493
admin-web/package-lock.json
generated
493
admin-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"test:all": "npm run test && npm run test:e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.47.0",
|
||||
"@supabase/ssr": "^0.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -16,18 +16,28 @@
|
||||
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
|
||||
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
|
||||
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
|
||||
| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 |
|
||||
| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 |
|
||||
| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 |
|
||||
| #5034 | " | ✅ | Removed Host wrapper from home screen JSX | ~139 |
|
||||
| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 |
|
||||
| #5030 | " | ✅ | Removed Host import from browse screen | ~190 |
|
||||
| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 |
|
||||
| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 |
|
||||
| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 |
|
||||
| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 |
|
||||
| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 |
|
||||
| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 |
|
||||
| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 |
|
||||
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6124 | 7:41 PM | 🔵 | Home screen uses theme-based colors properly | ~229 |
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6166 | 10:03 PM | ✅ | Updated Tab Layout Documentation | ~137 |
|
||||
| #6154 | 10:01 PM | 🔵 | Explored Explore Tab Structure | ~174 |
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6349 | 9:48 AM | 🔄 | Removed usePurchases import from home screen | ~271 |
|
||||
| #6348 | " | 🔄 | Removed usePurchases hook from home screen | ~277 |
|
||||
| #6346 | 9:47 AM | 🔄 | Cleaned up unused imports in home screen after removing direct program navigation | ~321 |
|
||||
| #6343 | 9:46 AM | 🔄 | Refactored home screen body zone sections to clickable cards | ~400 |
|
||||
| #6342 | 9:44 AM | 🔄 | Removed direct program navigation handler from home screen | ~305 |
|
||||
| #6336 | 9:39 AM | 🔵 | Reviewed complete home screen implementation for body-zone workout programs | ~386 |
|
||||
</claude-mem-context>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* TabataFit Profile Screen — Premium React Native
|
||||
* Apple Fitness+ inspired design, pure React Native components
|
||||
* TabataFit Profile Screen — Native iOS
|
||||
* Dark Medical design with SwiftUI Islands
|
||||
*/
|
||||
|
||||
import { useRouter } from 'expo-router'
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Switch,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import * as Linking from 'expo-linking'
|
||||
@@ -18,13 +17,22 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useUserStore, useActivityStore } from '@/src/shared/stores'
|
||||
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
|
||||
import { deleteSyncedData } from '@/src/shared/services/sync'
|
||||
import { GREEN, NAVY, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import {
|
||||
NativeList,
|
||||
NativeSection,
|
||||
NativeSwitch,
|
||||
NativeLabeledRow,
|
||||
NativeButton,
|
||||
} from '@/src/shared/components/native'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPONENT: PROFILE SCREEN
|
||||
@@ -47,7 +55,6 @@ export default function ProfileScreen() {
|
||||
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
|
||||
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
// Real stats from activity store
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const stats = useMemo(() => ({
|
||||
@@ -102,71 +109,63 @@ export default function ProfileScreen() {
|
||||
Linking.openURL('https://tabatafit.app/faq')
|
||||
}
|
||||
|
||||
// App version
|
||||
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 40 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PROFILE HEADER CARD
|
||||
PROFILE HEADER
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Avatar with gradient background */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<StyledText size={48} weight="bold" color="#FFFFFF">
|
||||
{avatarInitial}
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<StyledText size={48} weight="bold" color={TEXT.PRIMARY}>
|
||||
{avatarInitial}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
<View style={styles.nameContainer}>
|
||||
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
|
||||
{profile.name || t('profile.guest')}
|
||||
</StyledText>
|
||||
<View style={styles.planContainer}>
|
||||
<StyledText size={15} color={isPremium ? GREEN[500] : colors.text.tertiary}>
|
||||
{planLabel}
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<StyledText size={12} color={GREEN[500]}>✓</StyledText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
|
||||
🔥 {stats.workouts}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsWorkouts')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Name & Plan */}
|
||||
<View style={styles.nameContainer}>
|
||||
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
|
||||
{profile.name || t('profile.guest')}
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
|
||||
📅 {stats.streak}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsStreak')}
|
||||
</StyledText>
|
||||
<View style={styles.planContainer}>
|
||||
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
|
||||
{planLabel}
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<StyledText size={12} color={BRAND.PRIMARY}>
|
||||
✓
|
||||
</StyledText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
🔥 {stats.workouts}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsWorkouts')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
📅 {stats.streak}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsStreak')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
⚡️ {Math.round(stats.calories / 1000)}k
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsCalories')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
|
||||
⚡️ {Math.round(stats.calories / 1000)}k
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsCalories')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -175,20 +174,17 @@ export default function ProfileScreen() {
|
||||
UPGRADE CTA (FREE USERS ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{!isPremium && (
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={styles.premiumContainer}
|
||||
onPress={() => router.push('/paywall')}
|
||||
>
|
||||
<View style={styles.upgradeCard}>
|
||||
<Pressable onPress={() => router.push('/paywall')}>
|
||||
<View style={styles.premiumContent}>
|
||||
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
|
||||
<StyledText size={17} weight="semibold" color={GREEN[500]}>
|
||||
✨ {t('profile.upgradeTitle')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
|
||||
{t('profile.upgradeDescription')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<StyledText size={15} color={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
|
||||
<StyledText size={15} color={GREEN[500]} style={{ marginTop: SPACING[3] }}>
|
||||
{t('profile.learnMore')} →
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -196,136 +192,108 @@ export default function ProfileScreen() {
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
WORKOUT SETTINGS
|
||||
WORKOUT SETTINGS — Native List
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
|
||||
<Switch
|
||||
value={settings.haptics}
|
||||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
|
||||
<Switch
|
||||
value={settings.soundEffects}
|
||||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
|
||||
<Switch
|
||||
value={settings.voiceCoaching}
|
||||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<NativeList>
|
||||
<NativeSection title={t('profile.sectionWorkout').toUpperCase()}>
|
||||
<NativeLabeledRow label={t('profile.hapticFeedback')}>
|
||||
<NativeSwitch
|
||||
value={settings.haptics}
|
||||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||||
/>
|
||||
</NativeLabeledRow>
|
||||
<NativeLabeledRow label={t('profile.soundEffects')}>
|
||||
<NativeSwitch
|
||||
value={settings.soundEffects}
|
||||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||||
/>
|
||||
</NativeLabeledRow>
|
||||
<NativeLabeledRow label={t('profile.voiceCoaching')}>
|
||||
<NativeSwitch
|
||||
value={settings.voiceCoaching}
|
||||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||||
/>
|
||||
</NativeLabeledRow>
|
||||
</NativeSection>
|
||||
</NativeList>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
NOTIFICATIONS
|
||||
NOTIFICATIONS — Native List
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
|
||||
<Switch
|
||||
value={settings.reminders}
|
||||
onValueChange={handleReminderToggle}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{settings.reminders && (
|
||||
<View style={styles.rowTime}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<NativeList>
|
||||
<NativeSection title={t('profile.sectionNotifications').toUpperCase()}>
|
||||
<NativeLabeledRow label={t('profile.dailyReminders')}>
|
||||
<NativeSwitch
|
||||
value={settings.reminders}
|
||||
onValueChange={handleReminderToggle}
|
||||
/>
|
||||
</NativeLabeledRow>
|
||||
{settings.reminders && (
|
||||
<NativeLabeledRow label={t('profile.reminderTime')} value={settings.reminderTime} />
|
||||
)}
|
||||
</NativeSection>
|
||||
</NativeList>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PERSONALIZATION (PREMIUM ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{isPremium && (
|
||||
<>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<StyledText style={styles.rowLabel}>
|
||||
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
size={14}
|
||||
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
|
||||
>
|
||||
{profile.syncStatus === 'synced' ? '✓' : '○'}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
<NativeList>
|
||||
<NativeSection title={t('profile.sectionPersonalization').toUpperCase()}>
|
||||
<NativeLabeledRow
|
||||
label={
|
||||
profile.syncStatus === 'synced'
|
||||
? t('profile.personalizationEnabled')
|
||||
: t('profile.personalizationDisabled')
|
||||
}
|
||||
value={profile.syncStatus === 'synced' ? '✓' : '○'}
|
||||
/>
|
||||
</NativeSection>
|
||||
</NativeList>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT
|
||||
ABOUT — Native List
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
|
||||
</View>
|
||||
<Pressable style={styles.row} onPress={handleRateApp}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={styles.row} onPress={handleContactUs}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={styles.row} onPress={handleFAQ}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<NativeList>
|
||||
<NativeSection title={t('profile.sectionAbout').toUpperCase()}>
|
||||
<NativeLabeledRow
|
||||
label="Programmes Kiné"
|
||||
value="Rééducation et physiothérapie"
|
||||
chevron
|
||||
onPress={() => router.push('/program/debutant' as any)}
|
||||
/>
|
||||
<NativeLabeledRow label={t('profile.version')} value={appVersion} />
|
||||
<NativeLabeledRow label={t('profile.rateApp')} chevron onPress={handleRateApp} />
|
||||
<NativeLabeledRow label={t('profile.contactUs')} chevron onPress={handleContactUs} />
|
||||
<NativeLabeledRow label={t('profile.faq')} chevron onPress={handleFAQ} />
|
||||
<NativeLabeledRow label={t('profile.privacyPolicy')} chevron onPress={handlePrivacyPolicy} />
|
||||
</NativeSection>
|
||||
</NativeList>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ACCOUNT (PREMIUM USERS ONLY)
|
||||
ACCOUNT (PREMIUM ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{isPremium && (
|
||||
<>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
<NativeList>
|
||||
<NativeSection title={t('profile.sectionAccount').toUpperCase()}>
|
||||
<NativeLabeledRow label={t('profile.restorePurchases')} chevron onPress={handleRestore} />
|
||||
</NativeSection>
|
||||
</NativeList>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
SIGN OUT
|
||||
SIGN OUT — Native Button
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={[styles.section, styles.signOutSection]}>
|
||||
<Pressable style={styles.button} onPress={handleSignOut}>
|
||||
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
|
||||
</Pressable>
|
||||
<View style={styles.signOutContainer}>
|
||||
<NativeButton
|
||||
variant="destructive"
|
||||
title={t('profile.signOut')}
|
||||
onPress={handleSignOut}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Data Deletion Modal */}
|
||||
<DataDeletionModal
|
||||
visible={showDeleteModal}
|
||||
onDelete={handleDeleteData}
|
||||
@@ -351,22 +319,6 @@ function createStyles(colors: ThemeColors) {
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: SPACING[4],
|
||||
marginTop: SPACING[5],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.MD,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text.tertiary,
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: SPACING[8],
|
||||
marginTop: SPACING[5],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[6],
|
||||
@@ -375,11 +327,10 @@ function createStyles(colors: ThemeColors) {
|
||||
avatarContainer: {
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 45,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: NAVY[700],
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
|
||||
},
|
||||
nameContainer: {
|
||||
marginTop: SPACING[4],
|
||||
@@ -400,52 +351,22 @@ function createStyles(colors: ThemeColors) {
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
premiumContainer: {
|
||||
upgradeCard: {
|
||||
marginHorizontal: SPACING[5],
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous' as const,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.dim,
|
||||
},
|
||||
premiumContent: {
|
||||
gap: SPACING[1],
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border.glassLight,
|
||||
},
|
||||
rowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: 17,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
rowValue: {
|
||||
fontSize: 17,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
rowTime: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.border.glassLight,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: SPACING[3] + 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
destructive: {
|
||||
fontSize: 17,
|
||||
color: BRAND.DANGER,
|
||||
},
|
||||
signOutSection: {
|
||||
signOutContainer: {
|
||||
marginTop: SPACING[5],
|
||||
marginHorizontal: SPACING[5],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
|
||||
| #4964 | 9:23 AM | 🔴 | Added Host Wrapper to Root Layout | ~228 |
|
||||
| #4963 | 9:22 AM | ✅ | Root layout wraps Stack in View with pure black background | ~279 |
|
||||
| #4910 | 8:16 AM | 🟣 | Added Workout Detail and Complete Screen Routes | ~348 |
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
@@ -37,4 +34,37 @@
|
||||
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
|
||||
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
|
||||
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
|
||||
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6017 | 10:06 AM | 🔵 | Explore Filter Sheet for Level and Equipment | ~307 |
|
||||
| #6000 | 10:01 AM | 🔵 | Root App Architecture Examined | ~316 |
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6129 | 7:42 PM | 🔄 | Onboarding Wow icon circle opacity refactored | ~295 |
|
||||
| #6126 | 7:41 PM | 🔵 | Assessment screen imports reviewed | ~293 |
|
||||
| #6122 | " | 🔵 | Onboarding screen uses dynamic color with hex transparency | ~277 |
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6175 | 10:04 PM | 🟣 | Completed Explore Tab Removal | ~196 |
|
||||
| #6170 | 10:03 PM | 🟣 | Removed Explore Filters Modal Route | ~143 |
|
||||
| #6165 | 10:02 PM | 🔵 | Located Explore Filters Screen Configuration | ~141 |
|
||||
| #6160 | " | 🔵 | Identified Explore Filters Screen Configuration | ~141 |
|
||||
| #6156 | " | 🔵 | Found Explore Tab References | ~155 |
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6373 | 10:26 AM | 🟣 | Registered body zone detail screen route in app navigation | ~296 |
|
||||
| #6372 | 10:25 AM | 🔵 | Reviewed app layout navigation configuration for workout and program screens | ~318 |
|
||||
| #6371 | " | 🔵 | Examined app routing layout structure for workout routes | ~247 |
|
||||
</claude-mem-context>
|
||||
@@ -135,6 +135,18 @@ function RootLayoutInner() {
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="workout/body-zone/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: colors.bg.base },
|
||||
headerShadowVisible: false,
|
||||
headerTitle: '',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerTintColor: colors.text.primary,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="program/[id]"
|
||||
options={{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { NativeButton } from '@/src/shared/components/native'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -17,9 +18,11 @@ import { ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { withOpacity } from '@/src/shared/utils/color'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TEXT, GRADIENTS } from '@/src/shared/constants/colors'
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 28,
|
||||
@@ -118,17 +121,14 @@ export default function AssessmentScreen() {
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<Pressable style={styles.ctaButton} onPress={handleComplete}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF3B30']}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<StyledText size={16} weight="bold" color="#FFFFFF">
|
||||
{t('assessment.startAssessment')}
|
||||
</StyledText>
|
||||
<Icon name="play.fill" size={20} color="#FFFFFF" style={styles.ctaIcon} />
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
<NativeButton
|
||||
variant="primary"
|
||||
title={t('assessment.startAssessment')}
|
||||
systemImage="play.fill"
|
||||
onPress={handleComplete}
|
||||
fullWidth
|
||||
controlSize="large"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -242,23 +242,20 @@ export default function AssessmentScreen() {
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<Pressable style={styles.ctaButton} onPress={handleStart}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF3B30']}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<StyledText size={16} weight="bold" color="#FFFFFF">
|
||||
{t('assessment.takeAssessment')}
|
||||
</StyledText>
|
||||
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
<NativeButton
|
||||
variant="primary"
|
||||
title={t('assessment.takeAssessment')}
|
||||
systemImage="arrow.right"
|
||||
onPress={handleStart}
|
||||
fullWidth
|
||||
controlSize="large"
|
||||
/>
|
||||
|
||||
<Pressable style={styles.skipButton} onPress={handleSkip}>
|
||||
<StyledText size={15} color={colors.text.tertiary}>
|
||||
{t('assessment.skipForNow')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
<NativeButton
|
||||
variant="ghost"
|
||||
title={t('assessment.skipForNow')}
|
||||
onPress={handleSkip}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -304,8 +301,8 @@ function createStyles(colors: ThemeColors) {
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[5],
|
||||
@@ -335,8 +332,8 @@ function createStyles(colors: ThemeColors) {
|
||||
featureIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: SPACING[3],
|
||||
@@ -363,7 +360,7 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: colors.border.dim,
|
||||
},
|
||||
|
||||
// Assessment Container
|
||||
@@ -384,8 +381,8 @@ function createStyles(colors: ThemeColors) {
|
||||
exerciseNumber: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: SPACING[3],
|
||||
@@ -424,7 +421,7 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border.glass,
|
||||
borderTopColor: colors.border.dim,
|
||||
},
|
||||
ctaButton: {
|
||||
borderRadius: RADIUS.LG,
|
||||
|
||||
11
app/collection/CLAUDE.md
Normal file
11
app/collection/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6159 | 10:02 PM | 🔵 | Examined Collection Screen Explore Reference | ~150 |
|
||||
</claude-mem-context>
|
||||
@@ -7,7 +7,6 @@ import { useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -18,10 +17,11 @@ import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TEXT, NAVY } from '@/src/shared/constants/colors'
|
||||
|
||||
export default function CollectionDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -98,23 +98,18 @@ export default function CollectionDetailScreen() {
|
||||
>
|
||||
{/* Hero Card */}
|
||||
<View testID="collection-hero" style={styles.heroCard}>
|
||||
<LinearGradient
|
||||
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.heroContent}>
|
||||
<StyledText size={48} color="#FFFFFF" style={styles.heroIcon}>
|
||||
<StyledText size={48} color={TEXT.PRIMARY} style={styles.heroIcon}>
|
||||
{collection.icon}
|
||||
</StyledText>
|
||||
<StyledText size={28} weight="bold" color="#FFFFFF">
|
||||
<StyledText size={28} weight="bold" color={TEXT.PRIMARY}>
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
<StyledText size={15} color="rgba(255,255,255,0.8)" style={{ marginTop: SPACING[1] }}>
|
||||
<StyledText size={15} color={TEXT.SECONDARY} style={{ marginTop: SPACING[1] }}>
|
||||
{collection.description}
|
||||
</StyledText>
|
||||
<StyledText size={13} weight="semibold" color="rgba(255,255,255,0.6)" style={{ marginTop: SPACING[2] }}>
|
||||
<StyledText size={13} weight="semibold" color={TEXT.TERTIARY} style={{ marginTop: SPACING[2] }}>
|
||||
{t('plurals.workout', { count: workouts.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
@@ -138,7 +133,7 @@ export default function CollectionDetailScreen() {
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Icon name="flame.fill" size={20} color="#FFFFFF" />
|
||||
<Icon name="flame.fill" size={20} color={TEXT.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>
|
||||
@@ -200,7 +195,7 @@ function createStyles(colors: ThemeColors) {
|
||||
height: 200,
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.lg,
|
||||
backgroundColor: NAVY[700],
|
||||
},
|
||||
heroContent: {
|
||||
flex: 1,
|
||||
@@ -229,7 +224,7 @@ function createStyles(colors: ThemeColors) {
|
||||
workoutAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderRadius: RADIUS.FULL,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* TabataFit Workout Complete Screen
|
||||
* Celebration with real data from activity store
|
||||
* Dark Medical design system — navy, green, no glass
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import * as Sharing from 'expo-sharing'
|
||||
@@ -26,15 +26,17 @@ import { useActivityStore, useUserStore } from '@/src/shared/stores'
|
||||
import { getWorkoutById, getPopularWorkouts, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
|
||||
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
|
||||
import { NativeButton } from '@/src/shared/components/native'
|
||||
import { enableSync } from '@/src/shared/services/sync'
|
||||
import type { WorkoutSessionData } from '@/src/shared/types'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING, EASE } from '@/src/shared/constants/animations'
|
||||
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
|
||||
@@ -79,7 +81,7 @@ function SecondaryButton({
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
|
||||
{icon && <Icon name={icon} size={18} tintColor={colors.text.primary} style={styles.buttonIcon} />}
|
||||
{icon && <Icon name={icon} size={18} tintColor={TEXT.PRIMARY} style={styles.buttonIcon} />}
|
||||
<RNText style={styles.secondaryButtonText}>{children}</RNText>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
@@ -94,7 +96,6 @@ function PrimaryButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const isDark = colors.colorScheme === 'dark'
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
@@ -124,10 +125,10 @@ function PrimaryButton({
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: isDark ? '#FFFFFF' : '#000000', transform: [{ scale: scaleAnim }] },
|
||||
{ backgroundColor: GREEN['500'], transform: [{ scale: scaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<RNText style={[styles.primaryButtonText, { color: isDark ? '#000000' : '#FFFFFF' }]}>
|
||||
<RNText style={[styles.primaryButtonText, { color: NAVY['900'] }]}>
|
||||
{children}
|
||||
</RNText>
|
||||
</Animated.View>
|
||||
@@ -217,8 +218,7 @@ function StatCard({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Icon name={icon} size={24} tintColor={accentColor} />
|
||||
<Icon name={icon} size={24} tintColor={GREEN['500']} />
|
||||
<RNText style={styles.statValue}>{value}</RNText>
|
||||
<RNText style={styles.statLabel}>{label}</RNText>
|
||||
</Animated.View>
|
||||
@@ -248,9 +248,9 @@ function BurnBarResult({ percentile, accentColor }: { percentile: number; accent
|
||||
return (
|
||||
<View style={styles.burnBarContainer}>
|
||||
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
|
||||
<RNText style={[styles.burnBarResult, { color: accentColor }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
|
||||
<RNText style={[styles.burnBarResult, { color: GREEN['500'] }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
|
||||
<View style={styles.burnBarTrack}>
|
||||
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: accentColor }]} />
|
||||
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: GREEN['500'] }]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -383,20 +383,20 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={trainerColor} delay={100} />
|
||||
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={trainerColor} delay={200} />
|
||||
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={trainerColor} delay={300} />
|
||||
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={GREEN['500']} delay={100} />
|
||||
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={GREEN['500']} delay={200} />
|
||||
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={GREEN['500']} delay={300} />
|
||||
</View>
|
||||
|
||||
{/* Burn Bar */}
|
||||
<BurnBarResult percentile={burnBarPercentile} accentColor={trainerColor} />
|
||||
<BurnBarResult percentile={burnBarPercentile} accentColor={GREEN['500']} />
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Streak */}
|
||||
<View style={styles.streakSection}>
|
||||
<View style={[styles.streakBadge, { backgroundColor: trainerColor + '26' }]}>
|
||||
<Icon name="flame.fill" size={32} tintColor={trainerColor} />
|
||||
<View style={[styles.streakBadge, { backgroundColor: GREEN.DIM }]}>
|
||||
<Icon name="flame.fill" size={32} tintColor={GREEN['500']} />
|
||||
</View>
|
||||
<View style={styles.streakInfo}>
|
||||
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
|
||||
@@ -408,9 +408,13 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
{/* Share Button */}
|
||||
<View style={styles.shareSection}>
|
||||
<SecondaryButton onPress={handleShare} icon="square.and.arrow.up">
|
||||
{t('screens:complete.shareWorkout')}
|
||||
</SecondaryButton>
|
||||
<NativeButton
|
||||
variant="ghost"
|
||||
title={t('screens:complete.shareWorkout')}
|
||||
systemImage="square.and.arrow.up"
|
||||
onPress={handleShare}
|
||||
fullWidth
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
@@ -425,9 +429,8 @@ export default function WorkoutCompleteScreen() {
|
||||
onPress={() => handleWorkoutPress(w.id)}
|
||||
style={styles.recommendedCard}
|
||||
>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={[styles.recommendedThumb, { backgroundColor: trainerColor + '20' }]}>
|
||||
<Icon name="flame.fill" size={24} tintColor={trainerColor} />
|
||||
<View style={[styles.recommendedThumb, { backgroundColor: GREEN.DIM }]}>
|
||||
<Icon name="flame.fill" size={24} tintColor={GREEN['500']} />
|
||||
</View>
|
||||
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
|
||||
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
|
||||
@@ -439,11 +442,14 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.homeButtonContainer}>
|
||||
<PrimaryButton onPress={handleGoHome}>
|
||||
{t('screens:complete.backToHome')}
|
||||
</PrimaryButton>
|
||||
<NativeButton
|
||||
variant="primary"
|
||||
title={t('screens:complete.backToHome')}
|
||||
onPress={handleGoHome}
|
||||
fullWidth
|
||||
controlSize="large"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -481,27 +487,27 @@ function createStyles(colors: ThemeColors) {
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassStrong,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.primary,
|
||||
fontWeight: '600',
|
||||
color: TEXT.PRIMARY,
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
},
|
||||
primaryButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[6],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderRadius: RADIUS.MD,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
primaryButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: SPACING[2],
|
||||
@@ -518,7 +524,7 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
celebrationTitle: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
ringsContainer: {
|
||||
@@ -530,23 +536,21 @@ function createStyles(colors: ThemeColors) {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: colors.border.glass,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border.glassStrong,
|
||||
},
|
||||
ring1: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
borderColor: GREEN['500'],
|
||||
backgroundColor: GREEN.DIM,
|
||||
},
|
||||
ring2: {
|
||||
borderColor: '#30D158',
|
||||
backgroundColor: 'rgba(48, 209, 88, 0.15)',
|
||||
borderColor: GREEN['500'],
|
||||
backgroundColor: GREEN.DIM,
|
||||
},
|
||||
ring3: {
|
||||
borderColor: '#5AC8FA',
|
||||
backgroundColor: 'rgba(90, 200, 250, 0.15)',
|
||||
borderColor: GREEN['500'],
|
||||
backgroundColor: GREEN.DIM,
|
||||
},
|
||||
ringEmoji: {
|
||||
fontSize: 28,
|
||||
@@ -562,19 +566,20 @@ function createStyles(colors: ThemeColors) {
|
||||
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
|
||||
padding: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: colors.surface.default.backgroundColor,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: colors.surface.default.borderColor,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
@@ -584,7 +589,7 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
burnBarTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
burnBarResult: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
@@ -594,18 +599,18 @@ function createStyles(colors: ThemeColors) {
|
||||
burnBarTrack: {
|
||||
height: 8,
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: 4,
|
||||
borderRadius: RADIUS.SM,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
burnBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border.glass,
|
||||
backgroundColor: BORDER_COLORS.DIM,
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
|
||||
@@ -619,7 +624,7 @@ function createStyles(colors: ThemeColors) {
|
||||
streakBadge: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
borderRadius: RADIUS.FULL,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -628,11 +633,11 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
streakTitle: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
streakSubtitle: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
@@ -648,7 +653,7 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
recommendedTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
recommendedGrid: {
|
||||
@@ -660,7 +665,8 @@ function createStyles(colors: ThemeColors) {
|
||||
padding: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: colors.surface.default.borderColor,
|
||||
backgroundColor: colors.surface.default.backgroundColor,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
recommendedThumb: {
|
||||
@@ -674,15 +680,15 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
recommendedInitial: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
recommendedTitleText: {
|
||||
...TYPOGRAPHY.CARD_TITLE,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
recommendedDurationText: {
|
||||
...TYPOGRAPHY.CARD_METADATA,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
@@ -693,8 +699,9 @@ function createStyles(colors: ThemeColors) {
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
backgroundColor: colors.bg.base,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border.glass,
|
||||
borderTopColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
homeButtonContainer: {
|
||||
height: 56,
|
||||
|
||||
@@ -24,12 +24,16 @@ import { useUserStore } from '@/src/shared/stores'
|
||||
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND, PHASE } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
|
||||
import { track, identifyUser, setUserProperties, trackScreen } from '@/src/shared/services/analytics'
|
||||
import { GREEN, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
import { withOpacity } from '@/src/shared/utils/color'
|
||||
import { PHASE } from '@/src/shared/constants/colors'
|
||||
import { NativeButton } from '@/src/shared/components/native'
|
||||
|
||||
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types'
|
||||
|
||||
@@ -85,7 +89,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
|
||||
marginBottom: SPACING[8],
|
||||
}}
|
||||
>
|
||||
<Icon name="clock.fill" size={80} color={BRAND.PRIMARY} />
|
||||
<Icon name="clock.fill" size={80} color={GREEN[500]} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={{ opacity: textOpacity, alignItems: 'center' }}>
|
||||
@@ -122,7 +126,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
|
||||
onNext()
|
||||
}}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
<StyledText size={17} weight="semibold" color={NAVY[900]}>
|
||||
{t('onboarding.problem.cta')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -190,7 +194,7 @@ function EmpathyScreen({
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={28}
|
||||
color={selected ? BRAND.PRIMARY : colors.text.tertiary}
|
||||
color={selected ? GREEN[500] : colors.text.tertiary}
|
||||
/>
|
||||
<StyledText
|
||||
size={15}
|
||||
@@ -219,7 +223,7 @@ function EmpathyScreen({
|
||||
<StyledText
|
||||
size={17}
|
||||
weight="semibold"
|
||||
color={barriers.length > 0 ? '#FFFFFF' : colors.text.disabled}
|
||||
color={barriers.length > 0 ? NAVY[900] : colors.text.disabled}
|
||||
>
|
||||
{t('common:continue')}
|
||||
</StyledText>
|
||||
@@ -280,7 +284,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
|
||||
<View style={styles.comparisonContainer}>
|
||||
{/* Tabata bar */}
|
||||
<View style={styles.barColumn}>
|
||||
<StyledText size={22} weight="bold" color={BRAND.PRIMARY}>
|
||||
<StyledText size={22} weight="bold" color={GREEN[500]}>
|
||||
{t('onboarding.solution.tabataCalories')}
|
||||
</StyledText>
|
||||
<View style={styles.barTrack}>
|
||||
@@ -359,7 +363,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
|
||||
onNext()
|
||||
}}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
<StyledText size={17} weight="semibold" color={NAVY[900]}>
|
||||
{t('onboarding.solution.cta')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -373,7 +377,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const WOW_FEATURES = [
|
||||
{ icon: 'timer' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
|
||||
{ icon: 'timer' as const, iconColor: GREEN[500], titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
|
||||
{ icon: 'dumbbell' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
|
||||
{ icon: 'mic' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
|
||||
{ icon: 'arrow.up.right' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
|
||||
@@ -452,7 +456,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[wowStyles.iconCircle, { backgroundColor: `${feature.iconColor}26` }]}>
|
||||
<View style={[wowStyles.iconCircle, { backgroundColor: withOpacity(feature.iconColor, 0.15) }]}>
|
||||
<Icon name={feature.icon} size={22} color={feature.iconColor} />
|
||||
</View>
|
||||
<View style={wowStyles.textCol}>
|
||||
@@ -479,7 +483,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
<StyledText size={17} weight="semibold" color={NAVY[900]}>
|
||||
{t('common:next')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -659,7 +663,7 @@ function PersonalizationScreen({
|
||||
</View>
|
||||
|
||||
{name.trim().length > 0 && (
|
||||
<StyledText size={15} color={BRAND.SUCCESS} style={styles.readyMessage}>
|
||||
<StyledText size={15} color={GREEN[500]} style={styles.readyMessage}>
|
||||
{t('onboarding.personalization.readyMessage')}
|
||||
</StyledText>
|
||||
)}
|
||||
@@ -678,7 +682,7 @@ function PersonalizationScreen({
|
||||
<StyledText
|
||||
size={17}
|
||||
weight="semibold"
|
||||
color={name.trim() ? '#FFFFFF' : colors.text.disabled}
|
||||
color={name.trim() ? NAVY[900] : colors.text.disabled}
|
||||
>
|
||||
{t('common:continue')}
|
||||
</StyledText>
|
||||
@@ -822,7 +826,7 @@ function PaywallScreen({
|
||||
key={featureKey}
|
||||
style={[styles.featureRow, { opacity: featureAnims[i] }]}
|
||||
>
|
||||
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
|
||||
<Icon name="checkmark.circle.fill" size={22} color={GREEN[500]} />
|
||||
<StyledText
|
||||
size={16}
|
||||
color={colors.text.primary}
|
||||
@@ -846,7 +850,7 @@ function PaywallScreen({
|
||||
onPress={() => handlePlanSelect('premium-yearly')}
|
||||
>
|
||||
<View style={styles.bestValueBadge}>
|
||||
<StyledText size={11} weight="bold" color="#FFFFFF">
|
||||
<StyledText size={11} weight="bold" color={NAVY[900]}>
|
||||
{t('onboarding.paywall.bestValue')}
|
||||
</StyledText>
|
||||
</View>
|
||||
@@ -856,7 +860,7 @@ function PaywallScreen({
|
||||
<StyledText size={13} color={colors.text.secondary}>
|
||||
{t('common:units.perYear')}
|
||||
</StyledText>
|
||||
<StyledText size={12} weight="semibold" color={BRAND.PRIMARY} style={{ marginTop: SPACING[1] }}>
|
||||
<StyledText size={12} weight="semibold" color={GREEN[500]} style={{ marginTop: SPACING[1] }}>
|
||||
{t('onboarding.paywall.savePercent')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -886,7 +890,7 @@ function PaywallScreen({
|
||||
onPress={handlePurchase}
|
||||
disabled={isPurchasing}
|
||||
>
|
||||
<StyledText size={17} weight="bold" color="#FFFFFF">
|
||||
<StyledText size={17} weight="bold" color={NAVY[900]}>
|
||||
{isPurchasing ? '...' : t('onboarding.paywall.trialCta')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
@@ -906,8 +910,8 @@ function PaywallScreen({
|
||||
</Pressable>
|
||||
|
||||
{/* Skip */}
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
testID="skip-paywall"
|
||||
onPress={() => {
|
||||
track('onboarding_paywall_skipped')
|
||||
@@ -1125,8 +1129,8 @@ function createStyles(colors: ThemeColors) {
|
||||
// CTA Button
|
||||
ctaButton: {
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
backgroundColor: GREEN[500],
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -1146,12 +1150,14 @@ function createStyles(colors: ThemeColors) {
|
||||
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2,
|
||||
paddingVertical: SPACING[6],
|
||||
alignItems: 'center',
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
...colors.glass.base,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: NAVY[800],
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
barrierCardSelected: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
borderColor: GREEN.BORDER,
|
||||
backgroundColor: GREEN.DIM,
|
||||
},
|
||||
|
||||
// ── Screen 3: Comparison ──
|
||||
@@ -1181,7 +1187,7 @@ function createStyles(colors: ThemeColors) {
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
barTabata: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
backgroundColor: GREEN[500],
|
||||
},
|
||||
barCardio: {
|
||||
backgroundColor: PHASE.REST,
|
||||
@@ -1222,7 +1228,7 @@ function createStyles(colors: ThemeColors) {
|
||||
color: colors.text.primary,
|
||||
fontSize: 17,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
segmentRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -1268,16 +1274,18 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingVertical: SPACING[5],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
...colors.glass.base,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: NAVY[800],
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
pricingCardSelected: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
borderColor: GREEN.BORDER,
|
||||
borderWidth: 2,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.08)',
|
||||
backgroundColor: GREEN.DIM,
|
||||
},
|
||||
bestValueBadge: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
backgroundColor: GREEN[500],
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
@@ -1285,8 +1293,8 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
trialButton: {
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
backgroundColor: GREEN[500],
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING[6],
|
||||
@@ -1322,7 +1330,7 @@ function createWowStyles(colors: ThemeColors) {
|
||||
iconCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderRadius: RADIUS.FULL,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
@@ -12,16 +12,17 @@ import {
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics, usePurchases } from '@/src/shared/hooks'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import { NativeButton } from '@/src/shared/components/native'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { GREEN, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FEATURES LIST
|
||||
@@ -83,17 +84,17 @@ function PlanCard({
|
||||
onPress={handlePress}
|
||||
style={({ pressed }) => [
|
||||
styles.planCard,
|
||||
isSelected && { borderColor: BRAND.PRIMARY },
|
||||
isSelected && { borderColor: GREEN.BORDER },
|
||||
pressed && styles.planCardPressed,
|
||||
{
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
|
||||
borderColor: isSelected ? GREEN.BORDER : BORDER_COLORS.DIM,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{savings && (
|
||||
<View style={styles.savingsBadge}>
|
||||
<StyledText size={10} weight="bold" color={colors.text.primary}>{savings}</StyledText>
|
||||
<StyledText size={10} weight="bold" color={NAVY[900]}>{savings}</StyledText>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.planInfo}>
|
||||
@@ -104,12 +105,12 @@ function PlanCard({
|
||||
{period}
|
||||
</StyledText>
|
||||
</View>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY}>
|
||||
<StyledText size={20} weight="bold" color={GREEN[500]}>
|
||||
{price}
|
||||
</StyledText>
|
||||
{isSelected && (
|
||||
<View style={styles.checkmark}>
|
||||
<Icon name="checkmark.circle.fill" size={24} color={BRAND.PRIMARY} />
|
||||
<Icon name="checkmark.circle.fill" size={24} color={GREEN[500]} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -195,9 +196,13 @@ export default function PaywallScreen() {
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Close Button */}
|
||||
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
|
||||
<Icon name="xmark" size={28} color={colors.text.secondary} />
|
||||
</Pressable>
|
||||
<View style={[styles.closeButton, { top: insets.top + SPACING[2] }]}>
|
||||
<NativeButton
|
||||
variant="icon"
|
||||
systemImage="xmark"
|
||||
onPress={handleClose}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
@@ -221,8 +226,8 @@ export default function PaywallScreen() {
|
||||
<View style={styles.featuresGrid}>
|
||||
{PREMIUM_FEATURES.map((feature) => (
|
||||
<View key={feature.key} style={styles.featureItem}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
|
||||
<Icon name={feature.icon} size={22} color={BRAND.PRIMARY} />
|
||||
<View style={[styles.featureIcon, { backgroundColor: GREEN.DIM }]}>
|
||||
<Icon name={feature.icon} size={22} color={GREEN[500]} />
|
||||
</View>
|
||||
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
|
||||
{t(`paywall.features.${feature.key}`)}
|
||||
@@ -262,30 +267,22 @@ export default function PaywallScreen() {
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
<Pressable
|
||||
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
|
||||
<NativeButton
|
||||
variant="primary"
|
||||
title={isLoading ? t('paywall.processing') : t('paywall.trialCta')}
|
||||
onPress={handlePurchase}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
{isLoading ? t('paywall.processing') : t('paywall.trialCta')}
|
||||
</StyledText>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
fullWidth
|
||||
controlSize="large"
|
||||
/>
|
||||
|
||||
{/* Restore & Terms */}
|
||||
<View style={styles.footer}>
|
||||
<Pressable onPress={handleRestore}>
|
||||
<StyledText size={14} color={colors.text.tertiary}>
|
||||
{t('paywall.restore')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
<NativeButton
|
||||
variant="ghost"
|
||||
title={t('paywall.restore')}
|
||||
onPress={handleRestore}
|
||||
/>
|
||||
|
||||
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
|
||||
{t('paywall.terms')}
|
||||
@@ -379,7 +376,7 @@ function createStyles(colors: ThemeColors) {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: SPACING[3],
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
backgroundColor: GREEN[500],
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.SM,
|
||||
@@ -414,16 +411,14 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
ctaButton: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginTop: SPACING[6],
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
backgroundColor: GREEN[500],
|
||||
},
|
||||
ctaButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
ctaGradient: {
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
ctaText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
|
||||
@@ -10,9 +10,18 @@
|
||||
| #5000 | 9:35 AM | 🔵 | Reviewed Player Screen Implementation | ~522 |
|
||||
| #4912 | 8:16 AM | 🔵 | Found doneButton component in player screen | ~104 |
|
||||
|
||||
### Feb 21, 2026
|
||||
### Apr 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5551 | 12:02 AM | 🔄 | Converted onboarding and player screens to theme system | ~261 |
|
||||
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
|
||||
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
|
||||
| #5975 | 9:43 AM | 🟣 | Player screen updated to support kiné session detection and routing | ~316 |
|
||||
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6005 | 10:02 AM | 🔵 | Player Screen Routing Between Kine and Legacy Workouts | ~335 |
|
||||
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
|
||||
</claude-mem-context>
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
} from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { useKeepAwake } from 'expo-keep-awake'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useTimer } from '@/src/shared/hooks/useTimer'
|
||||
import { isTabataSession, getTabataSessionById } from '@/src/shared/data/tabata'
|
||||
import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms'
|
||||
import { TabataPlayerScreen } from '@/src/features/player/TabataPlayerScreen'
|
||||
import type { TabataSession } from '@/src/shared/types/program'
|
||||
import { useHaptics } from '@/src/shared/hooks/useHaptics'
|
||||
import { useAudio } from '@/src/shared/hooks/useAudio'
|
||||
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
|
||||
@@ -33,10 +35,11 @@ import { useWatchSync } from '@/src/features/watch'
|
||||
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
import { PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme'
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors'
|
||||
|
||||
import {
|
||||
TimerRing,
|
||||
@@ -64,6 +67,70 @@ export default function PlayerScreen() {
|
||||
useKeepAwake()
|
||||
const router = useRouter()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
|
||||
const sessionId = id ?? '1'
|
||||
|
||||
if (isWorkoutProgramId(sessionId)) {
|
||||
return <WorkoutProgramPlayerScreen compositeId={sessionId} />
|
||||
}
|
||||
|
||||
if (isTabataSession(sessionId)) {
|
||||
const session = getTabataSessionById(sessionId)
|
||||
if (session) {
|
||||
return <TabataPlayerScreen session={session} />
|
||||
}
|
||||
// Fallback to legacy if session not found
|
||||
}
|
||||
|
||||
return <LegacyPlayerScreen id={sessionId} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Workout Program player — async-loads a workout program from Supabase,
|
||||
* converts to TabataSession (3 tabata blocks), and renders TabataPlayerScreen.
|
||||
*/
|
||||
function WorkoutProgramPlayerScreen({ compositeId }: { compositeId: string }) {
|
||||
const [session, setSession] = React.useState<TabataSession | null | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const parsed = parseWorkoutProgramId(compositeId)
|
||||
if (!parsed) { if (!cancelled) setSession(null); return }
|
||||
const program = await fetchProgramById(parsed.programId)
|
||||
if (cancelled) return
|
||||
if (!program) { setSession(null); return }
|
||||
setSession(workoutProgramToTabataSession(program))
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [compositeId])
|
||||
|
||||
if (session === undefined) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={{ color: TEXT.SECONDARY }}>Chargement...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={{ color: TEXT.SECONDARY }}>Programme non trouvé</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <TabataPlayerScreen session={session} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy player for original workout format
|
||||
*/
|
||||
function LegacyPlayerScreen({ id }: { id: string }) {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
@@ -82,7 +149,7 @@ export default function PlayerScreen() {
|
||||
// Music player — synced with workout timer
|
||||
const music = useMusicPlayer({
|
||||
vibe: workout?.musicVibe ?? 'electronic',
|
||||
isPlaying: timer.isRunning && !timer.isPaused,
|
||||
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'PREP',
|
||||
})
|
||||
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
@@ -262,11 +329,7 @@ export default function PlayerScreen() {
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
|
||||
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
<Icon name="xmark" size={24} tintColor={colors.text.primary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCenter}>
|
||||
@@ -364,12 +427,6 @@ export default function PlayerScreen() {
|
||||
{timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<Pressable style={styles.doneButton} onPress={completeWorkout}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Text style={styles.doneButtonText}>{t('common:done')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -378,11 +435,6 @@ export default function PlayerScreen() {
|
||||
{/* Burn bar */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && (
|
||||
<View style={[styles.burnBarContainer, { bottom: insets.bottom + 140 }]}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
|
||||
</View>
|
||||
)}
|
||||
@@ -404,12 +456,10 @@ export default function PlayerScreen() {
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const colors = darkColors
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
backgroundColor: NAVY[900],
|
||||
},
|
||||
phaseBg: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -429,23 +479,24 @@ const styles = StyleSheet.create({
|
||||
closeBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderRadius: RADIUS.FULL,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
},
|
||||
headerCenter: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Stats overlay
|
||||
@@ -466,7 +517,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
timerTime: {
|
||||
...TYPOGRAPHY.TIMER_NUMBER,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
|
||||
@@ -489,7 +540,8 @@ const styles = StyleSheet.create({
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
padding: SPACING[3],
|
||||
},
|
||||
|
||||
@@ -507,7 +559,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
completeTitle: {
|
||||
...TYPOGRAPHY.LARGE_TITLE,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
completeSubtitle: {
|
||||
...TYPOGRAPHY.TITLE_3,
|
||||
@@ -523,27 +575,27 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
completeStatValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: colors.text.primary,
|
||||
color: TEXT.PRIMARY,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
completeStatLabel: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
doneButton: {
|
||||
width: 200,
|
||||
height: 56,
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderCurve: 'continuous',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.BRAND_GLOW,
|
||||
backgroundColor: GREEN[500],
|
||||
},
|
||||
doneButtonText: {
|
||||
...TYPOGRAPHY.BUTTON_MEDIUM,
|
||||
color: colors.text.primary,
|
||||
color: NAVY[900],
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { darkColors, BRAND } from '@/src/shared/theme'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
export default function PrivacyPolicyScreen() {
|
||||
const { t } = useTranslation('screens')
|
||||
@@ -144,7 +145,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: darkColors.border.glass,
|
||||
borderBottomColor: darkColors.border.dim,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
@@ -153,8 +154,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: darkColors.text.primary,
|
||||
},
|
||||
scrollView: {
|
||||
@@ -168,13 +168,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
...TYPOGRAPHY.TITLE_3,
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
color: darkColors.text.primary,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 15,
|
||||
...TYPOGRAPHY.BODY,
|
||||
lineHeight: 22,
|
||||
color: darkColors.text.secondary,
|
||||
},
|
||||
@@ -186,18 +186,18 @@ const styles = StyleSheet.create({
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
bullet: {
|
||||
fontSize: 15,
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
marginRight: SPACING[2],
|
||||
},
|
||||
bulletText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
...TYPOGRAPHY.BODY,
|
||||
lineHeight: 22,
|
||||
color: darkColors.text.secondary,
|
||||
},
|
||||
email: {
|
||||
fontSize: 15,
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
|
||||
28
app/program/CLAUDE.md
Normal file
28
app/program/CLAUDE.md
Normal file
@@ -0,0 +1,28 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
|
||||
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
|
||||
| #5978 | 9:53 AM | 🟣 | Kine program detail screen implemented | ~452 |
|
||||
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6027 | 10:08 AM | 🔵 | Program Detail Screen Re-Referenced for Kine Program Display | ~458 |
|
||||
| #6004 | 10:02 AM | 🔵 | Kine Program Detail Screen Architecture | ~337 |
|
||||
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
|
||||
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6150 | 7:49 PM | 🔵 | Program detail unlock button contains hardcoded orange | ~253 |
|
||||
| #6134 | 7:43 PM | 🔄 | Program detail screen added withOpacity import | ~237 |
|
||||
</claude-mem-context>
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tabata Kine Program Detail Screen
|
||||
* Tabata Program Detail Screen
|
||||
* Displays program overview, weeks, sessions, and progression for kiné programs
|
||||
*/
|
||||
|
||||
@@ -9,31 +9,31 @@ import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useKineProgramStore } from '@/src/shared/stores/kineProgramStore'
|
||||
import { getKineProgramById, getKineSessionsByWeek } from '@/src/shared/data/kine'
|
||||
import { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
|
||||
import { getTabataProgramById, getTabataSessionsByWeek } from '@/src/shared/data/tabata'
|
||||
import { canAccessProgram } from '@/src/shared/services/access'
|
||||
import { useUserStore } from '@/src/shared/stores/userStore'
|
||||
import type { KineProgramId } from '@/src/shared/types/program'
|
||||
import type { TabataProgramId } from '@/src/shared/types/program'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TEXT, NAVY, GREEN, BORDER_COLORS, AMBER, DARK } from '@/src/shared/constants/colors'
|
||||
import { withOpacity } from '@/src/shared/utils/color'
|
||||
|
||||
export default function KineProgramDetailScreen() {
|
||||
export default function TabataProgramDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const programId = id as KineProgramId
|
||||
const program = getKineProgramById(programId)
|
||||
const programId = id as TabataProgramId
|
||||
const program = getTabataProgramById(programId)
|
||||
|
||||
const selectProgram = useKineProgramStore(s => s.selectProgram)
|
||||
const progress = useKineProgramStore(s => s.programsProgress[programId])
|
||||
const isWeekUnlocked = useKineProgramStore(s => s.isWeekUnlocked)
|
||||
const getCurrentSession = useKineProgramStore(s => s.getCurrentSession)
|
||||
const completion = useKineProgramStore(s => s.getProgramCompletion(programId))
|
||||
const getProgramStatus = useKineProgramStore(s => s.getProgramStatus)
|
||||
const selectProgram = useTabataProgramStore(s => s.selectProgram)
|
||||
const progress = useTabataProgramStore(s => s.programsProgress[programId])
|
||||
const isWeekUnlocked = useTabataProgramStore(s => s.isWeekUnlocked)
|
||||
const getCurrentSession = useTabataProgramStore(s => s.getCurrentSession)
|
||||
const completion = useTabataProgramStore(s => s.getProgramCompletion(programId))
|
||||
const getProgramStatus = useTabataProgramStore(s => s.getProgramStatus)
|
||||
|
||||
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
|
||||
const canAccess = canAccessProgram(programId, isPremium)
|
||||
|
||||
@@ -11,4 +11,10 @@
|
||||
| #5044 | " | ✅ | Removed opening Host tag from workout detail screen | ~158 |
|
||||
| #5032 | 8:19 AM | ✅ | Removed Host import from workout detail screen | ~194 |
|
||||
| #5025 | 8:18 AM | 🔵 | Workout detail screen properly wraps content with Host component | ~244 |
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6366 | 10:21 AM | 🔵 | Verified workout program player integration in workout/[id].tsx | ~348 |
|
||||
</claude-mem-context>
|
||||
@@ -3,7 +3,7 @@
|
||||
* Clean scrollable layout — native header, no hero
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
@@ -24,7 +24,11 @@ import { useHaptics } from '@/src/shared/hooks'
|
||||
import { usePurchases } from '@/src/shared/hooks/usePurchases'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import { canAccessWorkout } from '@/src/shared/services/access'
|
||||
import { canAccessWorkout, canAccessSession } from '@/src/shared/services/access'
|
||||
import { getTabataSessionById, isTabataSession } from '@/src/shared/data/tabata'
|
||||
import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms'
|
||||
import { BODY_ZONE_META } from '@/src/shared/types/workoutProgram'
|
||||
import type { TabataSession } from '@/src/shared/types/program'
|
||||
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
|
||||
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
|
||||
|
||||
@@ -34,6 +38,8 @@ import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import { TEXT, NAVY, BRAND, GREEN, AMBER, RED, DARK, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
import { NativeButton } from '@/src/shared/components/native'
|
||||
|
||||
// ─── Save Button (headerRight) ───────────────────────────────────────────────
|
||||
|
||||
@@ -50,12 +56,15 @@ function SaveButton({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => pressed && { opacity: 0.6 }}
|
||||
style={({ pressed }) => [
|
||||
{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Icon
|
||||
name={isSaved ? 'heart.fill' : 'heart'}
|
||||
size={22}
|
||||
color={isSaved ? '#FF3B30' : colors.text.primary}
|
||||
color={isSaved ? BRAND.DANGER : colors.text.primary}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
@@ -69,6 +78,215 @@ export default function WorkoutDetailScreen() {
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
|
||||
if (isWorkoutProgramId(id ?? '')) {
|
||||
return <WorkoutProgramDetailScreen compositeId={id ?? ''} />
|
||||
}
|
||||
|
||||
if (isTabataSession(id ?? '')) {
|
||||
return <TabataSessionDetailScreen sessionId={id ?? ''} />
|
||||
}
|
||||
|
||||
return <LegacyWorkoutDetailScreen id={id ?? '1'} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Workout Program Detail — loads a program tabata and delegates to TabataSessionDetailScreen
|
||||
*/
|
||||
function WorkoutProgramDetailScreen({ compositeId }: { compositeId: string }) {
|
||||
const [session, setSession] = React.useState<TabataSession | null | undefined>(undefined)
|
||||
const [accent, setAccent] = React.useState<string>(GREEN[500])
|
||||
const [isFree, setIsFree] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const parsed = parseWorkoutProgramId(compositeId)
|
||||
if (!parsed) { if (!cancelled) setSession(null); return }
|
||||
const program = await fetchProgramById(parsed.programId)
|
||||
if (cancelled) return
|
||||
if (!program) { setSession(null); return }
|
||||
const tabataSession = workoutProgramToTabataSession(program)
|
||||
setSession(tabataSession)
|
||||
setIsFree(program.isFree === true)
|
||||
const zoneMeta = BODY_ZONE_META[program.bodyZone]
|
||||
setAccent(program.accentColor ?? zoneMeta.color)
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [compositeId])
|
||||
|
||||
if (session === undefined) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
|
||||
<RNText style={{ color: TEXT.SECONDARY }}>Chargement...</RNText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (session === null) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
|
||||
<RNText style={{ color: TEXT.SECONDARY }}>Programme non trouvé</RNText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <TabataSessionDetailScreen sessionId={session.id} sessionOverride={session} accentOverride={accent} isFreeOverride={isFree} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabata Session Detail — shows warmup, blocks, cooldown, tabata tips
|
||||
*/
|
||||
function TabataSessionDetailScreen({
|
||||
sessionId,
|
||||
sessionOverride,
|
||||
accentOverride,
|
||||
isFreeOverride,
|
||||
}: {
|
||||
sessionId: string
|
||||
sessionOverride?: TabataSession
|
||||
accentOverride?: string
|
||||
isFreeOverride?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const session = sessionOverride ?? getTabataSessionById(sessionId)
|
||||
const { isPremium } = usePurchases()
|
||||
const canAccess = isFreeOverride !== undefined
|
||||
? (isPremium || isFreeOverride)
|
||||
: canAccessSession(sessionId, isPremium)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
|
||||
<RNText style={{ color: TEXT.SECONDARY }}>Séance non trouvée</RNText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const programId = sessionId.startsWith('deb-') ? 'debutant' : sessionId.startsWith('int-') ? 'intermediaire' : sessionId.startsWith('avc-') ? 'avance' : 'bureau'
|
||||
const accentMap: Record<string, string> = { debutant: GREEN[500], intermediaire: BRAND.INFO, avance: RED[500], bureau: AMBER[500] }
|
||||
const accent = accentOverride ?? accentMap[programId] ?? GREEN[500]
|
||||
|
||||
const handleStart = () => {
|
||||
haptics.buttonTap()
|
||||
track('tabata_session_start_pressed', { session_id: sessionId })
|
||||
router.push(`/player/${sessionId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen options={{ headerShown: true, headerTitle: session.title, headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}>
|
||||
{/* Session info */}
|
||||
<View style={[styles.heroSection, { backgroundColor: accent + '15' }]}>
|
||||
<RNText style={styles.sessionTitle}>{session.title}</RNText>
|
||||
<RNText style={styles.sessionDesc}>{session.description}</RNText>
|
||||
<View style={styles.metaRow}>
|
||||
<RNText style={styles.metaText}>{session.blocks.length} bloc{session.blocks.length > 1 ? 's' : ''}</RNText>
|
||||
<RNText style={styles.metaText}>·</RNText>
|
||||
<RNText style={styles.metaText}>{session.totalDuration} min</RNText>
|
||||
<RNText style={styles.metaText}>·</RNText>
|
||||
<RNText style={styles.metaText}>{session.calories} cal</RNText>
|
||||
</View>
|
||||
{/* Focus tags */}
|
||||
<View style={styles.focusRow}>
|
||||
{session.focus.map((f, i) => (
|
||||
<View key={i} style={[styles.focusTag, { borderColor: accent }]}>
|
||||
<RNText style={[styles.focusTagText, { color: accent }]}>{f}</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Warmup */}
|
||||
{session.warmup.movements.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Échauffement · {Math.floor(session.warmup.totalDuration / 60)} min</RNText>
|
||||
{session.warmup.movements.map((m, i) => (
|
||||
<View key={i} style={styles.movementRow}>
|
||||
<RNText style={styles.movementDot}>●</RNText>
|
||||
<RNText style={styles.movementName}>{m.name}</RNText>
|
||||
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Blocks */}
|
||||
{session.blocks.map((block, bi) => (
|
||||
<View key={block.id} style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Bloc {bi + 1} · {block.rounds} rounds · {block.workTime}/{block.restTime}s</RNText>
|
||||
<View style={styles.exercisePair}>
|
||||
<View style={[styles.exerciseCard, { borderLeftColor: accent }]}>
|
||||
<RNText style={styles.exerciseLabel}>Rounds impairs</RNText>
|
||||
<RNText style={styles.exerciseName}>{block.oddExercise.name}</RNText>
|
||||
{block.oddExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.oddExercise.conseil}</RNText> : null}
|
||||
</View>
|
||||
<View style={[styles.exerciseCard, { borderLeftColor: BRAND.INFO }]}>
|
||||
<RNText style={styles.exerciseLabel}>Rounds pairs</RNText>
|
||||
<RNText style={styles.exerciseName}>{block.evenExercise.name}</RNText>
|
||||
{block.evenExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.evenExercise.conseil}</RNText> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Cooldown */}
|
||||
{session.cooldown.movements.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Retour au calme · {Math.floor(session.cooldown.totalDuration / 60)} min</RNText>
|
||||
{session.cooldown.movements.map((m, i) => (
|
||||
<View key={i} style={styles.movementRow}>
|
||||
<RNText style={styles.movementDot}>●</RNText>
|
||||
<RNText style={styles.movementName}>{m.name}</RNText>
|
||||
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{session.equipment.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Matériel</RNText>
|
||||
{session.equipment.map((eq, i) => (
|
||||
<RNText key={i} style={styles.equipText}>• {eq}</RNText>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA */}
|
||||
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
{canAccess ? (
|
||||
<Pressable style={[styles.ctaButton, { backgroundColor: accent }]} onPress={handleStart}>
|
||||
<RNText style={styles.ctaText}>Commencer la séance</RNText>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable style={[styles.ctaButton, { backgroundColor: GREEN[500] }]} onPress={() => router.push('/paywall')}>
|
||||
<RNText style={styles.ctaText}>Débloquer avec Premium</RNText>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy workout detail — original format
|
||||
*/
|
||||
function LegacyWorkoutDetailScreen({ id }: { id: string }) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
const savedWorkouts = useUserStore((s) => s.savedWorkouts)
|
||||
const toggleSavedWorkout = useUserStore((s) => s.toggleSavedWorkout)
|
||||
const { isPremium } = usePurchases()
|
||||
@@ -80,7 +298,7 @@ export default function WorkoutDetailScreen() {
|
||||
const workout = useTranslatedWorkout(rawWorkout)
|
||||
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
|
||||
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
|
||||
const accentColor = getWorkoutAccentColor(id ?? '1')
|
||||
const accentColor = GREEN[500]
|
||||
|
||||
// CTA entrance
|
||||
const ctaAnim = useRef(new Animated.Value(0)).current
|
||||
@@ -141,8 +359,8 @@ export default function WorkoutDetailScreen() {
|
||||
router.push(`/player/${workout.id}`)
|
||||
}
|
||||
|
||||
const ctaBg = isDark ? '#FFFFFF' : '#000000'
|
||||
const ctaText = isDark ? '#000000' : '#FFFFFF'
|
||||
const ctaBg = isDark ? TEXT.PRIMARY : NAVY[900]
|
||||
const ctaText = isDark ? NAVY[900] : TEXT.PRIMARY
|
||||
const ctaLockedBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
|
||||
const ctaLockedText = colors.text.primary
|
||||
|
||||
@@ -183,7 +401,7 @@ export default function WorkoutDetailScreen() {
|
||||
<View style={s.mediaContainer}>
|
||||
<VideoPlayer
|
||||
videoUrl={rawWorkout.videoUrl}
|
||||
gradientColors={['#1C1C1E', '#2C2C2E']}
|
||||
gradientColors={[NAVY[800], NAVY[700]]}
|
||||
mode="preview"
|
||||
isPlaying={false}
|
||||
style={s.thumbnail}
|
||||
@@ -208,14 +426,14 @@ export default function WorkoutDetailScreen() {
|
||||
<View style={s.metaItem}>
|
||||
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
|
||||
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
|
||||
{workout.duration} {t('units.minUnit', { count: workout.duration })}
|
||||
{t('units.minUnit', { count: workout.duration })}
|
||||
</RNText>
|
||||
</View>
|
||||
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
|
||||
<View style={s.metaItem}>
|
||||
<Icon name="flame" size={15} tintColor={colors.text.tertiary} />
|
||||
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
|
||||
{workout.calories} {t('units.calUnit', { count: workout.calories })}
|
||||
{t('units.calUnit', { count: workout.calories })}
|
||||
</RNText>
|
||||
</View>
|
||||
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
|
||||
@@ -230,7 +448,7 @@ export default function WorkoutDetailScreen() {
|
||||
</RNText>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={[s.separator, { backgroundColor: colors.border.dim }]} />
|
||||
|
||||
{/* Timing Card */}
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
|
||||
@@ -243,7 +461,7 @@ export default function WorkoutDetailScreen() {
|
||||
{t('screens:workout.prep', { defaultValue: 'Prep' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.workTime}s
|
||||
@@ -252,7 +470,7 @@ export default function WorkoutDetailScreen() {
|
||||
{t('screens:workout.work', { defaultValue: 'Work' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.restTime}s
|
||||
@@ -261,7 +479,7 @@ export default function WorkoutDetailScreen() {
|
||||
{t('screens:workout.rest', { defaultValue: 'Rest' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.rounds}
|
||||
@@ -293,7 +511,7 @@ export default function WorkoutDetailScreen() {
|
||||
</RNText>
|
||||
</View>
|
||||
{index < workout.exercises.length - 1 && (
|
||||
<View style={[s.exerciseSep, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={[s.exerciseSep, { backgroundColor: colors.border.dim }]} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
@@ -336,26 +554,15 @@ export default function WorkoutDetailScreen() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
|
||||
style={({ pressed }) => [
|
||||
s.ctaButton,
|
||||
{ backgroundColor: isLocked ? ctaLockedBg : ctaBg },
|
||||
isLocked && { borderWidth: 1, borderColor: colors.border.glass },
|
||||
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
<NativeButton
|
||||
variant={isLocked ? 'secondary' : 'primary'}
|
||||
title={isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
|
||||
systemImage={isLocked ? 'lock.fill' : 'play.fill'}
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
{isLocked && (
|
||||
<Icon name="lock.fill" size={16} color={ctaLockedText} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<RNText
|
||||
testID="workout-cta-text"
|
||||
style={[s.ctaText, { color: isLocked ? ctaLockedText : ctaText }]}
|
||||
>
|
||||
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
|
||||
</RNText>
|
||||
</Pressable>
|
||||
fullWidth
|
||||
controlSize="large"
|
||||
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
@@ -364,6 +571,33 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: NAVY[900] },
|
||||
heroSection: { padding: SPACING[5], alignItems: 'center' },
|
||||
sessionTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
|
||||
sessionDesc: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY, textAlign: 'center', marginTop: SPACING[2], lineHeight: 22 },
|
||||
metaRow: { flexDirection: 'row', marginTop: SPACING[4], gap: SPACING[2], justifyContent: 'center' },
|
||||
metaText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
|
||||
focusRow: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING[2], marginTop: SPACING[3], justifyContent: 'center' },
|
||||
focusTag: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 12, borderWidth: 1 },
|
||||
focusTagText: { fontSize: 12, fontWeight: '600' },
|
||||
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
|
||||
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
|
||||
movementRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[2] },
|
||||
movementDot: { fontSize: 8, color: TEXT.TERTIARY },
|
||||
movementName: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
|
||||
movementDuration: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
|
||||
exercisePair: { gap: SPACING[3] },
|
||||
exerciseCard: { backgroundColor: NAVY[800], borderRadius: RADIUS.MD, padding: SPACING[3], borderLeftWidth: 3 },
|
||||
exerciseLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 },
|
||||
exerciseName: { ...TYPOGRAPHY.TITLE_3, color: TEXT.PRIMARY },
|
||||
exerciseTip: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: SPACING[1], lineHeight: 18 },
|
||||
equipText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginBottom: SPACING[1] },
|
||||
ctaContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: SPACING[5], paddingTop: SPACING[3], backgroundColor: DARK.SCRIM, borderTopWidth: 1, borderTopColor: BORDER_COLORS.DIM },
|
||||
ctaButton: { height: 52, borderRadius: RADIUS.MD, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center' },
|
||||
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
|
||||
})
|
||||
|
||||
const s = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
16
app/workout/body-zone/CLAUDE.md
Normal file
16
app/workout/body-zone/CLAUDE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 17, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6377 | 10:28 AM | 🔴 | Fixed duplicate ScrollView opening tag in body zone detail screen | ~223 |
|
||||
| #6374 | 10:26 AM | 🔄 | Removed header section from body zone detail screen | ~260 |
|
||||
| #6363 | 10:20 AM | 🔄 | Changed program navigation to exclude explicit tabata position | ~319 |
|
||||
| #6353 | 10:02 AM | 🔄 | Simplified difficulty pill styling in body-zone detail screen | ~281 |
|
||||
| #6352 | 10:01 AM | 🔄 | Removed program count badges from difficulty filter pills | ~319 |
|
||||
| #6351 | " | 🔵 | Discovered body zone detail page with difficulty level filtering | ~364 |
|
||||
</claude-mem-context>
|
||||
296
app/workout/body-zone/[id].tsx
Normal file
296
app/workout/body-zone/[id].tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Body Zone Detail Screen
|
||||
* Shows workout programs filtered by body zone with difficulty pills
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { usePurchases } from '@/src/shared/hooks/usePurchases'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { BRAND, GREEN, TEXT, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { canAccessWorkoutProgram } from '@/src/shared/services/access'
|
||||
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
|
||||
import { fetchProgramsByBodyZone, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
|
||||
import type { WorkoutProgram, BodyZone, ProgramLevel } from '@/src/shared/types/workoutProgram'
|
||||
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
|
||||
|
||||
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
|
||||
|
||||
export default function BodyZoneDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation('screens')
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const colors = useThemeColors()
|
||||
const { isPremium } = usePurchases()
|
||||
const isProgramCompleted = useWorkoutProgramStore(s => s.isProgramCompleted)
|
||||
|
||||
const bodyZone = (id ?? 'full-body') as BodyZone
|
||||
const meta = BODY_ZONE_META[bodyZone]
|
||||
|
||||
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
|
||||
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
|
||||
|
||||
useEffect(() => {
|
||||
fetchProgramsByBodyZone(bodyZone).then((data) => {
|
||||
setPrograms(data)
|
||||
// Default to first level that has programs
|
||||
const firstAvailable = LEVELS.find(l => data.some(p => p.level === l))
|
||||
if (firstAvailable) setSelectedLevel(firstAvailable)
|
||||
})
|
||||
}, [bodyZone])
|
||||
|
||||
const filteredPrograms = useMemo(
|
||||
() => programs.filter(p => p.level === selectedLevel),
|
||||
[programs, selectedLevel],
|
||||
)
|
||||
|
||||
const handleProgramPress = (program: WorkoutProgram) => {
|
||||
haptics.buttonTap()
|
||||
const isLocked = !canAccessWorkoutProgram(program, isPremium)
|
||||
if (isLocked) {
|
||||
router.push('/paywall')
|
||||
return
|
||||
}
|
||||
router.push(`/workout/${buildWorkoutProgramId(program.id)}` as any)
|
||||
}
|
||||
|
||||
const handleLevelPress = (level: ProgramLevel) => {
|
||||
haptics.buttonTap()
|
||||
setSelectedLevel(level)
|
||||
}
|
||||
|
||||
const accentColor = meta.color
|
||||
|
||||
const styles = useMemo(() => createStyles(colors, accentColor), [colors, accentColor])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 20 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Difficulty Pills */}
|
||||
<View style={styles.pillsRow}>
|
||||
{LEVELS.map((level) => {
|
||||
const levelMeta = LEVEL_META[level]
|
||||
const isActive = selectedLevel === level
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={level}
|
||||
onPress={() => handleLevelPress(level)}
|
||||
style={[
|
||||
styles.pill,
|
||||
{
|
||||
backgroundColor: isActive ? accentColor + '20' : NAVY[800],
|
||||
borderColor: isActive ? accentColor : BORDER_COLORS.DIM,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={isActive ? 'semibold' : 'regular'}
|
||||
color={isActive ? accentColor : colors.text.secondary}
|
||||
>
|
||||
{levelMeta.label}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Program Count */}
|
||||
<StyledText size={13} color={colors.text.tertiary} style={styles.resultCount}>
|
||||
{filteredPrograms.length} programme{filteredPrograms.length !== 1 ? 's' : ''} {LEVEL_META[selectedLevel].label.toLowerCase()}
|
||||
</StyledText>
|
||||
|
||||
{/* Program List */}
|
||||
{filteredPrograms.map((program) => (
|
||||
<ProgramCard
|
||||
key={program.id}
|
||||
program={program}
|
||||
accentColor={accentColor}
|
||||
onPress={() => handleProgramPress(program)}
|
||||
isPremium={isPremium}
|
||||
isCompleted={isProgramCompleted(program.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredPrograms.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Icon name="dumbbell" size={32} tintColor={colors.text.tertiary} />
|
||||
<StyledText preset="CALLOUT" color={colors.text.tertiary} style={{ marginTop: SPACING[3], textAlign: 'center' }}>
|
||||
Aucun programme disponible pour ce niveau
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROGRAM CARD (full-width)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ProgramCard({
|
||||
program,
|
||||
accentColor,
|
||||
onPress,
|
||||
isPremium,
|
||||
isCompleted,
|
||||
}: {
|
||||
program: WorkoutProgram
|
||||
accentColor: string
|
||||
onPress: () => void
|
||||
isPremium: boolean
|
||||
isCompleted: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('screens')
|
||||
const colors = useThemeColors()
|
||||
const isLocked = !canAccessWorkoutProgram(program, isPremium)
|
||||
const levelMeta = LEVEL_META[program.level]
|
||||
const color = program.accentColor ?? accentColor
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderCurve: 'continuous' as const,
|
||||
borderColor: colors.border.dim,
|
||||
backgroundColor: colors.surface.default.backgroundColor,
|
||||
marginBottom: SPACING[3],
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Accent line */}
|
||||
<View style={{ height: 3, width: '100%', backgroundColor: color }} />
|
||||
|
||||
<View style={{ padding: SPACING[5] }}>
|
||||
{/* Title row */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<StyledText preset="TITLE_3" color={colors.text.primary} style={{ flex: 1, marginRight: SPACING[3] }}>
|
||||
{program.title}
|
||||
</StyledText>
|
||||
|
||||
{isCompleted ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN['500'] + '20' }}>
|
||||
<Icon name="checkmark" size={12} tintColor={GREEN['500']} />
|
||||
<StyledText size={11} weight="semibold" color={GREEN['500']} style={{ marginLeft: 4 }}>
|
||||
Complété
|
||||
</StyledText>
|
||||
</View>
|
||||
) : isLocked ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: color + '15' }}>
|
||||
<Icon name="lock" size={12} tintColor={color} />
|
||||
<StyledText size={11} weight="semibold" color={color} style={{ marginLeft: 4 }}>
|
||||
{t('home.premiumBadge')}
|
||||
</StyledText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN.DIM }}>
|
||||
<StyledText size={11} weight="semibold" color={GREEN['500']}>
|
||||
{t('home.freeBadge')}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{program.description ? (
|
||||
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[2] }} numberOfLines={2}>
|
||||
{program.description}
|
||||
</StyledText>
|
||||
) : null}
|
||||
|
||||
{/* Meta row */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[4], marginTop: SPACING[4] }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
|
||||
<Icon name="timer" size={14} tintColor={colors.text.tertiary} />
|
||||
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedDuration} min</StyledText>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
|
||||
<Icon name="flame" size={14} tintColor={colors.text.tertiary} />
|
||||
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedCalories} kcal</StyledText>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
|
||||
<Icon name="list.bullet" size={14} tintColor={colors.text.tertiary} />
|
||||
<StyledText size={12} color={colors.text.tertiary}>{program.tabatas.length} tabatas</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<View style={{ marginTop: SPACING[4], alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[4], paddingVertical: SPACING[2], borderRadius: RADIUS.PILL, backgroundColor: isLocked ? color + '15' : GREEN.DIM }}>
|
||||
<Icon name={isLocked ? 'lock' : 'play.fill'} size={12} tintColor={isLocked ? color : GREEN['500']} />
|
||||
<StyledText size={13} weight="semibold" color={isLocked ? color : GREEN['500']} style={{ marginLeft: SPACING[2] }}>
|
||||
{isLocked ? t('home.unlockPremium') : t('home.startProgram')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const createStyles = (colors: ThemeColors, accentColor: string) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: NAVY[900],
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Difficulty pills
|
||||
pillsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
pill: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
borderRadius: RADIUS.PILL,
|
||||
borderWidth: 1,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
// Results
|
||||
resultCount: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[10],
|
||||
},
|
||||
})
|
||||
@@ -3,11 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 11, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
|
||||
| #5291 | 2:56 PM | 🔵 | Category detail screen implementation examined | ~305 |
|
||||
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
|
||||
| #6114 | 7:39 PM | 🔵 | Category detail screen imports reviewed | ~298 |
|
||||
</claude-mem-context>
|
||||
@@ -8,10 +8,6 @@ import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-n
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import {
|
||||
Host,
|
||||
Picker,
|
||||
} from '@expo/ui/swift-ui'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -26,7 +22,7 @@ import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TEXT } from '@/src/shared/constants/colors'
|
||||
import { TEXT, GREEN } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
|
||||
const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
|
||||
@@ -89,20 +85,24 @@ export default function CategoryDetailScreen() {
|
||||
<View style={styles.backButton} />
|
||||
</View>
|
||||
|
||||
{/* Level Filter */}
|
||||
{/* Level Filter — segmented pills */}
|
||||
<View style={styles.filterContainer}>
|
||||
<Host matchContents useViewportSizeMeasurement colorScheme={colors.colorScheme}>
|
||||
<Picker
|
||||
selectedIndex={selectedLevelIndex}
|
||||
onOptionSelected={(e) => {
|
||||
haptics.selection()
|
||||
setSelectedLevelIndex(e.nativeEvent.index)
|
||||
}}
|
||||
variant="segmented"
|
||||
options={levelLabels}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
</Host>
|
||||
<View style={styles.segmentedRow}>
|
||||
{levelLabels.map((label, idx) => (
|
||||
<Pressable
|
||||
key={idx}
|
||||
style={[styles.segment, idx === selectedLevelIndex && styles.segmentActive]}
|
||||
onPress={() => {
|
||||
haptics.selection()
|
||||
setSelectedLevelIndex(idx)
|
||||
}}
|
||||
>
|
||||
<RNText style={[styles.segmentText, idx === selectedLevelIndex && styles.segmentTextActive]}>
|
||||
{label}
|
||||
</RNText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<StyledText
|
||||
@@ -175,6 +175,33 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
segmentedRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
paddingVertical: SPACING[2],
|
||||
paddingHorizontal: SPACING[3],
|
||||
borderRadius: RADIUS.MD,
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.dim,
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: GREEN.DIM,
|
||||
borderColor: GREEN.BORDER,
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
BIN
assets/mascot.gif
Normal file
BIN
assets/mascot.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 MiB |
BIN
assets/mascot.png
Normal file
BIN
assets/mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 435 KiB |
BIN
assets/mascot_bak.gif
Normal file
BIN
assets/mascot_bak.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
assets/model.glb
Normal file
BIN
assets/model.glb
Normal file
Binary file not shown.
BIN
blob_https___www.3daistudio.png
Normal file
BIN
blob_https___www.3daistudio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
223
build_in_public/script-episode-01.md
Normal file
223
build_in_public/script-episode-01.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Script Build in Public — Épisode 01
|
||||
## Tabata App : Construire une app rentable avec 0€ d'infra
|
||||
|
||||
---
|
||||
|
||||
## 🎬 INTRO (Slide 1)
|
||||
*Durée estimée : 30 sec*
|
||||
|
||||
**[Face caméra]**
|
||||
|
||||
"Salut ! Aujourd'hui je démarre une nouvelle série : Build in Public.
|
||||
|
||||
Le concept ? Je vous montre en temps réel comment je construis une app mobile... et pas n'importe laquelle.
|
||||
|
||||
Le pitch : créer l'Apple Fitness+ du Tabata. Une app vidéo-first, guidée par des coachs, avec une particularité — **zéro euro de coût d'infrastructure**.
|
||||
|
||||
Et le défi ? Tout livrer en **un mois**.
|
||||
|
||||
Let's go."
|
||||
|
||||
---
|
||||
|
||||
## 📌 LE PROBLÈME (Slide 2)
|
||||
*Durée estimée : 1 min*
|
||||
|
||||
**[Transition vers écran partage]**
|
||||
|
||||
"Pourquoi cette app ? Regardez le marché des apps Tabata...
|
||||
|
||||
Elles proposent toutes la même chose : des minuteurs génériques. Timer, bip bip, c'est fini.
|
||||
|
||||
Ce qu'aucune n'a ? **La légitimité médicale.**
|
||||
|
||||
Moi, je suis kinésithérapeute. J'ai passé des années à comprendre comment le corps bouge, comment il récupère, comment éviter les blessures.
|
||||
|
||||
Cette expertise, vous ne pouvez pas la copier. C'est ma barrière à l'entrée naturelle.
|
||||
|
||||
Et l'autre avantage ? Je self-host tout. Serveur chez moi, stockage vidéo sur mon RAID... Résultat : **coût marginal par utilisateur = quasiment zéro**.
|
||||
|
||||
Chaque abonné, c'est du revenu presque pur."
|
||||
|
||||
---
|
||||
|
||||
## 💰 BUSINESS MODEL (Slide 3)
|
||||
*Durée estimée : 1 min 30*
|
||||
|
||||
**[Slide pricing]**
|
||||
|
||||
"Parlons business model. J'ai opté pour un freemium à 3 niveaux.
|
||||
|
||||
**Niveau 1 — Gratuit à vie.**
|
||||
Le programme Débutant complet. Minuteurs, vidéos, stats de base. Pourquoi gratuit ? Parce que c'est mon entonnoir d'acquisition.
|
||||
|
||||
**Niveau 2 — Premium, 24,99€ par an.**
|
||||
Soit 2 euros par mois. Là vous débloquez les programmes Intermédiaire, Avancé, Bureau... plus les stats avancées et les nouveaux programmes chaque mois.
|
||||
|
||||
**Niveau 3 — Health+, 99,99€ par an.**
|
||||
C'est pour plus tard. Programmes Post-partum, Seniors... les trucs qui demandent vraiment mon expertise médicale.
|
||||
|
||||
La clé ? Le gratuit donne envie. Le premium deliver la valeur. Le Health+ capture la marge maximale."
|
||||
|
||||
---
|
||||
|
||||
## 🏋️ LE CONTENU (Slide 4)
|
||||
*Durée estimée : 1 min*
|
||||
|
||||
**[Slide programmes]**
|
||||
|
||||
"6 programmes au total. Regardez la structure :
|
||||
|
||||
- **Débutant** — 4 semaines, zéro impact, gratuit
|
||||
- **Intermédiaire** — plyométrie progressive
|
||||
- **Avancé** — pistol squat, fentes bulgares, le vrai challenge
|
||||
- **Bureau** — 3 formats, zéro sueur visible
|
||||
- **Post-partum** — protocole inversé, hypopressifs
|
||||
- **Seniors** — tests cliniques, prévention chutes
|
||||
|
||||
Important : **le contenu a été conçu AVANT le code**.
|
||||
|
||||
Le contenu, c'est le produit. L'app, c'est juste le vecteur."
|
||||
|
||||
---
|
||||
|
||||
## 🎯 UX & CONVERSION (Slide 5)
|
||||
*Durée estimée : 1 min 15*
|
||||
|
||||
**[Slide UX]**
|
||||
|
||||
"Côté UX, une règle d'or : **zéro friction au départ.**
|
||||
|
||||
Première séance ? Pas besoin de créer un compte. 3 questions max, et vous y êtes.
|
||||
|
||||
L'inscription vient APRÈS la première réussite. Parce que là, l'utilisateur a quelque chose à perdre.
|
||||
|
||||
Ensuite, 3 déclencheurs de conversion :
|
||||
|
||||
1. **T1** — Fin du programme Débutant. L'utilisateur est fier, motivé... et se demande "et maintenant ?"
|
||||
2. **T2** — Blocage sur contenu Premium. Paywall contextuel avec aperçu.
|
||||
3. **T3** — 5 séances en 7 jours. Là je sors l'offre personnalisée : -20% pendant 24h.
|
||||
|
||||
Et l'essai gratuit ? 7 jours, pas 14. Plus court = plus d'urgence. Les数据显示 12% de conversion vs 9%."
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ INFRASTRUCTURE (Slide 6)
|
||||
*Durée estimée : 1 min*
|
||||
|
||||
**[Slide infra]**
|
||||
|
||||
"Passons à l'infrastructure. Tout est self-hosted.
|
||||
|
||||
**Le hardware :**
|
||||
- Ryzen 5500GT, 32 Go RAM
|
||||
- SSD 256 Go pour l'OS et la base
|
||||
- 4 To en RAID 5 pour les vidéos
|
||||
- Fibre 1 Gbps symétrique
|
||||
|
||||
**La stack :**
|
||||
- Traefik en reverse proxy
|
||||
- Supabase auto-hébergé pour le backend
|
||||
- Expo pour le mobile
|
||||
- RevenueCat pour les paiements
|
||||
- PostHog pour l'analytics
|
||||
|
||||
Les vidéos ? Environ 1 Go pour 100 exercices. À partir de 1000 utilisateurs actifs, je passerai sur un CDN. Pour l'instant, zéro coût."
|
||||
|
||||
---
|
||||
|
||||
## 📊 TECHNICAL (Slide 7)
|
||||
*Durée estimée : 1 min*
|
||||
|
||||
**[Slide PostHog/RevenueCat]**
|
||||
|
||||
"Deux outils intégrés dès le jour 1 : RevenueCat et PostHog.
|
||||
|
||||
**PostHog** — 12 événements critiques trackés :
|
||||
- session_started, completed, abandoned → taux de complétion
|
||||
- paywall_viewed + trigger → quel déclencheur convertit
|
||||
- trial_started → subscription_purchased → funnel complet
|
||||
|
||||
Et un A/B test prêt : paywall_price_variant. 24,99€ vs 29,99€. La réponse en 4-6 semaines.
|
||||
|
||||
**RevenueCat** — 2 offres :
|
||||
- "default" pour Premium (24,99€/an)
|
||||
- "medical_plus" pour Health+ (99,99€/an)
|
||||
|
||||
Webhook vers Supabase Edge Function pour synchroniser les droits."
|
||||
|
||||
---
|
||||
|
||||
## 📅 ROADMAP (Slide 8)
|
||||
*Durée estimée : 45 sec*
|
||||
|
||||
**[Slide roadmap]**
|
||||
|
||||
"La roadmap — 1 mois, 3 phases.
|
||||
|
||||
**Semaine 1 — MVP**
|
||||
Auth Supabase, programme Débutant complet, timer avec vidéo, 12 events PostHog.
|
||||
|
||||
**Semaines 2-3 — Core & Monétisation**
|
||||
RevenueCat, paywall basique, programmes Intermédiaire + Bureau, notifs push.
|
||||
|
||||
**Semaine 4 — Lancement**
|
||||
Post-partum, Seniors, A/B test pricing, soumission App Store.
|
||||
|
||||
Une règle : tout ce qui n'est pas critique pour la première conversion est repoussé à la V1.1. Gestion stricte du périmètre."
|
||||
|
||||
---
|
||||
|
||||
## 💵 PROJECTIONS (Slide 9)
|
||||
*Durée estimée : 45 sec*
|
||||
|
||||
**[Slide financiers]**
|
||||
|
||||
"Les projections sur 36 mois.
|
||||
|
||||
**Scénario conservateur** — 500 abonnés → 12 480€/an
|
||||
**Scénario de base** — 1 500 abonnés → 37 440€/an
|
||||
**Scénario optimiste** — 5 000 abonnés → 124 800€/an
|
||||
|
||||
Rappel : 0€ de coût de fonctionnement. Rentable dès le premier abonné.
|
||||
|
||||
Target de conversion freemium → premium : 8 à 12%."
|
||||
|
||||
---
|
||||
|
||||
## 🚀 PROCHAINES ÉTAPES (Slide 10)
|
||||
*Durée estimée : 30 sec*
|
||||
|
||||
**[Face caméra]**
|
||||
|
||||
"Alors, ce que je fais maintenant ?
|
||||
|
||||
**Jour 1** — Setup Supabase auto-hébergé. Auth, DB schema, Storage.
|
||||
**Jour 3** — Expo + RevenueCat + PostHog initialisés.
|
||||
**Jour 7** — Premier vrai utilisateur sur le programme Débutant.
|
||||
|
||||
C'est tout pour cet épisode 01. Dans le prochain, je vous montre le setup de l'infra et les premiers écrans Expo.
|
||||
|
||||
Abonnez-vous pour suivre l'aventure. À la prochaine !"
|
||||
|
||||
---
|
||||
|
||||
## 📝 NOTES POUR L'ENREGISTREMENT
|
||||
|
||||
### Ton & Style
|
||||
- Conversationnel, naturel, pas de lecture robotique
|
||||
- Utiliser les mains, montrer de l'énergie
|
||||
- Faire des pauses après les points importants
|
||||
- Varier le rythme : plus lent sur les concepts clés
|
||||
|
||||
### Visuels suggérés
|
||||
- B-roll du serveur/infrastructure (Slide 6)
|
||||
- Screen recording de l'app en développement (intro + Slide 5)
|
||||
- Quick cuts entre face caméra et slides
|
||||
|
||||
### Durée totale estimée : ~10-12 minutes
|
||||
|
||||
---
|
||||
|
||||
*Script généré le 2026-04-05*
|
||||
*Build in Public — Épisode 01*
|
||||
1171
build_in_public/tabata-kine-journal.html
Normal file
1171
build_in_public/tabata-kine-journal.html
Normal file
File diff suppressed because it is too large
Load Diff
486
docs/ui-feature-brief.md
Normal file
486
docs/ui-feature-brief.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# TabataFit — UI Feature Brief
|
||||
|
||||
> Generated for Google Stitch design handoff.
|
||||
> Covers all end-user screens, interactions, states, and navigation flows.
|
||||
|
||||
---
|
||||
|
||||
## App Overview
|
||||
|
||||
**TabataFit** is a mobile fitness app ("Apple Fitness+ for Tabata") built with React Native / Expo. It delivers guided Tabata HIIT workouts with video, voice coaching, music, and Apple Watch heart-rate sync.
|
||||
|
||||
### Design System
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| Background | `#000000` | Pure black base |
|
||||
| Surface | `#1C1C1E` | Cards, sheets |
|
||||
| Brand | `#FF6B35` | Flame orange — primary accent |
|
||||
| Rest | `#5AC8FA` | Ice blue — rest phases |
|
||||
| Success | `#30D158` | Energy green — completion |
|
||||
| Prep phase | `#FF9500` | Orange-yellow |
|
||||
| Work phase | `#FF6B35` | Flame orange |
|
||||
| Rest phase | `#5AC8FA` | Ice blue |
|
||||
| Complete phase | `#30D158` | Green |
|
||||
|
||||
- Supports **dark and light mode**
|
||||
- Multi-language (i18n)
|
||||
- Haptic feedback throughout
|
||||
|
||||
---
|
||||
|
||||
## Navigation Structure
|
||||
|
||||
```
|
||||
Root Stack
|
||||
├── Onboarding (6-step flow)
|
||||
├── (tabs)
|
||||
│ ├── Home — index
|
||||
│ ├── Explore — browse workouts
|
||||
│ ├── Activity — stats & history
|
||||
│ └── Profile — settings & account
|
||||
├── workout/[id] — Workout detail (push)
|
||||
├── program/[id] — Program detail (push)
|
||||
├── collection/[id] — Collection detail (push)
|
||||
├── player/[id] — Workout player (push, full-screen)
|
||||
├── complete/[id] — Post-workout celebration (push)
|
||||
├── paywall — Premium upsell (modal)
|
||||
├── explore-filters — Filter sheet (form sheet modal)
|
||||
└── privacy — Privacy policy (push)
|
||||
```
|
||||
|
||||
**Tab bar**: 4 tabs — Home, Explore, Activity, Profile. SF Symbol icons. Badge support.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onboarding Flow
|
||||
|
||||
A 6-screen linear funnel shown on first launch. Progress dots at top. Skip available on some steps.
|
||||
|
||||
### 1.1 Problem Screen
|
||||
- **Purpose**: Motivational hook about time constraints
|
||||
- **Elements**: Headline text, subtitle, illustration
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.2 Empathy Screen
|
||||
- **Purpose**: User selects fitness barriers to build rapport
|
||||
- **Elements**: Grid of 4 selectable cards — "No time", "Low motivation", "No knowledge", "No gym"
|
||||
- **Interaction**: Tap to select, max 2 selections, visual highlight on selected
|
||||
- **CTA**: Continue (enabled after 1+ selection)
|
||||
|
||||
### 1.3 Solution Screen
|
||||
- **Purpose**: Show Tabata's effectiveness
|
||||
- **Elements**: Animated comparison bar chart (Tabata vs traditional cardio calorie burn)
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.4 Wow Screen
|
||||
- **Purpose**: Reveal key app features
|
||||
- **Elements**: 4 feature cards with staggered entrance animations — Timer, Exercises, Voice Coaching, Progress Tracking
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.5 Personalization Screen
|
||||
- **Purpose**: Collect user preferences to personalize experience
|
||||
- **Inputs**:
|
||||
- Name (text input)
|
||||
- Fitness level: Beginner / Intermediate / Advanced (single select chips)
|
||||
- Goal: Weight Loss / Cardio / Strength / Wellness (single select chips)
|
||||
- Weekly frequency: 2x / 3x / 5x per week (single select chips)
|
||||
- **CTA**: Continue (enabled when all fields filled)
|
||||
|
||||
### 1.6 Paywall Screen (Onboarding variant)
|
||||
- **Purpose**: Premium conversion at end of onboarding
|
||||
- **Elements**: Premium features list, yearly/monthly plan toggle with real prices from RevenueCat
|
||||
- **CTAs**: Subscribe, Restore Purchases, Skip (close button)
|
||||
- **States**: Loading prices, purchase in progress, error
|
||||
|
||||
---
|
||||
|
||||
## 2. Home Tab
|
||||
|
||||
Personalized dashboard and primary entry point.
|
||||
|
||||
### Elements
|
||||
- **Greeting header**: Time-based ("Good morning/afternoon/evening") + user's name + animated mascot
|
||||
- **Streak badge**: Current streak count with flame icon
|
||||
- **Quick stats row**: 3 stat pills — Current streak, This week (workouts), Total minutes
|
||||
- **Assessment card**: Feature-flagged (currently OFF) — fitness assessment prompt
|
||||
- **Program cards**: 3 horizontal cards (Upper Body, Lower Body, Full Body)
|
||||
- Each shows: icon, title, progress bar (% complete), status badge (Not Started / In Progress / Completed)
|
||||
- CTA per card: Start / Continue / Restart (depends on state)
|
||||
- **Switch program button**: Opens program selection
|
||||
|
||||
### Navigation
|
||||
- Tap program card → `program/[id]`
|
||||
- Tap "Start" on program → `player/[id]` (first workout)
|
||||
- Tap "Continue" → `player/[id]` (next incomplete workout)
|
||||
|
||||
### States
|
||||
- **New user**: 0 stats, no streak, programs at 0%
|
||||
- **Returning user**: Populated stats, active streak, program progress
|
||||
|
||||
---
|
||||
|
||||
## 3. Explore Tab
|
||||
|
||||
Browse, search, and filter the full workout catalog.
|
||||
|
||||
### Elements
|
||||
- **Search bar**: Search by workout title, trainer name, exercise name, category. Real-time filtering.
|
||||
- **Featured collection**: Hero card at top with image, title, workout count. Tap → `collection/[id]`
|
||||
- **Trainer avatars**: Horizontal scroll of circular trainer photos. Tap to filter workouts by trainer.
|
||||
- **Collections carousel**: Horizontal scroll of collection cards. Tap → `collection/[id]`
|
||||
- **Recommended For You**: Horizontal workout card list, personalized based on workout history
|
||||
- **Featured workouts**: Grid of highlighted workouts
|
||||
- **All Workouts section**:
|
||||
- Category filter pills: All, Full Body, Upper Body, Lower Body, Core, Cardio
|
||||
- Filter button → opens `explore-filters` sheet
|
||||
- Active filter indicator + clear filters button
|
||||
- 2-column workout card grid
|
||||
|
||||
### Workout Card
|
||||
- Thumbnail image
|
||||
- Duration badge overlay
|
||||
- Title, trainer name, level indicator
|
||||
- Lock icon if premium-only and user is free tier
|
||||
|
||||
### Navigation
|
||||
- Tap workout card → `workout/[id]`
|
||||
- Tap collection → `collection/[id]`
|
||||
- Tap trainer avatar → filters workout list by that trainer
|
||||
- Tap filter button → `explore-filters` (form sheet modal)
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton placeholders for cards
|
||||
- **Error**: Error message with Retry button
|
||||
- **Empty search**: "No workouts found" message
|
||||
- **Filtered**: Active filter chips shown, clear all button
|
||||
|
||||
---
|
||||
|
||||
## 4. Activity Tab
|
||||
|
||||
Workout history, statistics, and achievements.
|
||||
|
||||
### Elements
|
||||
- **Streak banner**: Current streak + longest streak (flame icons)
|
||||
- **Stats grid** (2x2): Each stat in a card with circular progress ring
|
||||
- Total workouts (ring fills toward goal)
|
||||
- Total minutes
|
||||
- Total calories
|
||||
- Best streak
|
||||
- **Weekly bar chart**: Sun–Sat, each bar filled if a workout was completed that day, current day highlighted
|
||||
- **Recent workouts list**: Last 5 workouts
|
||||
- Each row: workout title, relative time ("2h ago"), duration, calories
|
||||
- Tap → `workout/[id]`
|
||||
- **Achievements grid**: 4 achievement badges displayed
|
||||
- Types: workouts milestone, streak milestone, minutes milestone, calories milestone
|
||||
- States: locked (greyed out) / unlocked (colored with checkmark)
|
||||
|
||||
### States
|
||||
- **Empty**: No workouts yet — motivational message + "Start Your First Workout" CTA → `explore`
|
||||
- **Populated**: All sections visible with data
|
||||
|
||||
---
|
||||
|
||||
## 5. Profile Tab
|
||||
|
||||
User settings, account management, and app info.
|
||||
|
||||
### Elements
|
||||
- **User header**: Avatar circle with initial, display name, plan label ("Free" or "TabataFit+")
|
||||
- **Stats row**: 3 inline stats — workouts count, streak, calories
|
||||
- **Upgrade CTA** (free users only): Gradient button → `paywall`
|
||||
|
||||
#### Workout Settings Section
|
||||
- Haptic feedback toggle
|
||||
- Sound effects toggle
|
||||
- Voice coaching toggle
|
||||
|
||||
#### Notifications Section
|
||||
- Daily reminders toggle
|
||||
- Reminder time display (when enabled)
|
||||
|
||||
#### Personalization Section (premium only)
|
||||
- Sync status indicator
|
||||
|
||||
#### About Section
|
||||
- Version number
|
||||
- Rate App → opens App Store rating prompt
|
||||
- Contact Us → opens email compose
|
||||
- FAQ → opens external web link
|
||||
- Privacy Policy → `privacy` screen
|
||||
|
||||
#### Account Section (premium only)
|
||||
- Restore Purchases → triggers RevenueCat restore
|
||||
|
||||
#### Danger Zone
|
||||
- Sign Out button
|
||||
- Data deletion: triggers confirmation modal
|
||||
|
||||
### Data Deletion Modal
|
||||
- Warning text explaining data loss
|
||||
- Cancel / Delete buttons
|
||||
- Delete is destructive (red)
|
||||
|
||||
---
|
||||
|
||||
## 6. Workout Detail Screen
|
||||
|
||||
Pre-workout information screen. Reached by tapping any workout card.
|
||||
|
||||
**Route**: `workout/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Thumbnail or video preview, back button, heart/save button (toggle)
|
||||
- **Title**: Workout name
|
||||
- **Trainer**: Trainer name (colored text)
|
||||
- **Metadata row**: Duration (minutes), Calories estimate, Level badge (Beginner/Intermediate/Advanced)
|
||||
- **Equipment list**: Icons + labels for required equipment (or "No equipment")
|
||||
- **Timing card**: Prep time, Work time, Rest time, Rounds — displayed in a structured card
|
||||
- **Exercise list**: Ordered list of exercises with individual durations
|
||||
- **Repeat rounds indicator**: Shows if rounds repeat the exercise sequence
|
||||
- **Music vibe label**: Genre/mood of the workout soundtrack
|
||||
|
||||
### CTAs
|
||||
- **Start Workout** → `player/[id]` (if unlocked or user is premium)
|
||||
- **Unlock with TabataFit+** → `paywall` (if locked and user is free tier)
|
||||
|
||||
### Header Actions
|
||||
- **Back**: Navigate back
|
||||
- **Save/Unsave**: Heart icon toggle — saves workout to favorites
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton layout
|
||||
- **Unlocked**: Full detail visible, "Start Workout" CTA
|
||||
- **Locked**: Full detail visible but "Unlock with TabataFit+" CTA replaces start button
|
||||
|
||||
---
|
||||
|
||||
## 7. Program Detail Screen
|
||||
|
||||
Multi-week training program overview with per-week workout breakdown.
|
||||
|
||||
**Route**: `program/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Program icon, title, subtitle (e.g., "4 weeks · 12 workouts")
|
||||
- **Description**: Program summary text
|
||||
- **Stats card**: 3 stats — Weeks, Workouts, Total Minutes
|
||||
- **Tags**: Equipment required (e.g., Dumbbells, Mat) + Equipment optional + Focus areas (e.g., Arms, Core)
|
||||
- **Progress bar** (if started): Percentage complete with label
|
||||
- **Training plan**: Expandable week sections
|
||||
- Each week shows its workouts in order
|
||||
- Workout rows show: title, duration, completion checkmark (if done)
|
||||
- Current week has a "Current" badge
|
||||
- Future weeks may show lock icons (progressive unlock)
|
||||
|
||||
### CTAs
|
||||
- **Start Program** (not started) → `player/[id]` (first workout)
|
||||
- **Continue Training** (in progress) → `player/[id]` (next incomplete workout)
|
||||
- **Restart** (completed) → resets progress, starts from week 1
|
||||
|
||||
### States
|
||||
- **Not Started**: 0% progress, all weeks shown, "Start Program" CTA
|
||||
- **In Progress**: Progress bar filled, completed workouts checked, "Continue Training" CTA
|
||||
- **Completed**: 100% progress, all checked, "Restart" CTA
|
||||
|
||||
---
|
||||
|
||||
## 8. Collection Detail Screen
|
||||
|
||||
A curated group of workouts.
|
||||
|
||||
**Route**: `collection/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Collection title, description, workout count
|
||||
- **Workout list**: Vertical list of workout cards in the collection
|
||||
- Each card: thumbnail, title, trainer, duration, level
|
||||
- Lock icon for premium-gated workouts
|
||||
|
||||
### Navigation
|
||||
- Tap workout → `workout/[id]`
|
||||
- Back button → previous screen
|
||||
|
||||
---
|
||||
|
||||
## 9. Player Screen
|
||||
|
||||
Full-screen workout execution with timer, video, audio, and Watch sync.
|
||||
|
||||
**Route**: `player/[id]`
|
||||
|
||||
### Layout
|
||||
- **Full-screen dark mode** — no tab bar, no status bar chrome
|
||||
- **Background**: Workout video (HLS streaming) or gradient fallback
|
||||
- **Phase-colored tint**: Background overlay changes color per phase (prep=orange, work=flame, rest=blue, complete=green)
|
||||
|
||||
### Timer Section
|
||||
- **Timer ring**: Large circular progress indicator, fills as phase progresses
|
||||
- **Phase label**: PREP / WORK / REST / COMPLETE (color-coded)
|
||||
- **Countdown**: Large MM:SS timer (uses tabular-nums for alignment)
|
||||
- **Round indicator**: "Round 3 of 8" text
|
||||
|
||||
### Exercise Info
|
||||
- **Current exercise name**: Large text
|
||||
- **Next exercise preview**: Smaller text ("Up next: Burpees")
|
||||
- **Coach encouragement**: Motivational text overlays (e.g., "Keep going!", "Almost there!")
|
||||
|
||||
### Controls
|
||||
- **Start**: Begins the workout (shown before first start)
|
||||
- **Pause / Resume**: Toggle button during workout
|
||||
- **Stop**: Ends workout early (confirmation prompt)
|
||||
- **Skip**: Skip to next phase
|
||||
|
||||
### Stats Overlay
|
||||
- **Calories**: Running calorie count
|
||||
- **Heart rate**: BPM from Apple Watch (if connected)
|
||||
- **Rounds**: Current / total
|
||||
|
||||
### Burn Bar
|
||||
- Horizontal bar comparing user's current calorie burn vs. average for this workout
|
||||
- Updates in real-time
|
||||
|
||||
### Now Playing Pill
|
||||
- Shows current music track name
|
||||
- Skip track button
|
||||
|
||||
### Audio & Haptics
|
||||
- **Sound effects**: Phase start chime, 3-2-1 countdown beeps, workout complete fanfare
|
||||
- **Haptic feedback**: Phase transitions, countdown ticks, button presses
|
||||
- **Voice coaching**: Audio cues for exercises and encouragement
|
||||
- **Screen stays awake** (useKeepAwake)
|
||||
|
||||
### Apple Watch Integration
|
||||
- Sends: workout state (phase, timer, exercise)
|
||||
- Receives: play/pause, skip, stop commands, heart rate data
|
||||
|
||||
### Completion State
|
||||
- Timer ring shows 100%
|
||||
- Phase label: COMPLETE
|
||||
- Summary: Rounds completed, calories burned, total minutes
|
||||
- **Done** CTA → `complete/[id]`
|
||||
|
||||
### States
|
||||
- **Ready**: Before starting — shows workout info, Start CTA
|
||||
- **Active**: Timer running, video playing, stats updating
|
||||
- **Paused**: Timer frozen, controls show Resume
|
||||
- **Complete**: Summary shown, Done CTA
|
||||
|
||||
---
|
||||
|
||||
## 10. Workout Complete Screen
|
||||
|
||||
Post-workout celebration and next steps.
|
||||
|
||||
**Route**: `complete/[id]`
|
||||
|
||||
### Elements
|
||||
- **Celebration animation**: Concentric emoji rings spinning (fire, muscle, lightning emojis)
|
||||
- **Stats grid**: 3 stats — Calories, Minutes, 100% completion
|
||||
- **Burn bar result**: Percentile comparison ("You burned more than 73% of users")
|
||||
- **Streak display**: Current streak count + subtitle ("Keep it going!")
|
||||
- **Share button**: Opens native share sheet with workout summary
|
||||
- **Recommended next workouts**: 3 horizontal workout cards
|
||||
- Tap → `workout/[id]`
|
||||
- **Back to Home** CTA → navigates to Home tab
|
||||
|
||||
### Sync Consent Modal
|
||||
- Appears after first workout for premium users
|
||||
- Prompts to enable cross-device data sync
|
||||
- Accept / Decline buttons
|
||||
|
||||
---
|
||||
|
||||
## 11. Paywall Screen
|
||||
|
||||
Premium subscription purchase flow.
|
||||
|
||||
**Route**: `paywall` (presented as modal)
|
||||
|
||||
### Elements
|
||||
- **Header**: "TabataFit+" branding
|
||||
- **Features grid**: 6 premium feature cards with icons
|
||||
- Music during workouts
|
||||
- Unlimited workouts
|
||||
- Detailed stats
|
||||
- Calorie tracking
|
||||
- Smart reminders
|
||||
- No ads
|
||||
- **Plan selection**: Two radio-style options
|
||||
- Yearly: Price/year + "Save 50%" badge (highlighted as best value)
|
||||
- Monthly: Price/month
|
||||
- Prices fetched live from RevenueCat
|
||||
- **Subscribe CTA**: Gradient button, shows selected plan price
|
||||
- **Restore purchases**: Text link below CTA
|
||||
- **Terms**: Privacy policy + terms of service links
|
||||
- **Close button**: X in top corner to dismiss
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton while fetching prices from RevenueCat
|
||||
- **Ready**: Plans displayed with real prices
|
||||
- **Purchasing**: Loading spinner on CTA, inputs disabled
|
||||
- **Error**: Error message with retry
|
||||
- **Success**: Dismisses modal, unlocks premium features
|
||||
|
||||
---
|
||||
|
||||
## 12. Explore Filters Sheet
|
||||
|
||||
Filter modal for the Explore tab workout grid.
|
||||
|
||||
**Route**: `explore-filters` (form sheet modal with grabber)
|
||||
|
||||
### Elements
|
||||
- **Level filter chips**: All / Beginner / Intermediate / Advanced (single select)
|
||||
- **Equipment filter chips**: All / None / Dumbbells / Band / Mat (dynamic from data, single select)
|
||||
- **Apply**: Dismiss sheet, filters persist in shared store
|
||||
- **Clear**: Reset all filters to "All"
|
||||
|
||||
---
|
||||
|
||||
## 13. Privacy Policy Screen
|
||||
|
||||
Static content screen.
|
||||
|
||||
**Route**: `privacy`
|
||||
|
||||
### Elements
|
||||
- Privacy policy text content
|
||||
- Back navigation
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Features
|
||||
|
||||
### Premium Gating
|
||||
- Free users see all workouts but some are locked (lock icon overlay)
|
||||
- Tapping a locked workout's "Start" CTA redirects to `paywall`
|
||||
- Premium users have full access to all workouts, stats sync, and personalization
|
||||
|
||||
### Internationalization (i18n)
|
||||
- All user-facing strings are translated via i18n system
|
||||
- Multi-language support throughout
|
||||
|
||||
### Haptic Feedback
|
||||
- Configurable via Profile settings toggle
|
||||
- Triggered on: button presses, phase changes, countdown ticks, achievements
|
||||
|
||||
### Analytics (PostHog)
|
||||
- Events tracked across all screens: screen views, button taps, workout starts/completions, purchases, onboarding steps
|
||||
|
||||
### Dark / Light Mode
|
||||
- Full theme support — colors adapt to system appearance
|
||||
- Player screen is always dark mode regardless of system setting
|
||||
|
||||
### Loading & Error States
|
||||
- Skeleton placeholders during data fetches
|
||||
- Error states with descriptive message + Retry button
|
||||
- Empty states with motivational messaging + CTAs
|
||||
|
||||
### Animations
|
||||
- Onboarding: staggered card reveals, animated charts
|
||||
- Home: mascot animation
|
||||
- Player: timer ring fill, phase color transitions
|
||||
- Complete: spinning emoji celebration rings
|
||||
- Navigation: standard iOS push/pop + modal presentations
|
||||
20
fix_i18n.js
Normal file
20
fix_i18n.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const langs = [
|
||||
{ code: 'fr', text: 'Prêt à tout casser aujourd\'hui ?' },
|
||||
{ code: 'es', text: '¿Listo para arrasar hoy?' },
|
||||
{ code: 'de', text: 'Bereit, heute alles zu geben?' }
|
||||
];
|
||||
|
||||
langs.forEach(({ code, text }) => {
|
||||
const path = `src/shared/i18n/locales/${code}/screens.json`;
|
||||
if (fs.existsSync(path)) {
|
||||
let content = fs.readFileSync(path, 'utf8');
|
||||
content = content.replace(
|
||||
/"home":\s*\{/,
|
||||
`"home": {\n "readyToCrush": "${text}",`
|
||||
);
|
||||
fs.writeFileSync(path, content);
|
||||
console.log(`Updated ${code}`);
|
||||
}
|
||||
});
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -8,7 +8,10 @@
|
||||
"name": "tabatafit",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/dm-mono": "^0.4.2",
|
||||
"@expo-google-fonts/dm-serif-display": "^0.4.2",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/outfit": "^0.4.3",
|
||||
"@expo/ui": "~0.2.0-beta.9",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
@@ -26,6 +29,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-gl": "~16.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
@@ -1994,12 +1998,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo-google-fonts/dm-mono": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/dm-mono/-/dm-mono-0.4.2.tgz",
|
||||
"integrity": "sha512-loMaZOkQRs1r7yt4rN39zcr9e0J+smwnSx929yuODkuiPfsY4PaW18C9SEZ0BvXfcBKoRhatGoIBl8V2MOYVPQ==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/dm-serif-display": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/dm-serif-display/-/dm-serif-display-0.4.2.tgz",
|
||||
"integrity": "sha512-onlO8xAzsgMbKcwUE+fAgJ5AFHhk06VtaDN7eQOJwjV65QIciDKTiSNu1ymHc4m6g/x6D9OqPIYPXdTNIfaEaA==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/inter": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/inter/-/inter-0.4.2.tgz",
|
||||
"integrity": "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/outfit": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/outfit/-/outfit-0.4.3.tgz",
|
||||
"integrity": "sha512-2uQmDVJencWLllxds6SG92E+SjxyZfvg7eZKZ5XLHggmm5AuUyQK7lzMAFOUzT6kheq2kJ7BAiubMdjKT32fJg==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo/code-signing-certificates": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||
@@ -7591,6 +7613,34 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-gl": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-gl/-/expo-gl-16.0.10.tgz",
|
||||
"integrity": "sha512-/pPlSJvfmrGuW+UXBRVADr52nhiHFwRGXB8shhQb+b6KKreCuTmQZUASznAXS6YaHNjkOghmkaUW0hRnyiAwBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-reanimated": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"test:maestro:reset": "maestro test .maestro/flows/reset-state.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/dm-mono": "^0.4.2",
|
||||
"@expo-google-fonts/dm-serif-display": "^0.4.2",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/outfit": "^0.4.3",
|
||||
"@expo/ui": "~0.2.0-beta.9",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
@@ -43,6 +46,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-gl": "~16.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
|
||||
52
scripts/remove_bg.py
Normal file
52
scripts/remove_bg.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remove background from mascot.gif frame-by-frame using rembg."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from rembg import remove
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("Missing dependencies. Install with:")
|
||||
print(" pip install rembg Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT = Path(__file__).resolve().parent.parent / "assets" / "mascot.gif"
|
||||
OUTPUT = Path(__file__).resolve().parent.parent / "assets" / "mascot_nobg.gif"
|
||||
|
||||
|
||||
def main():
|
||||
if not INPUT.exists():
|
||||
print(f"Input not found: {INPUT}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {INPUT}...")
|
||||
original = Image.open(INPUT)
|
||||
|
||||
frames = []
|
||||
durations = []
|
||||
try:
|
||||
while True:
|
||||
durations.append(original.info.get("duration", 100))
|
||||
frame = original.convert("RGBA")
|
||||
print(f" Processing frame {len(frames) + 1}...")
|
||||
frames.append(remove(frame))
|
||||
original.seek(original.tell() + 1)
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
print(f"Saving {len(frames)} frames to {OUTPUT}...")
|
||||
frames[0].save(
|
||||
OUTPUT,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=durations,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
print(f"Done → {OUTPUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,69 +2,28 @@ import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
|
||||
import { Card, CardAccent, CardTip, GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
|
||||
|
||||
describe('GlassCard', () => {
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Card>
|
||||
<Text testID="child">Hello</Text>
|
||||
</GlassCard>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByTestId('child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders BlurView when hasBlur is true (default)', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render BlurView when hasBlur is false', () => {
|
||||
render(
|
||||
<GlassCard hasBlur={false}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.queryByTestId('blur-view')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with custom blurIntensity', () => {
|
||||
render(
|
||||
<GlassCard blurIntensity={80}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
expect(blurView.props.intensity).toBe(80)
|
||||
})
|
||||
|
||||
it('uses theme blurMedium when blurIntensity is not provided', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
// from mock: colors.glass.blurMedium = 40
|
||||
expect(blurView.props.intensity).toBe(40)
|
||||
})
|
||||
|
||||
it('applies custom style prop to root container', () => {
|
||||
const customStyle = { padding: 20 }
|
||||
const { toJSON } = render(
|
||||
<GlassCard style={customStyle}>
|
||||
<Card style={customStyle}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
</Card>
|
||||
)
|
||||
const tree = toJSON()
|
||||
// Root View should have the custom style merged into its style array
|
||||
const rootStyle = tree?.props?.style
|
||||
expect(rootStyle).toBeDefined()
|
||||
// Style is an array — flatten and check custom style is present
|
||||
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
|
||||
const hasPadding = flatStyles.some(
|
||||
(s: any) => s && typeof s === 'object' && s.padding === 20
|
||||
@@ -73,82 +32,69 @@ describe('GlassCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard variants', () => {
|
||||
it('renders base variant (snapshot)', () => {
|
||||
describe('Card variants', () => {
|
||||
it('renders default variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard>
|
||||
<Text>Base</Text>
|
||||
</GlassCard>
|
||||
<Card>
|
||||
<Text>Default</Text>
|
||||
</Card>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders elevated variant (snapshot)', () => {
|
||||
it('renders accent variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="elevated">
|
||||
<Text>Elevated</Text>
|
||||
</GlassCard>
|
||||
<CardAccent>
|
||||
<Text>Accent</Text>
|
||||
</CardAccent>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders inset variant (snapshot)', () => {
|
||||
it('renders tip variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="inset">
|
||||
<Text>Inset</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders tinted variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="tinted">
|
||||
<Text>Tinted</Text>
|
||||
</GlassCard>
|
||||
<CardTip>
|
||||
<Text>Tip</Text>
|
||||
</CardTip>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard presets', () => {
|
||||
it('GlassCardElevated renders with blur and children', () => {
|
||||
const { getByTestId } = render(
|
||||
describe('Backward-compatible aliases', () => {
|
||||
it('GlassCard renders children', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text testID="bc-child">Backward compat</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('bc-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardElevated renders children', () => {
|
||||
render(
|
||||
<GlassCardElevated>
|
||||
<Text testID="elevated-child">Elevated</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(getByTestId('elevated-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
expect(screen.getByTestId('elevated-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardInset renders WITHOUT blur (hasBlur=false)', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
it('GlassCardInset renders children', () => {
|
||||
render(
|
||||
<GlassCardInset>
|
||||
<Text testID="inset-child">Inset</Text>
|
||||
</GlassCardInset>
|
||||
)
|
||||
expect(getByTestId('inset-child')).toBeTruthy()
|
||||
// GlassCardInset passes hasBlur={false} — this is the key behavioral assertion
|
||||
expect(queryByTestId('blur-view')).toBeNull()
|
||||
expect(screen.getByTestId('inset-child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardTinted renders with blur', () => {
|
||||
const { getByTestId } = render(
|
||||
it('GlassCardTinted renders children', () => {
|
||||
render(
|
||||
<GlassCardTinted>
|
||||
<Text testID="tinted-child">Tinted</Text>
|
||||
</GlassCardTinted>
|
||||
)
|
||||
expect(getByTestId('tinted-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardElevated snapshot', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCardElevated>
|
||||
<Text>Elevated preset</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
expect(screen.getByTestId('tinted-child')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('dataService', () => {
|
||||
|
||||
describe('getTrainerById', () => {
|
||||
it('should return trainer by id', async () => {
|
||||
const trainer = await dataService.getTrainerById('emma')
|
||||
const trainer = await dataService.getTrainerById('felia')
|
||||
|
||||
expect(trainer).toBeDefined()
|
||||
expect(trainer?.id).toBe('emma')
|
||||
expect(trainer?.id).toBe('felia')
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent trainer', async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { TRAINERS } from '../../shared/data/trainers'
|
||||
|
||||
describe('trainers data', () => {
|
||||
describe('TRAINERS structure', () => {
|
||||
it('should have exactly 5 trainers', () => {
|
||||
expect(TRAINERS).toHaveLength(5)
|
||||
it('should have exactly 2 trainers', () => {
|
||||
expect(TRAINERS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have all required properties', () => {
|
||||
@@ -44,62 +44,42 @@ describe('trainers data', () => {
|
||||
})
|
||||
|
||||
describe('specific trainers', () => {
|
||||
it('should have Emma as first trainer', () => {
|
||||
expect(TRAINERS[0].id).toBe('emma')
|
||||
expect(TRAINERS[0].name).toBe('Emma')
|
||||
expect(TRAINERS[0].specialty).toBe('Full Body')
|
||||
it('should have Félia as first trainer', () => {
|
||||
expect(TRAINERS[0].id).toBe('felia')
|
||||
expect(TRAINERS[0].name).toBe('Félia')
|
||||
expect(TRAINERS[0].gender).toBe('female')
|
||||
expect(TRAINERS[0].specialty).toBe('Core')
|
||||
})
|
||||
|
||||
it('should have Jake as second trainer', () => {
|
||||
expect(TRAINERS[1].id).toBe('jake')
|
||||
expect(TRAINERS[1].name).toBe('Jake')
|
||||
it('should have Félix as second trainer', () => {
|
||||
expect(TRAINERS[1].id).toBe('felix')
|
||||
expect(TRAINERS[1].name).toBe('Félix')
|
||||
expect(TRAINERS[1].gender).toBe('male')
|
||||
expect(TRAINERS[1].specialty).toBe('Strength')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have Mia as third trainer', () => {
|
||||
expect(TRAINERS[2].id).toBe('mia')
|
||||
expect(TRAINERS[2].name).toBe('Mia')
|
||||
expect(TRAINERS[2].specialty).toBe('Core')
|
||||
})
|
||||
|
||||
it('should have Alex as fourth trainer', () => {
|
||||
expect(TRAINERS[3].id).toBe('alex')
|
||||
expect(TRAINERS[3].name).toBe('Alex')
|
||||
expect(TRAINERS[3].specialty).toBe('Cardio')
|
||||
})
|
||||
|
||||
it('should have Sofia as fifth trainer', () => {
|
||||
expect(TRAINERS[4].id).toBe('sofia')
|
||||
expect(TRAINERS[4].name).toBe('Sofia')
|
||||
expect(TRAINERS[4].specialty).toBe('Recovery')
|
||||
describe('gender distribution', () => {
|
||||
it('should have exactly 1 male and 1 female trainer', () => {
|
||||
const males = TRAINERS.filter(t => t.gender === 'male')
|
||||
const females = TRAINERS.filter(t => t.gender === 'female')
|
||||
expect(males).toHaveLength(1)
|
||||
expect(females).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('specialty coverage', () => {
|
||||
it('should cover all major workout types', () => {
|
||||
it('should cover core workout types', () => {
|
||||
const specialties = TRAINERS.map(t => t.specialty)
|
||||
expect(specialties).toContain('Full Body')
|
||||
expect(specialties).toContain('Strength')
|
||||
expect(specialties).toContain('Core')
|
||||
expect(specialties).toContain('Cardio')
|
||||
expect(specialties).toContain('Recovery')
|
||||
expect(specialties).toContain('Strength')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workout distribution', () => {
|
||||
it('should have Emma with most workouts', () => {
|
||||
const emma = TRAINERS.find(t => t.id === 'emma')
|
||||
expect(emma!.workoutCount).toBe(15)
|
||||
})
|
||||
|
||||
it('should have Sofia with fewest workouts', () => {
|
||||
const sofia = TRAINERS.find(t => t.id === 'sofia')
|
||||
expect(sofia!.workoutCount).toBe(5)
|
||||
})
|
||||
|
||||
it('should have total workout count of 50', () => {
|
||||
it('should have total workout count of 30', () => {
|
||||
const total = TRAINERS.reduce((sum, t) => sum + t.workoutCount, 0)
|
||||
expect(total).toBe(50)
|
||||
expect(total).toBe(30)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('workouts data', () => {
|
||||
})
|
||||
|
||||
describe('trainer assignments', () => {
|
||||
const validTrainers = ['emma', 'jake', 'alex', 'sofia', 'mia']
|
||||
const validTrainers = ['felia', 'felix']
|
||||
|
||||
it('should only have valid trainer IDs', () => {
|
||||
WORKOUTS.forEach((workout) => {
|
||||
|
||||
@@ -85,67 +85,66 @@ vi.mock('expo-linking', () => ({
|
||||
|
||||
const mockThemeColors = {
|
||||
bg: {
|
||||
base: '#000000',
|
||||
surface: '#1C1C1E',
|
||||
elevated: '#2C2C2E',
|
||||
base: '#0D1B2A',
|
||||
surface: '#112240',
|
||||
elevated: '#1A3050',
|
||||
overlay1: 'rgba(168,178,216,0.06)',
|
||||
overlay2: 'rgba(168,178,216,0.10)',
|
||||
overlay3: 'rgba(168,178,216,0.15)',
|
||||
scrim: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#8E8E93',
|
||||
tertiary: '#636366',
|
||||
primary: '#E6F1FF',
|
||||
secondary: '#A8B2D8',
|
||||
tertiary: '#8892B0',
|
||||
muted: '#8892B0',
|
||||
hint: '#8892B0',
|
||||
disabled: '#3A3A3C',
|
||||
},
|
||||
glass: {
|
||||
base: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
surface: {
|
||||
default: {
|
||||
backgroundColor: '#112240',
|
||||
borderColor: 'rgba(168,178,216,0.15)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
accent: {
|
||||
backgroundColor: 'rgba(0,200,150,0.05)',
|
||||
borderColor: 'rgba(0,200,150,0.35)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
tip: {
|
||||
backgroundColor: 'rgba(255,138,92,0.12)',
|
||||
borderColor: '#FF8A5C',
|
||||
borderWidth: 1,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
borderColor: 'rgba(255, 107, 53, 0.2)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
blurTint: 'dark',
|
||||
blurMedium: 40,
|
||||
},
|
||||
shadow: {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 2 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
|
||||
border: {
|
||||
dim: 'rgba(168,178,216,0.15)',
|
||||
hover: 'rgba(168,178,216,0.25)',
|
||||
brand: 'rgba(0,200,150,0.35)',
|
||||
},
|
||||
gradients: {
|
||||
videoOverlay: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
videoTop: ['rgba(0,0,0,0.5)', 'transparent'],
|
||||
},
|
||||
colorScheme: 'dark' as const,
|
||||
statusBarStyle: 'light' as const,
|
||||
}
|
||||
|
||||
vi.mock('@/src/shared/theme', () => ({
|
||||
useThemeColors: () => mockThemeColors,
|
||||
ThemeProvider: ({ children }: any) => children,
|
||||
BRAND: {
|
||||
PRIMARY: '#FF6B35',
|
||||
PRIMARY_DARK: '#E55A2B',
|
||||
},
|
||||
GRADIENTS: {
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/src/shared/constants/borderRadius', () => ({
|
||||
RADIUS: {
|
||||
SM: 8,
|
||||
MD: 12,
|
||||
LG: 16,
|
||||
XL: 20,
|
||||
NONE: 0,
|
||||
SM: 4,
|
||||
MD: 8,
|
||||
LG: 12,
|
||||
XL: 16,
|
||||
PILL: 9999,
|
||||
FULL: 9999,
|
||||
GLASS_CARD: 24,
|
||||
GLASS_BUTTON: 14,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
201
src/__tests__/stores/tabataProgramStore.test.ts
Normal file
201
src/__tests__/stores/tabataProgramStore.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useTabataProgramStore } from '../../shared/stores/tabataProgramStore'
|
||||
import type { TabataProgramId } from '../../shared/types/program'
|
||||
|
||||
const PROGRAM_IDS: TabataProgramId[] = ['debutant', 'intermediaire', 'avance', 'bureau']
|
||||
|
||||
const resetStore = () => {
|
||||
const initial: Record<TabataProgramId, any> = {} as any
|
||||
for (const id of PROGRAM_IDS) {
|
||||
initial[id] = {
|
||||
programId: id,
|
||||
currentWeek: 1,
|
||||
currentSessionIndex: 0,
|
||||
completedSessionIds: [],
|
||||
isProgramCompleted: false,
|
||||
startDate: undefined,
|
||||
lastSessionDate: undefined,
|
||||
}
|
||||
}
|
||||
useTabataProgramStore.setState({
|
||||
selectedProgramId: null,
|
||||
programsProgress: initial,
|
||||
})
|
||||
}
|
||||
|
||||
describe('tabataProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have no selected program', () => {
|
||||
expect(useTabataProgramStore.getState().selectedProgramId).toBeNull()
|
||||
})
|
||||
|
||||
it('should have initial progress for all 4 programs', () => {
|
||||
const progress = useTabataProgramStore.getState().programsProgress
|
||||
for (const id of PROGRAM_IDS) {
|
||||
expect(progress[id]).toBeDefined()
|
||||
expect(progress[id].completedSessionIds).toEqual([])
|
||||
expect(progress[id].isProgramCompleted).toBe(false)
|
||||
expect(progress[id].currentWeek).toBe(1)
|
||||
expect(progress[id].currentSessionIndex).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectProgram', () => {
|
||||
it('should set selectedProgramId and startDate on first selection', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('debutant')
|
||||
expect(state.programsProgress.debutant.startDate).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not overwrite startDate on re-selection after progress', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const firstDate = useTabataProgramStore.getState().programsProgress.debutant.startDate
|
||||
|
||||
// Simulate some progress
|
||||
useTabataProgramStore.setState(s => ({
|
||||
programsProgress: {
|
||||
...s.programsProgress,
|
||||
debutant: {
|
||||
...s.programsProgress.debutant,
|
||||
completedSessionIds: ['deb-w1-s1'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Re-select
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
expect(useTabataProgramStore.getState().selectedProgramId).toBe('debutant')
|
||||
// startDate should remain unchanged
|
||||
expect(useTabataProgramStore.getState().programsProgress.debutant.startDate).toBe(firstDate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeSession', () => {
|
||||
it('should add session ID to completed list', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.completedSessionIds).toContain('deb-w1-s1')
|
||||
expect(progress.lastSessionDate).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not duplicate session IDs', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.completedSessionIds.filter(id => id === 'deb-w1-s1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should advance session index within same week', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
const progress = useTabataProgramStore.getState().programsProgress.debutant
|
||||
expect(progress.currentSessionIndex).toBe(1)
|
||||
expect(progress.currentWeek).toBe(1)
|
||||
})
|
||||
|
||||
it('should ignore unknown program IDs', () => {
|
||||
// Should not throw
|
||||
useTabataProgramStore.getState().completeSession('nonexistent' as TabataProgramId, 'x')
|
||||
// State unchanged
|
||||
expect(useTabataProgramStore.getState().programsProgress.debutant.completedSessionIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetProgram', () => {
|
||||
it('should reset progress to initial state', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().resetProgram('debutant')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBeNull()
|
||||
expect(state.programsProgress.debutant.completedSessionIds).toEqual([])
|
||||
expect(state.programsProgress.debutant.startDate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not affect other programs when resetting one', () => {
|
||||
useTabataProgramStore.getState().selectProgram('intermediaire')
|
||||
useTabataProgramStore.getState().completeSession('intermediaire', 'int-w1-s1')
|
||||
useTabataProgramStore.getState().resetProgram('debutant')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('intermediaire')
|
||||
expect(state.programsProgress.intermediaire.completedSessionIds).toContain('int-w1-s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeProgram', () => {
|
||||
it('should change selected program without resetting progress', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().changeProgram('intermediaire')
|
||||
|
||||
const state = useTabataProgramStore.getState()
|
||||
expect(state.selectedProgramId).toBe('intermediaire')
|
||||
expect(state.programsProgress.debutant.completedSessionIds).toContain('deb-w1-s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
it('getCurrentSession should return first session initially', () => {
|
||||
const session = useTabataProgramStore.getState().getCurrentSession('debutant')
|
||||
expect(session).not.toBeNull()
|
||||
expect(session?.id).toMatch(/^deb-/)
|
||||
})
|
||||
|
||||
it('getCurrentSession should return null for unknown program', () => {
|
||||
const session = useTabataProgramStore.getState().getCurrentSession('nonexistent' as TabataProgramId)
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('getProgramCompletion should return 0 initially', () => {
|
||||
expect(useTabataProgramStore.getState().getProgramCompletion('debutant')).toBe(0)
|
||||
})
|
||||
|
||||
it('getTotalSessionsCompleted should sum across all programs', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
useTabataProgramStore.getState().completeSession('intermediaire', 'int-w1-s1')
|
||||
expect(useTabataProgramStore.getState().getTotalSessionsCompleted()).toBe(2)
|
||||
})
|
||||
|
||||
it('getProgramStatus should return not-started initially', () => {
|
||||
expect(useTabataProgramStore.getState().getProgramStatus('debutant')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('getProgramStatus should return in-progress after completing a session', () => {
|
||||
useTabataProgramStore.getState().completeSession('debutant', 'deb-w1-s1')
|
||||
expect(useTabataProgramStore.getState().getProgramStatus('debutant')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('isWeekUnlocked should return true for week 1', () => {
|
||||
expect(useTabataProgramStore.getState().isWeekUnlocked('debutant', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('isWeekUnlocked should return false for week 2 initially', () => {
|
||||
expect(useTabataProgramStore.getState().isWeekUnlocked('debutant', 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('getProgram should return program data', () => {
|
||||
const program = useTabataProgramStore.getState().getProgram('debutant')
|
||||
expect(program).toBeDefined()
|
||||
expect(program?.id).toBe('debutant')
|
||||
})
|
||||
|
||||
it('getRecommendedNext should return current session of selected program', () => {
|
||||
useTabataProgramStore.getState().selectProgram('debutant')
|
||||
const rec = useTabataProgramStore.getState().getRecommendedNext()
|
||||
expect(rec).not.toBeNull()
|
||||
expect(rec?.programId).toBe('debutant')
|
||||
})
|
||||
|
||||
it('getRecommendedNext should return null when no programs started', () => {
|
||||
const rec = useTabataProgramStore.getState().getRecommendedNext()
|
||||
expect(rec).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
189
src/__tests__/stores/workoutProgramStore.test.ts
Normal file
189
src/__tests__/stores/workoutProgramStore.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useWorkoutProgramStore } from '../../shared/stores/workoutProgramStore'
|
||||
import type { WorkoutProgram } from '../../shared/types/workoutProgram'
|
||||
|
||||
const resetStore = () => {
|
||||
useWorkoutProgramStore.setState({ completions: {} })
|
||||
}
|
||||
|
||||
const mockPrograms: WorkoutProgram[] = [
|
||||
{
|
||||
id: 'prog-1',
|
||||
title: 'Upper Body Basics',
|
||||
description: 'Upper body workout',
|
||||
bodyZone: 'upper-body',
|
||||
level: 'Beginner',
|
||||
isFree: true,
|
||||
musicVibe: 'electronic',
|
||||
estimatedDuration: 12,
|
||||
estimatedCalories: 100,
|
||||
icon: 'dumbbell',
|
||||
accentColor: '#FF6B35',
|
||||
sortOrder: 1,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'prog-2',
|
||||
title: 'Lower Body Burn',
|
||||
description: 'Lower body workout',
|
||||
bodyZone: 'lower-body',
|
||||
level: 'Intermediate',
|
||||
isFree: false,
|
||||
musicVibe: 'hip-hop',
|
||||
estimatedDuration: 15,
|
||||
estimatedCalories: 150,
|
||||
icon: 'figure.walk',
|
||||
accentColor: '#5AC8FA',
|
||||
sortOrder: 2,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'prog-3',
|
||||
title: 'Full Body Advanced',
|
||||
description: 'Full body workout',
|
||||
bodyZone: 'full-body',
|
||||
level: 'Advanced',
|
||||
isFree: false,
|
||||
musicVibe: 'rock',
|
||||
estimatedDuration: 20,
|
||||
estimatedCalories: 200,
|
||||
icon: 'bolt',
|
||||
accentColor: '#30D158',
|
||||
sortOrder: 3,
|
||||
tabatas: [],
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
]
|
||||
|
||||
describe('workoutProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have empty completions', () => {
|
||||
expect(useWorkoutProgramStore.getState().completions).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeProgram', () => {
|
||||
it('should mark entire program as completed when no tabataPosition', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1']).toBeDefined()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1, 2, 3])
|
||||
expect(state.completions['prog-1'].completedAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should mark specific tabata as completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1])
|
||||
})
|
||||
|
||||
it('should accumulate tabata completions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should not duplicate tabata positions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toEqual([1])
|
||||
})
|
||||
|
||||
it('should set completedAt when all 3 tabatas done', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 3)
|
||||
const state = useWorkoutProgramStore.getState()
|
||||
expect(state.completions['prog-1'].completedAt).toBeTruthy()
|
||||
expect(state.completions['prog-1'].tabatasCompleted).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetProgram', () => {
|
||||
it('should remove program completion', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().resetProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().completions['prog-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not affect other programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().resetProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().completions['prog-2']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProgramCompleted', () => {
|
||||
it('should return false for uncompleted program', () => {
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when all 3 tabatas completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when only 2 tabatas completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 1)
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
expect(useWorkoutProgramStore.getState().isProgramCompleted('prog-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompletedCount', () => {
|
||||
it('should return 0 initially', () => {
|
||||
expect(useWorkoutProgramStore.getState().getCompletedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should count fully completed programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-3', 1) // partial
|
||||
expect(useWorkoutProgramStore.getState().getCompletedCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecommendedNext', () => {
|
||||
it('should recommend first incomplete program sorted by level', () => {
|
||||
const rec = useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)
|
||||
expect(rec).not.toBeNull()
|
||||
expect(rec?.id).toBe('prog-1') // Beginner first
|
||||
})
|
||||
|
||||
it('should skip completed programs', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
const rec = useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)
|
||||
expect(rec?.id).toBe('prog-2') // Intermediate
|
||||
})
|
||||
|
||||
it('should return null when all completed', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-2')
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-3')
|
||||
expect(useWorkoutProgramStore.getState().getRecommendedNext(mockPrograms)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTabatasCompleted', () => {
|
||||
it('should return empty array for unknown program', () => {
|
||||
expect(useWorkoutProgramStore.getState().getTabatasCompleted('unknown')).toEqual([])
|
||||
})
|
||||
|
||||
it('should return completed tabata positions', () => {
|
||||
useWorkoutProgramStore.getState().completeProgram('prog-1', 2)
|
||||
expect(useWorkoutProgramStore.getState().getTabatasCompleted('prog-1')).toEqual([2])
|
||||
})
|
||||
})
|
||||
})
|
||||
32
src/__tests__/utils/color.test.ts
Normal file
32
src/__tests__/utils/color.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { withOpacity } from '../../shared/utils/color'
|
||||
|
||||
describe('withOpacity', () => {
|
||||
it('should convert 6-digit hex with opacity', () => {
|
||||
expect(withOpacity('#FF6B35', 0.5)).toBe('rgba(255,107,53,0.5)')
|
||||
})
|
||||
|
||||
it('should convert 3-digit hex with opacity', () => {
|
||||
expect(withOpacity('#FFF', 1)).toBe('rgba(255,255,255,1)')
|
||||
})
|
||||
|
||||
it('should handle hex without # prefix', () => {
|
||||
expect(withOpacity('000000', 0)).toBe('rgba(0,0,0,0)')
|
||||
})
|
||||
|
||||
it('should handle 3-digit hex without # prefix', () => {
|
||||
expect(withOpacity('F00', 0.8)).toBe('rgba(255,0,0,0.8)')
|
||||
})
|
||||
|
||||
it('should handle pure black', () => {
|
||||
expect(withOpacity('#000000', 1)).toBe('rgba(0,0,0,1)')
|
||||
})
|
||||
|
||||
it('should handle pure white', () => {
|
||||
expect(withOpacity('#FFFFFF', 0.12)).toBe('rgba(255,255,255,0.12)')
|
||||
})
|
||||
|
||||
it('should handle lowercase hex', () => {
|
||||
expect(withOpacity('#ff6b35', 0.5)).toBe('rgba(255,107,53,0.5)')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { adminService } from '../services/adminService'
|
||||
import { logger } from '@/src/shared/utils/logger'
|
||||
|
||||
interface AdminAuthContextType {
|
||||
isAuthenticated: boolean
|
||||
@@ -29,7 +30,7 @@ export function AdminAuthProvider({ children }: { children: ReactNode }) {
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
logger.error('Auth check failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
15
src/features/player/CLAUDE.md
Normal file
15
src/features/player/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6298 | 11:39 PM | 🟣 | YouTube music system fully integrated with workout timers - database-driven architecture replaces storage buckets | ~617 |
|
||||
| #6282 | 11:06 PM | 🟣 | Music playback disabled during warm-up phase in Kine sessions | ~293 |
|
||||
| #6275 | 10:53 PM | 🔴 | React hook execution order fixed in KinePlayerScreen | ~327 |
|
||||
| #6272 | " | 🟣 | NowPlaying component integrated into KinePlayerScreen UI | ~368 |
|
||||
| #6271 | 10:52 PM | 🟣 | useMusicPlayer hook added to KinePlayerScreen for music synchronization | ~359 |
|
||||
</claude-mem-context>
|
||||
354
src/features/player/TabataPlayerScreen.tsx
Normal file
354
src/features/player/TabataPlayerScreen.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Tabata Player Screen
|
||||
* Handles multi-block tabata sessions with warmup, blocks, inter-block rest, cooldown
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, Animated, StatusBar,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
import { useTabataTimer } from '@/src/shared/hooks/useTabataTimer'
|
||||
import { useHaptics } from '@/src/shared/hooks/useHaptics'
|
||||
import { useAudio } from '@/src/shared/hooks/useAudio'
|
||||
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
|
||||
import { useActivityStore } from '@/src/shared/stores'
|
||||
import { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
|
||||
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
|
||||
import { getSessionProgramId } from '@/src/shared/services/access'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import type { TabataSession } from '@/src/shared/types/program'
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT, PHASE, AMBER } from '@/src/shared/constants/colors'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { TimerRing, PhaseIndicator, RoundIndicator, PlayerControls, StatsOverlay, CoachEncouragement, NowPlaying } from '@/src/features/player'
|
||||
import { TabataTip } from '@/src/features/player/components/TabataTip'
|
||||
import { BlockIndicator } from '@/src/features/player/components/BlockIndicator'
|
||||
import { WarmupOverlay } from '@/src/features/player/components/WarmupOverlay'
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const TABATA_PHASE_COLORS: Record<string, string> = {
|
||||
WARMUP: PHASE.PREP,
|
||||
WORK: PHASE.WORK,
|
||||
REST: PHASE.REST,
|
||||
INTER_BLOCK_REST: AMBER[500],
|
||||
COOLDOWN: GREEN[500],
|
||||
COMPLETE: GREEN[500],
|
||||
}
|
||||
|
||||
interface TabataPlayerScreenProps {
|
||||
session: TabataSession
|
||||
}
|
||||
|
||||
export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
useKeepAwake()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const audio = useAudio()
|
||||
const addWorkoutResult = useActivityStore(s => s.addWorkoutResult)
|
||||
const completeSession = useTabataProgramStore(s => s.completeSession)
|
||||
const completeProgram = useWorkoutProgramStore(s => s.completeProgram)
|
||||
|
||||
const timer = useTabataTimer(session)
|
||||
const music = useMusicPlayer({
|
||||
vibe: session.musicVibe ?? 'electronic',
|
||||
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'WARMUP',
|
||||
})
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
|
||||
|
||||
const phaseColor = TABATA_PHASE_COLORS[timer.phase] ?? PHASE.WORK
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
timer.start()
|
||||
haptics.buttonTap()
|
||||
track('tabata_session_started', { session_id: session.id, blocks: session.blocks.length })
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (timer.isPaused) {
|
||||
timer.resume()
|
||||
track('tabata_session_resumed', { session_id: session.id })
|
||||
} else {
|
||||
timer.pause()
|
||||
track('tabata_session_paused', { session_id: session.id })
|
||||
}
|
||||
haptics.selection()
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const stopWorkout = useCallback(() => {
|
||||
haptics.phaseChange()
|
||||
timer.stop()
|
||||
router.back()
|
||||
}, [router, timer, haptics])
|
||||
|
||||
const completeWorkout = useCallback(() => {
|
||||
haptics.workoutComplete()
|
||||
track('tabata_session_completed', {
|
||||
session_id: session.id,
|
||||
calories: timer.calories,
|
||||
total_rounds: timer.totalRounds,
|
||||
blocks: timer.totalBlocks,
|
||||
})
|
||||
addWorkoutResult({
|
||||
id: Date.now().toString(),
|
||||
workoutId: session.id,
|
||||
completedAt: Date.now(),
|
||||
calories: timer.calories,
|
||||
durationMinutes: session.totalDuration,
|
||||
rounds: timer.totalRounds,
|
||||
completionRate: 1,
|
||||
})
|
||||
// Mark session complete in program store
|
||||
if (session.id.startsWith('wp-')) {
|
||||
const programId = session.id.slice(3)
|
||||
completeProgram(programId)
|
||||
} else {
|
||||
const programId = getSessionProgramId(session.id)
|
||||
if (programId) {
|
||||
completeSession(programId, session.id)
|
||||
}
|
||||
}
|
||||
router.replace(`/complete/${session.id}`)
|
||||
}, [router, session, timer, haptics, addWorkoutResult, completeSession, completeProgram])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
timer.skip()
|
||||
haptics.selection()
|
||||
}, [timer, haptics])
|
||||
|
||||
// ─── Animations & side-effects ───────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1, friction: 6, tension: 100, useNativeDriver: true,
|
||||
}).start()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
timerScaleAnim.setValue(0.9)
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1, friction: 4, tension: 150, useNativeDriver: true,
|
||||
}).start()
|
||||
haptics.phaseChange()
|
||||
if (timer.phase === 'COMPLETE') {
|
||||
audio.workoutComplete()
|
||||
} else if (timer.isRunning) {
|
||||
audio.phaseStart()
|
||||
}
|
||||
}, [timer.phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
|
||||
audio.countdownBeep()
|
||||
haptics.countdownTick()
|
||||
}
|
||||
}, [timer.timeRemaining])
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────
|
||||
|
||||
const isWarmup = timer.phase === 'WARMUP'
|
||||
const isCooldown = timer.phase === 'COOLDOWN'
|
||||
const isInterBlockRest = timer.phase === 'INTER_BLOCK_REST'
|
||||
const isBlockPhase = timer.phase === 'WORK' || timer.phase === 'REST'
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar hidden />
|
||||
<View style={[styles.phaseBg, { backgroundColor: phaseColor }]} />
|
||||
|
||||
<Pressable style={styles.content} onPress={() => setShowControls(s => !s)}>
|
||||
{/* Header */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
|
||||
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
<Icon name="xmark" size={24} tintColor={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.title}>{session.title}</Text>
|
||||
<Text style={styles.subtitle}>Semaine {session.week} · Séance {session.order}</Text>
|
||||
</View>
|
||||
<View style={styles.closeBtn} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats overlay */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && !isWarmup && !isCooldown && (
|
||||
<View style={styles.statsContainer}>
|
||||
<StatsOverlay
|
||||
calories={timer.calories}
|
||||
heartRate={null}
|
||||
elapsedRounds={timer.currentRound - 1}
|
||||
totalRounds={timer.totalRounds}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Warmup/Cooldown overlay */}
|
||||
{(isWarmup || isCooldown) && timer.currentWarmupMovement && (
|
||||
<WarmupOverlay
|
||||
movementName={isWarmup ? timer.currentWarmupMovement.name : (timer.currentCooldownMovement?.name ?? '')}
|
||||
movementIndex={isWarmup ? (timer as ReturnType<typeof useTabataTimer>).currentBlockIndex : 0}
|
||||
totalMovements={isWarmup ? session.warmup.movements.length : session.cooldown.movements.length}
|
||||
timeRemaining={timer.timeRemaining}
|
||||
isCooldown={isCooldown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inter-block rest */}
|
||||
{isInterBlockRest && (
|
||||
<View style={styles.interBlockContainer}>
|
||||
<Text style={styles.interBlockLabel}>RÉCUPÉRATION</Text>
|
||||
<Text style={styles.interBlockTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<BlockIndicator
|
||||
currentBlock={timer.currentBlockIndex}
|
||||
totalBlocks={timer.totalBlocks}
|
||||
/>
|
||||
<Text style={styles.interBlockNext}>
|
||||
Prochain: Bloc {timer.currentBlockIndex + 1}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Main timer ring for WORK/REST phases */}
|
||||
{isBlockPhase && (
|
||||
<>
|
||||
<BlockIndicator
|
||||
currentBlock={timer.currentBlockIndex}
|
||||
totalBlocks={timer.totalBlocks}
|
||||
/>
|
||||
<Animated.View style={[styles.timerContainer, { transform: [{ scale: timerScaleAnim }] }]}>
|
||||
<TimerRing progress={timer.progress} phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
|
||||
<View style={styles.timerInner}>
|
||||
<PhaseIndicator phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
|
||||
<Text selectable style={styles.timerTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<RoundIndicator current={timer.currentRound} total={session.blocks[timer.currentBlockIndex]?.rounds ?? 8} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Exercise + tabata tip */}
|
||||
<Text style={styles.exerciseName}>{timer.currentExercise?.name}</Text>
|
||||
<TabataTip tip={timer.currentConseil} visible={timer.phase === 'WORK'} />
|
||||
|
||||
<CoachEncouragement
|
||||
phase={timer.phase === 'WORK' ? 'WORK' : 'REST'}
|
||||
currentRound={timer.currentRound}
|
||||
totalRounds={timer.totalRounds}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Complete state */}
|
||||
{timer.isComplete && (
|
||||
<View style={styles.completeSection}>
|
||||
<Text style={styles.completeTitle}>Séance terminée !</Text>
|
||||
<Text style={[styles.completeSubtitle, { color: GREEN[500] }]}>Excellent travail</Text>
|
||||
<View style={styles.completeStats}>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalBlocks}</Text>
|
||||
<Text style={styles.completeStatLabel}>Blocs</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
|
||||
<Text style={styles.completeStatLabel}>Rounds</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.calories}</Text>
|
||||
<Text style={styles.completeStatLabel}>Calories</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Now Playing music pill */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && (
|
||||
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 100 }]}>
|
||||
<NowPlaying
|
||||
track={music.currentTrack}
|
||||
isReady={music.isReady}
|
||||
onSkipTrack={music.nextTrack}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{showControls && !timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<PlayerControls
|
||||
isRunning={timer.isRunning}
|
||||
isPaused={timer.isPaused}
|
||||
onStart={startTimer}
|
||||
onPause={() => { timer.pause(); haptics.selection() }}
|
||||
onResume={() => { timer.resume(); haptics.selection() }}
|
||||
onStop={stopWorkout}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Complete CTA */}
|
||||
{timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<Pressable style={styles.doneButton} onPress={completeWorkout}>
|
||||
<Text style={styles.doneButtonText}>Terminé</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: NAVY[900] },
|
||||
phaseBg: { ...StyleSheet.absoluteFillObject, opacity: 0.15 },
|
||||
content: { flex: 1 },
|
||||
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: SPACING[4] },
|
||||
closeBtn: { width: 44, height: 44, borderRadius: RADIUS.FULL, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderWidth: 1, borderColor: BORDER_COLORS.DIM, backgroundColor: NAVY[800] },
|
||||
headerCenter: { alignItems: 'center' },
|
||||
title: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
|
||||
subtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
|
||||
|
||||
statsContainer: { marginTop: SPACING[4], marginHorizontal: SPACING[4] },
|
||||
|
||||
timerContainer: { alignItems: 'center', justifyContent: 'center', marginTop: SPACING[6] },
|
||||
timerInner: { position: 'absolute', alignItems: 'center' },
|
||||
timerTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
|
||||
exerciseName: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, textAlign: 'center', marginTop: SPACING[4], marginHorizontal: SPACING[4] },
|
||||
|
||||
interBlockContainer: { alignItems: 'center', justifyContent: 'center', flex: 1 },
|
||||
interBlockLabel: { ...TYPOGRAPHY.FOOTNOTE, fontFamily: FONT_FAMILY.SANS_BOLD, letterSpacing: 2, color: AMBER[500], marginBottom: SPACING[2] },
|
||||
interBlockTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
interBlockNext: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[3] },
|
||||
|
||||
controls: { position: 'absolute', bottom: 0, left: 0, right: 0, alignItems: 'center' },
|
||||
nowPlayingContainer: { position: 'absolute', left: SPACING[6], right: SPACING[6] },
|
||||
|
||||
completeSection: { alignItems: 'center', marginTop: SPACING[8] },
|
||||
completeTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY },
|
||||
completeSubtitle: { ...TYPOGRAPHY.TITLE_3, marginTop: SPACING[1] },
|
||||
completeStats: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
|
||||
completeStat: { alignItems: 'center' },
|
||||
completeStatValue: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
completeStatLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1] },
|
||||
doneButton: { width: 200, height: 56, borderRadius: RADIUS.MD, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', backgroundColor: GREEN[500] },
|
||||
doneButtonText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 1 },
|
||||
})
|
||||
67
src/features/player/components/BlockIndicator.tsx
Normal file
67
src/features/player/components/BlockIndicator.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* BlockIndicator — Shows multi-block progress (e.g., "Block 2/3")
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, RADIUS } from '@/src/shared/constants'
|
||||
|
||||
interface BlockIndicatorProps {
|
||||
currentBlock: number // 0-based
|
||||
totalBlocks: number
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
export function BlockIndicator({ currentBlock, totalBlocks, accentColor = GREEN[500] }: BlockIndicatorProps) {
|
||||
if (totalBlocks <= 1) return null
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>
|
||||
Bloc {currentBlock + 1}/{totalBlocks}
|
||||
</Text>
|
||||
<View style={styles.dots}>
|
||||
{Array.from({ length: totalBlocks }).map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
backgroundColor: i < currentBlock
|
||||
? GREEN[500]
|
||||
: i === currentBlock
|
||||
? accentColor
|
||||
: TEXT.TERTIARY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontSize: 14,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
dots: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
dot: {
|
||||
width: SPACING[2],
|
||||
height: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface BurnBarProps {
|
||||
@@ -30,7 +31,7 @@ export function BurnBar({ currentCalories, avgCalories }: BurnBarProps) {
|
||||
{t('units.calUnit', { count: currentCalories })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.track, { backgroundColor: colors.border.glass }]}>
|
||||
<View style={[styles.track, { backgroundColor: colors.border.dim }]}>
|
||||
<View style={[styles.fill, { width: `${percentage}%` }]} />
|
||||
<View style={[styles.avg, { left: '50%', backgroundColor: colors.text.tertiary }]} />
|
||||
</View>
|
||||
@@ -54,18 +55,18 @@ const styles = StyleSheet.create({
|
||||
value: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '600',
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
track: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderRadius: RADIUS.XS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 3,
|
||||
borderRadius: RADIUS.XS,
|
||||
},
|
||||
avg: {
|
||||
position: 'absolute',
|
||||
|
||||
23
src/features/player/components/CLAUDE.md
Normal file
23
src/features/player/components/CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
|
||||
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
|
||||
| #5973 | 9:36 AM | 🟣 | WarmupOverlay component for exercise phases | ~325 |
|
||||
| #5971 | 9:35 AM | 🟣 | KineTip component created for physiotherapist advice display | ~293 |
|
||||
|
||||
### Apr 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6003 | 10:02 AM | 🔵 | WarmupOverlay Component for Exercise Phases | ~264 |
|
||||
| #6002 | " | 🔵 | BlockIndicator Component for Multi-Block Workouts | ~243 |
|
||||
| #6001 | " | 🔵 | KineTip Component Found for Physiotherapist Advice | ~235 |
|
||||
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
|
||||
</claude-mem-context>
|
||||
@@ -7,6 +7,8 @@ import { View, Pressable, StyleSheet, Animated } from 'react-native'
|
||||
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { BRAND_DANGER } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
|
||||
interface ControlButtonProps {
|
||||
@@ -45,8 +47,8 @@ export function ControlButton({
|
||||
variant === 'primary'
|
||||
? BRAND.PRIMARY
|
||||
: variant === 'danger'
|
||||
? '#FF3B30'
|
||||
: colors.border.glass
|
||||
? BRAND_DANGER
|
||||
: colors.border.dim
|
||||
|
||||
return (
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
@@ -77,6 +79,6 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 100,
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* NowPlaying — Floating pill showing current music track
|
||||
* Glass background, animated entrance, skip button
|
||||
* Solid navy background, animated entrance, skip button
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet, Animated } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors'
|
||||
import { darkColors } from '@/src/shared/theme'
|
||||
import type { MusicTrack } from '@/src/shared/services/music'
|
||||
|
||||
interface NowPlayingProps {
|
||||
@@ -68,13 +68,8 @@ export function NowPlaying({ track, isReady, onSkipTrack }: NowPlayingProps) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name="music.note" size={16} tintColor={BRAND.PRIMARY} />
|
||||
<Icon name="music.note" size={16} tintColor={GREEN[500]} />
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text numberOfLines={1} style={[styles.title, { color: colors.text.primary }]}>
|
||||
@@ -103,7 +98,8 @@ const styles = StyleSheet.create({
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: darkColors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
paddingVertical: SPACING[2],
|
||||
paddingHorizontal: SPACING[3],
|
||||
gap: SPACING[2],
|
||||
@@ -111,8 +107,8 @@ const styles = StyleSheet.create({
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: `${BRAND.PRIMARY}20`,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: GREEN.DIM,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -121,7 +117,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
fontWeight: '600',
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
},
|
||||
artist: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
@@ -44,7 +44,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
text: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface RoundIndicatorProps {
|
||||
@@ -38,7 +38,7 @@ const styles = StyleSheet.create({
|
||||
...TYPOGRAPHY.BODY,
|
||||
},
|
||||
current: {
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import { GREEN, NAVY, BORDER_COLORS, TEXT, BRAND_DANGER, AMBER } from '@/src/shared/constants/colors'
|
||||
|
||||
interface StatsOverlayProps {
|
||||
calories: number
|
||||
@@ -35,7 +34,6 @@ function StatItem({
|
||||
iconColor: string
|
||||
delay?: number
|
||||
}) {
|
||||
const colors = darkColors
|
||||
const scaleAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,11 +57,11 @@ function StatItem({
|
||||
<Icon name={icon as any} size={16} tintColor={iconColor} />
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.statValue, { color: colors.text.primary }]}
|
||||
style={[styles.statValue, { color: TEXT.PRIMARY }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.text.tertiary }]}>{label}</Text>
|
||||
<Text style={[styles.statLabel, { color: TEXT.TERTIARY }]}>{label}</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -75,39 +73,33 @@ export function StatsOverlay({
|
||||
totalRounds,
|
||||
}: StatsOverlayProps) {
|
||||
const { t } = useTranslation()
|
||||
const colors = darkColors
|
||||
const effort = totalRounds > 0
|
||||
? Math.round((elapsedRounds / totalRounds) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<StatItem
|
||||
value={String(calories)}
|
||||
label={t('screens:player.calories')}
|
||||
icon="flame.fill"
|
||||
iconColor={BRAND.PRIMARY}
|
||||
iconColor={GREEN[500]}
|
||||
delay={0}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border.glass }]} />
|
||||
<View style={[styles.divider, { backgroundColor: BORDER_COLORS.DIM }]} />
|
||||
<StatItem
|
||||
value={heartRate ? String(heartRate) : '--'}
|
||||
label="bpm"
|
||||
icon="heart.fill"
|
||||
iconColor="#FF3B30"
|
||||
iconColor={BRAND_DANGER}
|
||||
delay={100}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border.glass }]} />
|
||||
<View style={[styles.divider, { backgroundColor: BORDER_COLORS.DIM }]} />
|
||||
<StatItem
|
||||
value={`${effort}%`}
|
||||
label={t('screens:player.effort', { defaultValue: 'effort' })}
|
||||
icon="bolt.fill"
|
||||
iconColor="#FFD60A"
|
||||
iconColor={AMBER[500]}
|
||||
delay={200}
|
||||
/>
|
||||
</View>
|
||||
@@ -123,7 +115,8 @@ const styles = StyleSheet.create({
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: darkColors.border.glass,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
backgroundColor: NAVY[800],
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
@@ -135,7 +128,7 @@ const styles = StyleSheet.create({
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
fontWeight: '700',
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
50
src/features/player/components/TabataTip.tsx
Normal file
50
src/features/player/components/TabataTip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* TabataTip — Displays physiotherapist advice during exercise
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { NAVY, ORANGE, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, RADIUS, LAYOUT } from '@/src/shared/constants'
|
||||
|
||||
interface TabataTipProps {
|
||||
tip: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function TabataTip({ tip, visible }: TabataTipProps) {
|
||||
if (!visible || !tip) return null
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.icon}>📋</Text>
|
||||
<Text style={styles.tip} numberOfLines={3}>{tip}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.MD,
|
||||
padding: SPACING[3],
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
gap: SPACING[2],
|
||||
borderWidth: 1,
|
||||
borderColor: ORANGE.DIM,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 16,
|
||||
marginTop: SPACING[0],
|
||||
},
|
||||
tip: {
|
||||
flex: 1,
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
})
|
||||
@@ -62,7 +62,7 @@ export function TimerRing({
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={colors.border.glass}
|
||||
stroke={colors.border.dim}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
70
src/features/player/components/WarmupOverlay.tsx
Normal file
70
src/features/player/components/WarmupOverlay.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* WarmupOverlay — Displays warmup/cooldown movement with countdown
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { PHASE, GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface WarmupOverlayProps {
|
||||
movementName: string
|
||||
movementIndex: number
|
||||
totalMovements: number
|
||||
timeRemaining: number
|
||||
isCooldown?: boolean
|
||||
}
|
||||
|
||||
export function WarmupOverlay({
|
||||
movementName,
|
||||
movementIndex,
|
||||
totalMovements,
|
||||
timeRemaining,
|
||||
isCooldown = false,
|
||||
}: WarmupOverlayProps) {
|
||||
const label = isCooldown ? 'RETOUR AU CALME' : 'ÉCHAUFFEMENT'
|
||||
const color = isCooldown ? GREEN[500] : PHASE.PREP
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.phaseLabel, { color }]}>{label}</Text>
|
||||
<Text style={styles.progress}>{movementIndex + 1}/{totalMovements}</Text>
|
||||
<Text style={styles.movement}>{movementName}</Text>
|
||||
<Text style={styles.countdown}>{timeRemaining}s</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: SPACING[10],
|
||||
paddingVertical: SPACING[5],
|
||||
},
|
||||
phaseLabel: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontSize: 14,
|
||||
letterSpacing: 2,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
progress: {
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontSize: 13,
|
||||
color: TEXT.TERTIARY,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
movement: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontSize: 22,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
countdown: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontSize: 48,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user