Compare commits
20 Commits
edcd857c70
...
fix/health
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9943dce82d | ||
|
|
cf096f2068 | ||
|
|
d74c47b1a8 | ||
|
|
0f5b7b9e18 | ||
|
|
e28bebea79 | ||
|
|
9f15ae2d79 | ||
|
|
877f836f19 | ||
|
|
2413bc0356 | ||
|
|
89cca25e22 | ||
|
|
8c90b73d90 | ||
|
|
d4edf54aeb | ||
|
|
5888aac08e | ||
|
|
04b83fc419 | ||
|
|
13262305e5 | ||
|
|
3fe9d926ad | ||
|
|
d82205cd71 | ||
|
|
791f432334 | ||
|
|
e0e02c4550 | ||
|
|
0990ec8e11 | ||
|
|
4458044d0e |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@expo/cicd-workflows-skill",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"js-yaml": "^4.1.0"
|
||||
}
|
||||
}
|
||||
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
|
||||
```
|
||||
4
.env
4
.env
@@ -1,4 +0,0 @@
|
||||
# TabataFit Environment Variables
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://supabase.1000co.fr
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w
|
||||
13
.env.example
13
.env.example
@@ -1,13 +0,0 @@
|
||||
# TabataFit Environment Variables
|
||||
# Copy this file to .env and fill in your credentials
|
||||
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
# RevenueCat (Apple subscriptions)
|
||||
# Defaults to test_ sandbox key if not set
|
||||
EXPO_PUBLIC_REVENUECAT_API_KEY=your_revenuecat_api_key
|
||||
|
||||
# Admin Dashboard (optional - for admin authentication)
|
||||
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ coverage/
|
||||
|
||||
# Node compile cache
|
||||
node-compile-cache/
|
||||
.gitnexus
|
||||
Config/Secrets.xcconfig
|
||||
|
||||
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: tabatago-production-tracker
|
||||
description: >
|
||||
Inventaire complet des features et tracker de production pour l'application TabataGo.
|
||||
Utilise ce skill pour savoir si l'app est prête pour la production, connaître le statut
|
||||
de chaque feature, créer des tickets, mettre à jour la progression, ou générer un rapport
|
||||
de production-readiness. Déclenche ce skill dès que l'utilisateur mentionne : "prêt pour
|
||||
la prod", "statut des features", "qu'est-ce qu'il reste à faire", "production checklist",
|
||||
"feature inventory", "roadmap", ou demande si une feature spécifique de TabataGo est
|
||||
implementée. Ce skill est la source de vérité pour savoir ce qui est fait, ce qui est
|
||||
en cours, et ce qui bloque la mise en production.
|
||||
---
|
||||
|
||||
# TabataGo — Feature Inventory & Production Readiness
|
||||
|
||||
## Comment utiliser ce skill
|
||||
|
||||
1. **Rapport de statut** → Lire la section FEATURE INVENTORY et afficher un tableau de bord visuel
|
||||
2. **Mettre à jour une feature** → Modifier son statut dans FEATURE INVENTORY ci-dessous
|
||||
3. **Savoir si on est prêt pour la prod** → Lire la section PRODUCTION GATE CONDITIONS
|
||||
4. **Ajouter une feature** → Ajouter une entrée dans la bonne épopée
|
||||
|
||||
Statuts possibles :
|
||||
- `[ ]` — À faire
|
||||
- `[~]` — En cours
|
||||
- `[x]` — Fait et testé
|
||||
- `[!]` — Bloquant / Problème
|
||||
|
||||
---
|
||||
|
||||
## FEATURE INVENTORY
|
||||
|
||||
### ÉPOPÉE 1 — FUNNEL D'ACQUISITION (Onboarding)
|
||||
|
||||
> **Note :** L'onboarding est implémenté comme un funnel 6 écrans dans `app/onboarding.tsx` (1342 lignes) :
|
||||
> ProblemScreen → EmpathyScreen → SolutionScreen → WowScreen → PersonalizationScreen → PaywallScreen.
|
||||
> Les US-01 à US-04 sont mappées sur ces 6 écrans.
|
||||
|
||||
#### US-01 · Écran Problème (Pain Point)
|
||||
**Story :** En tant que nouvel utilisateur, je vois en premier l'écran qui met en évidence mon problème (manque de temps, pas de salle, manque de motivation) afin de me sentir compris avant qu'on me propose une solution.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-001 | Écran hero avec headline "Le sport sans excuses" | `[x]` | P0 | `ProblemScreen` avec animations clock + text |
|
||||
| F-002 | 3 pain points illustrés (pas de temps / pas d'équipement / pas de motivation) | `[x]` | P0 | `EmpathyScreen` — barriers grid avec icônes |
|
||||
| F-003 | CTA "Commencer" → navigation vers choix raison | `[x]` | P0 | `onNext()` entre chaque step |
|
||||
| F-004 | Animation d'entrée (FadeIn + SlideUp) | `[x]` | P1 | Spring + timing animations avec Animated API |
|
||||
|
||||
#### US-02 · Écran Raison (Why Screen)
|
||||
**Story :** En tant que nouvel utilisateur, je sélectionne ma raison principale de ne pas faire de sport afin que l'app me propose un parcours personnalisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-005 | Sélection parmi : Pas le temps / Ne sais pas comment / Pas d'équipement / Manque de motivation | `[x]` | P0 | `EmpathyScreen` — 4 barrier options avec multi-select (max 2) |
|
||||
| F-006 | Chaque option avec icône + label + sous-titre court | `[x]` | P0 | `barrierCard` avec icône + i18n labels |
|
||||
| F-007 | Enregistrement du choix dans le profil local | `[x]` | P0 | Via `useUserStore` + `completeOnboarding({barriers})` |
|
||||
| F-008 | Progression (barre step 1/4) | `[x]` | P1 | TOTAL_STEPS = 6, step indicator intégré |
|
||||
|
||||
#### US-03 · Écran Solution (Tabata Pitch)
|
||||
**Story :** En tant qu'utilisateur ayant identifié son problème, je découvre comment Tabata résout précisément MON problème afin d'être convaincu de continuer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-009 | Affichage dynamique selon la raison choisie (US-02) | `[x]` | P0 | `SolutionScreen` — contexte basé sur barriers |
|
||||
| F-010 | Explication Tabata : 20s effort / 10s repos / 8 rounds / 4 min | `[x]` | P0 | Dans `SolutionScreen` |
|
||||
| F-011 | 3 avantages clés avec icônes (scientifiquement prouvé, sans matériel, court) | `[x]` | P0 | Avec icônes SF Symbols |
|
||||
| F-012 | CTA "Je veux essayer" → navigation vers config profil | `[x]` | P0 | `onNext()` → PersonalizationScreen |
|
||||
| F-013 | Animation des bénéfices (StaggerList) | `[x]` | P2 | `WowScreen` — stagger animation rows + CTA fadeIn |
|
||||
|
||||
#### US-04 · Configuration Profil
|
||||
**Story :** En tant que nouvel utilisateur convaincu, je renseigne mon prénom et mon objectif afin que l'app me personnalise l'expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-014 | Champ prénom (TextField) avec validation non-vide | `[x]` | P0 | `PersonalizationScreen` — TextInput |
|
||||
| F-015 | Choix nombre de séances/semaine (1 à 6, picker ou slider) | `[x]` | P0 | `weeklyFrequency` picker |
|
||||
| F-016 | Choix objectif : Forme générale / Cardio / Énergie / Perte de poids / Renforcement | `[x]` | P0 | `fitnessGoal` selector |
|
||||
| F-017 | Enregistrement en AsyncStorage / profil local | `[x]` | P0 | Via Zustand `userStore` persist middleware |
|
||||
| F-018 | Validation et navigation vers Paywall | `[x]` | P0 | Navigates to `PaywallScreen` (step 6) then `router.push('/paywall')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 2 — PAYWALL & MONÉTISATION
|
||||
|
||||
#### US-05 · Paywall
|
||||
**Story :** En tant qu'utilisateur ayant complété le funnel, je vois une offre claire et honnête avant d'accéder au contenu complet, afin de prendre une décision éclairée.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-019 | Écran paywall avec structure obligatoire : célébration → valeur → pricing → CTA | `[x]` | P0 | `app/paywall.tsx` (442 lignes) — PREMIUM_FEATURES list + PlanCard + CTA |
|
||||
| F-020 | Intégration RevenueCat (iOS App Store + Google Play) | `[x]` | P0 | `react-native-purchases` + `usePurchases` hook + `purchases.ts` service |
|
||||
| F-021 | Prix dynamique selon la localisation (géolocalisation ou store) | `[x]` | P0 | RevenueCat gère automatiquement via store pricing |
|
||||
| F-022 | Offre annuelle (prix mensuel affiché) + option mensuelle | `[x]` | P0 | `hasAnnual: true, hasMonthly: true` — PlanCard component |
|
||||
| F-023 | Essai gratuit 7 jours (configurable RevenueCat) | `[x]` | P0 | Trial support intégré dans paywall |
|
||||
| F-024 | CTA "Démarrer l'essai gratuit" (PrimaryButton full-width) | `[x]` | P0 | NativeButton CTA |
|
||||
| F-025 | Bouton fermeture toujours visible (accès mode free) | `[x]` | P0 | `closeButton` positionné avec `top: insets.top` |
|
||||
| F-026 | Lien "Restaurer les achats" | `[x]` | P0 | `handleRestore` → `restorePurchases()` |
|
||||
| F-027 | Lien CGU + Politique de confidentialité | `[x]` | P0 | Privacy + Terms links dans paywall, `app/terms.tsx` créé |
|
||||
| F-028 | Gestion état Premium (hook usePremium) | `[x]` | P0 | `usePurchases` hook avec state management |
|
||||
| F-029 | Zéro dark pattern (pas de compte à rebours fictif) | `[x]` | P0 | Aucun countdown/timer/urgency détecté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 3 — ONBOARDING CONTENU (post-paywall)
|
||||
|
||||
#### US-06 · Découverte des 3 Sections
|
||||
**Story :** En tant que nouvel abonné, je découvre les 3 grandes sections de l'app afin de comprendre comment le contenu est organisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-030 | Écran onboarding 3 sections avec swipe/pagination | `[x]` | P0 | `app/discovery.tsx` — 3 SectionCards animées |
|
||||
| F-031 | Section "Haut du corps" — illustration + description | `[x]` | P0 | Discovery screen — Upper Body avec icône + description |
|
||||
| F-032 | Section "Bas du corps" — illustration + description | `[x]` | P0 | Discovery screen — Lower Body avec icône + description |
|
||||
| F-033 | Section "Corps complet" — illustration + description | `[x]` | P0 | Discovery screen — Full Body avec icône + description |
|
||||
| F-034 | CTA "Explorer les programmes" → Tab Programmes | `[x]` | P0 | CTA → `router.replace('/(tabs)')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 4 — PROGRAMMES & CATALOGUE
|
||||
|
||||
#### US-07 · Liste des Programmes
|
||||
**Story :** En tant qu'utilisateur connecté, je consulte le catalogue de programmes organisé par section et niveau afin de choisir celui qui correspond à mon niveau.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-035 | Écran Programmes avec onglets : Haut / Bas / Corps complet | `[~]` | P0 | Home tab a `BodyZoneCard` — pas d'onglets dédiés mais navigation par zone |
|
||||
| F-036 | CardProgram pour chaque programme (thumbnail, nom, level, durée, nb séances) | `[x]` | P0 | WorkoutCard + programme cards dans home |
|
||||
| F-037 | Filtre par niveau : Débutant / Intermédiaire / Avancé | `[ ]` | P0 | **MANQUANT** — Pas de filtres niveau |
|
||||
| F-038 | Progression visible sur chaque card (X/12 séances) | `[x]` | P0 | Progress tracking dans `programStore` + `workoutProgramStore` |
|
||||
| F-039 | Programme "recommandé" selon objectif choisi au profil | `[ ]` | P1 | Pas implémenté |
|
||||
| F-040 | Lock icon sur contenu premium (mode free) | `[x]` | P0 | `sessionLocked` dans `program/[id].tsx` |
|
||||
|
||||
#### US-08 · Détail d'un Programme
|
||||
**Story :** En tant qu'utilisateur, je consulte le détail d'un programme afin de savoir ce qui m'attend avant de commencer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-041 | Écran détail : header image, nom, niveau, durée totale | `[x]` | P0 | `app/program/[id].tsx` (261 lignes) |
|
||||
| F-042 | Description du programme et objectifs | `[x]` | P0 | Programme detail avec description |
|
||||
| F-043 | Liste ordonnée des séances avec statut (fait / à venir) | `[x]` | P0 | `week.sessions.map` avec `isCompleted` check |
|
||||
| F-044 | CTA "Commencer la prochaine séance" | `[x]` | P0 | `getCurrentSession` → `router.push(/workout/...)` |
|
||||
| F-045 | Progression globale du programme (progress bar) | `[x]` | P0 | Progress tracking intégré |
|
||||
|
||||
#### US-09 · Structure des Programmes
|
||||
**Story :** En tant que product owner, chaque section dispose de 3 niveaux avec des séances progressives.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-046 | Haut du corps — Débutant (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` |
|
||||
| F-047 | Haut du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` |
|
||||
| F-048 | Haut du corps — Avancé (8–12 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` |
|
||||
| F-049 | Bas du corps — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-050 | Bas du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-051 | Bas du corps — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
| F-052 | Corps complet — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-053 | Corps complet — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-054 | Corps complet — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 5 — SÉANCE TABATA (Core Feature)
|
||||
|
||||
#### US-10 · Phase d'Échauffement
|
||||
**Story :** En tant qu'utilisateur sur le point de commencer une séance, je passe par une phase d'échauffement guidée avec les exercices expliqués visuellement afin de me préparer sans risque de blessure.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-055 | Écran échauffement avec liste des exercices de la séance | `[x]` | P0 | `WarmupOverlay` component |
|
||||
| F-056 | Fiche exercice : nom, muscles ciblés, description gestuelle | `[x]` | P0 | Exercise display dans warmup |
|
||||
| F-057 | Vidéo de démonstration en boucle (coach IA) par exercice | `[!]` | P0 | **BLOQUANT** — Code prêt (`VideoPlayer`) mais vidéos non créées |
|
||||
| F-058 | Conseil kiné (CardTip) pour chaque exercice | `[x]` | P0 | `TabataTip` component |
|
||||
| F-059 | Minuteur échauffement (ex: 5 min) avec CTA skip | `[x]` | P1 | WARMUP phase dans useTabataTimer |
|
||||
| F-060 | Navigation entre les exercices de l'échauffement (swipe ou bouton) | `[x]` | P0 | Dans WarmupOverlay |
|
||||
| F-061 | CTA "Je suis prêt(e)" → lancement séance | `[x]` | P0 | Transition WARMUP → WORK |
|
||||
|
||||
#### US-11 · Séance Tabata (Timer)
|
||||
**Story :** En tant qu'utilisateur en séance, je vois clairement la phase (effort / repos), le bloc et le round en cours afin de suivre ma séance sans regarder mon téléphone de près.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-062 | Timer géant (≥80px) : vert (effort 20s) / slate (repos 10s) / rouge (<10s) | `[x]` | P0 | `TimerRing` component avec PHASE_COLORS |
|
||||
| F-063 | Moteur de séquence : 3 blocs × 2 exercices × 3 rounds, alternance correcte | `[x]` | P0 | `useTabataTimer` — phases WARMUP/WORK/REST/INTER_BLOCK_REST/COOLDOWN/COMPLETE |
|
||||
| F-064 | Vidéo exercice plein écran en boucle (phase effort) | `[!]` | P0 | **BLOQUANT** — Code prêt mais vidéos non créées |
|
||||
| F-065 | Fond navy-800 uni (phase repos court 10s) | `[x]` | P0 | `TABATA_PHASE_COLORS.REST: PHASE.REST` |
|
||||
| F-066 | Écran repos long entre blocs (~60s) avec indication "PROCHAIN BLOC" | `[x]` | P0 | `INTER_BLOCK_REST` phase avec `AMBER[500]` |
|
||||
| F-067 | Indicateur de position : Bloc X/3 + Round Y/3 + Exercice A ou B | `[x]` | P0 | `BlockIndicator` + `RoundIndicator` + exercise name |
|
||||
| F-068 | Nom de l'exercice en cours en overlay | `[x]` | P0 | `ExerciseDisplay` component |
|
||||
| F-069 | Aperçu prochain exercice pendant le repos (thumbnail + nom) | `[x]` | P0 | Next preview dans player |
|
||||
| F-070 | Progress bar séance globale (sur les 18 intervalles) | `[x]` | P0 | `BurnBar` + `StatsOverlay` |
|
||||
| F-071 | Son (bip effort, bip repos, countdown 3-2-1, gong repos long) | `[x]` | P0 | `useAudio` + assets: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare |
|
||||
| F-072 | Toggle mute accessible en 1 tap | `[x]` | P0 | Mute toggle dans PlayerControls, volume 0 sur musique + SFX |
|
||||
| F-073 | Vibration haptique aux transitions effort/repos et fin de bloc | `[x]` | P1 | `useHaptics` intégré dans le player |
|
||||
| F-074 | Bouton pause (centré, accessible) | `[x]` | P0 | Dans `PlayerControls` — pause/resume |
|
||||
| F-075 | Bouton quitter (DangerButton, confirmation requise) | `[x]` | P0 | `Alert.alert` confirmation dans PlayerControls `handleQuit` |
|
||||
| F-076 | Pas de mise en veille écran pendant la séance (keepAwake) | `[x]` | P0 | `useKeepAwake()` dans les deux player screens |
|
||||
| F-077 | Gestion interruption (appel téléphonique → pause auto) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
#### US-12 · Fin de Séance
|
||||
**Story :** En tant qu'utilisateur ayant complété une séance, je vois un écran de célébration avec mes stats afin de me sentir accompli et motivé à revenir.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-078 | Écran célébration (BounceAnimation + serif italic) | `[x]` | P0 | `app/complete/[id].tsx` (712 lignes) — Animated celebrations |
|
||||
| F-079 | Stats : calories estimées, durée réelle, blocs complétés (X/3) | `[x]` | P0 | Calories + duration displayed |
|
||||
| F-080 | Marquage séance comme "completée" dans le programme | `[x]` | P0 | Via `activityStore.addWorkoutResult` |
|
||||
| F-081 | Feedback ressenti (FeedbackButton : Dur / Parfait / Trop facile) | `[x]` | P0 | 3 feedback chips dans `app/complete/[id].tsx` |
|
||||
| F-082 | Mise à jour streak hebdomadaire | `[x]` | P0 | `activityStore` streak calculation |
|
||||
| F-083 | CTA "Prochaine séance" + CTA "Retour accueil" | `[x]` | P0 | Next workout + return home CTAs |
|
||||
| F-084 | Conseil kiné post-séance (étirements recommandés) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 6 — ACCUEIL & DASHBOARD
|
||||
|
||||
#### US-13 · Écran Accueil
|
||||
**Story :** En tant qu'utilisateur régulier, je vois en un coup d'œil mon prochain entraînement et ma progression cette semaine afin de rester motivé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-085 | Greeting personnalisé "Bonjour [Prénom]" | `[x]` | P0 | `app/(tabs)/index.tsx` (463 lignes) — greeting avec nom |
|
||||
| F-086 | CardAccent "Prochaine séance" avec nom programme + CTA | `[x]` | P0 | `ContinueSessionCard` component |
|
||||
| F-087 | Streak hebdomadaire (StreakDot × 7 jours) | `[x]` | P0 | Streak display intégré |
|
||||
| F-088 | Stat rapide : séances faites cette semaine vs objectif | `[x]` | P0 | `QuickStats` component avec weekly stats |
|
||||
| F-089 | Section "Reprendre là où j'en suis" | `[x]` | P0 | `ContinueSessionCard` — reprise de programme |
|
||||
| F-090 | Notifications de rappel configurables | `[~]` | P1 | `expo-notifications` installé, `useNotifications` hook existe, toggle dans profile |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 7 — PROGRESSION & STATISTIQUES
|
||||
|
||||
#### US-14 · Écran Progression
|
||||
**Story :** En tant qu'utilisateur fidèle, je consulte mon historique et mes statistiques afin de voir ma progression concrète dans le temps.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-091 | Historique des séances (liste chronologique) | `[x]` | P0 | `app/(tabs)/activity.tsx` (581 lignes) — history list |
|
||||
| F-092 | Graphique hebdomadaire (barres ou ligne) | `[x]` | P1 | `WeeklyBar` component |
|
||||
| F-093 | Totaux : séances totales, minutes totales, calories totales | `[x]` | P0 | `StatCard` × 4 avec totaux |
|
||||
| F-094 | Record streak (max consécutif) | `[x]` | P1 | `activityStore.streak.longest` |
|
||||
| F-095 | Progression par programme (% complété) | `[~]` | P0 | Progression dans `programStore` mais pas affichée dans activity tab |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 8 — PROFIL & PARAMÈTRES
|
||||
|
||||
#### US-15 · Écran Profil
|
||||
**Story :** En tant qu'utilisateur, je peux modifier mon profil et gérer mon abonnement afin de garder le contrôle sur mon expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-096 | Édition prénom, objectif, nb séances/semaine | `[~]` | P0 | Personalization section exists mais **édition directe non confirmée** |
|
||||
| F-097 | Statut abonnement (Free / Premium) avec date renouvellement | `[x]` | P0 | Subscription status affiché |
|
||||
| F-098 | Bouton gérer/annuler abonnement (lien store) | `[~]` | P0 | À vérifier — manage subscription pas confirmé |
|
||||
| F-099 | Toggle notifications | `[x]` | P1 | `NativeLabeledRow` avec daily reminders toggle |
|
||||
| F-100 | Liens : CGU, Politique de confidentialité, Contact support | `[x]` | P0 | Terms + Privacy dans paywall, CGU row dans profile, contact ✓ |
|
||||
| F-101 | Version de l'app | `[x]` | P0 | Version affichée dans about section |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 9 — INFRASTRUCTURE & QUALITÉ
|
||||
|
||||
#### US-16 · Architecture Technique
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-102 | React Native + Expo SDK setup | `[x]` | P0 | Expo SDK 54, React Native 0.81.5, React 19.1 |
|
||||
| F-103 | Navigation (Expo Router ou React Navigation) | `[x]` | P0 | Expo Router v6 avec tabs (index/activity/profile) |
|
||||
| F-104 | Design tokens implémentés (design-tokens.ts) | `[x]` | P0 | `colors.ts`, `typography.ts`, `spacing.ts`, `borderRadius.ts`, `animations.ts`, `ThemeContext` |
|
||||
| F-105 | State management (Zustand ou Context) | `[x]` | P0 | Zustand v5 — 6 stores: user, activity, player, program, tabataProgram, workoutProgram |
|
||||
| F-106 | Persistence locale (AsyncStorage) | `[x]` | P0 | AsyncStorage + React Query persist |
|
||||
| F-107 | RevenueCat SDK intégré (iOS + Android) | `[x]` | P0 | `react-native-purchases` v9 + service + hook + tests |
|
||||
| F-108 | Gestion erreurs globale (ErrorBoundary) | `[x]` | P0 | ErrorBoundary class dans `app/_layout.tsx` avec retry |
|
||||
| F-109 | Analytics (Mixpanel ou PostHog) — events funnel + rétention | `[x]` | P1 | PostHog + session replay, `track()` calls dans onboarding/player/complete |
|
||||
|
||||
#### US-17 · Contenu & Assets
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-110 | Vidéos coach IA — Haut du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-111 | Vidéos coach IA — Bas du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-112 | Vidéos coach IA — Corps complet (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-113 | Sons : bip effort, bip repos, countdown, gong repos long, fanfare fin | `[x]` | P0 | 10 fichiers audio: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare, countdown.wav, complete.wav, phase-start.wav + 2 musiques |
|
||||
| F-114 | Illustrations sections (onboarding) | `[~]` | P1 | Discovery screen utilise des icônes SF Symbols, pas d'illustrations custom |
|
||||
| F-115 | Icônes app (toutes tailles iOS + Android) | `[~]` | P0 | Icons existent (icon.png, android-icon-*, favicon) mais **contiennent encore des logos React placeholder** |
|
||||
| F-116 | Splash screen | `[x]` | P0 | `expo-splash-screen` + splash-icon.png |
|
||||
|
||||
#### US-18 · Tests & QA
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-117 | Tests unitaires moteur de séquence tabata (3 blocs × 2 exercices × 3 rounds) | `[x]` | P0 | `tabataProgramStore.test.ts` + `playerStore.test.ts` |
|
||||
| F-118 | Tests unitaires timer (20s effort / 10s repos / repos long inter-blocs) | `[x]` | P0 | `useTimer.test.ts` + `useTimer.integration.test.ts` |
|
||||
| F-119 | Tests unitaires RevenueCat (purchase flow) | `[x]` | P0 | `usePurchases.test.ts` + `purchases.test.ts` |
|
||||
| F-120 | Tests E2E funnel complet (Detox ou Maestro) | `[ ]` | P1 | Non implémenté |
|
||||
| F-121 | Test sur iPhone SE (petit écran) | `[ ]` | P0 | Non vérifié |
|
||||
| F-122 | Test sur Android (Samsung Galaxy S-series) | `[ ]` | P0 | Non vérifié |
|
||||
| F-123 | Test mode avion (gestion offline) | `[ ]` | P1 | Non vérifié |
|
||||
|
||||
#### US-19 · Store & Publication
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-124 | Screenshots App Store (6.5" + 5.5" + iPad) | `[ ]` | P0 | Non créé |
|
||||
| F-125 | Screenshots Google Play | `[ ]` | P0 | Non créé |
|
||||
| F-126 | Description App Store (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-127 | Description Google Play (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-128 | Politique de confidentialité publiée (URL) | `[~]` | P0 | `app/privacy.tsx` existe mais pas publié en URL externe |
|
||||
| F-129 | CGU publiées (URL) | `[~]` | P0 | `app/terms.tsx` existe in-app, URL externe non publiée |
|
||||
| F-130 | Soumission TestFlight (iOS) | `[ ]` | P0 | Non soumis |
|
||||
| F-131 | Soumission Google Play Beta (Android) | `[ ]` | P0 | Non soumis |
|
||||
| F-132 | Review Apple (délai ~2-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
| F-133 | Review Google Play (délai ~1-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
|
||||
---
|
||||
|
||||
## PRODUCTION GATE CONDITIONS
|
||||
|
||||
Pour que TabataGo soit considéré **prêt pour la production**, les conditions suivantes doivent être remplies :
|
||||
|
||||
### BLOQUANTS ABSOLUS (toutes les P0 doivent être `[x]`)
|
||||
|
||||
- [x] **FUNNEL COMPLET** : F-001 à F-018 tous `[x]`
|
||||
- [x] **PAYWALL FONCTIONNEL** : F-019 à F-028 — Tous complétés (CGU + privacy links ajoutés)
|
||||
- [x] **AU MOINS 1 PROGRAMME COMPLET** par section (Débutant minimum) : F-046, F-049, F-052 `[x]`
|
||||
- [x] **SÉANCE TABATA CORE** : F-062 à F-076 — Mute toggle + quit confirmation ajoutés
|
||||
- [x] **FIN DE SÉANCE** : F-078 à F-083 — Feedback buttons ajoutés
|
||||
- [ ] **LÉGAL** : F-128 partiellement, F-129 manquant, F-130/131 non soumis
|
||||
- [ ] **CONTENU** : F-110 à F-112 vidéos non créées
|
||||
|
||||
### REQUIS AVANT SCALE (peuvent être post-lancement v1.1)
|
||||
|
||||
- Notifications (F-090) — en cours
|
||||
- Analytics (F-109) — fait (PostHog)
|
||||
- Tests E2E (F-120) — non fait
|
||||
- Programmes Avancés (F-048, F-051, F-054) — fait
|
||||
- Gestion offline (F-123) — non vérifié
|
||||
|
||||
---
|
||||
|
||||
## COMMENT AFFICHER LE STATUT
|
||||
|
||||
Quand l'utilisateur demande le statut de production, générer un tableau de bord avec :
|
||||
|
||||
1. **Score global** : `[x]` / total features P0
|
||||
2. **Score par épopée** : tableau avec % de complétion
|
||||
3. **Features bloquantes** : liste des P0 encore à `[ ]` ou `[!]`
|
||||
4. **Verdict** : PRET / PRESQUE PRET (X bloquants) / NON PRET (X bloquants)
|
||||
|
||||
---
|
||||
|
||||
## NOTES DE MISE À JOUR
|
||||
|
||||
| Date | Changement | Auteur |
|
||||
|------|-----------|--------|
|
||||
| 2026-04-18 | Création initiale de l'inventaire (130 features) | TabataGo Team |
|
||||
| 2026-04-18 | Mise à jour structure séance : 3 blocs × 2 exercices × 3 rounds (133 features) | TabataGo Team |
|
||||
| 2026-04-18 | Audit complet code vs tracker — 85+ features marquées done, gaps identifiés | OpenCode Audit |
|
||||
| 2026-04-18 | Implémentation : ErrorBoundary, feedback buttons, mute/quit, legal links, discovery screen, i18n DE/ES | OpenCode |
|
||||
| 2026-04-18 | Mise à jour tracker : F-027/030-034/072/075/081/100/108 → [x], groupes paywall/séance/fin → [x] | OpenCode |
|
||||
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 -->
|
||||
|
||||
112
README.md
112
README.md
@@ -1,112 +0,0 @@
|
||||
# TabataFit
|
||||
|
||||
> **Apple Fitness+ for Tabata** — The Premium HIIT Experience
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Vision
|
||||
|
||||
TabataFit est l'Apple Fitness+ du Tabata. Une expérience premium, video-first, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 **Video-led workouts** — HD video demonstrations by professional trainers
|
||||
- ⏱️ **Smart timer** — Tabata timer with work/rest phases
|
||||
- 🔥 **Burn Bar** — Compare your calories with the community
|
||||
- 📊 **Activity tracking** — Streaks, stats, and trends
|
||||
- 🎵 **Music sync** — Curated playlists for each workout
|
||||
- ⌚ **Apple Watch** — Heart rate and activity rings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Expo SDK 52
|
||||
- **Navigation**: Expo Router v3
|
||||
- **State**: Zustand
|
||||
- **Video**: expo-av (HLS streaming)
|
||||
- **Payments**: RevenueCat
|
||||
- **Analytics**: PostHog
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npx expo start
|
||||
|
||||
# Run on device (scan QR with Expo Go)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [PRD v2.0](./TabataFit_PRD_v2.0.md) | Product Requirements |
|
||||
| [PDD v2.0](./TabataFit_PDD_v2.0.md) | Product Design |
|
||||
| [BDSD v2.0](./TabataFit_BDSD_v2.0.md) | Brand Design |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
features/
|
||||
home/ # Home tab
|
||||
workouts/ # Workouts browser
|
||||
player/ # Video player + timer
|
||||
activity/ # Stats & history
|
||||
browse/ # Filters & trainers
|
||||
profile/ # User settings
|
||||
shared/
|
||||
components/ # Reusable UI
|
||||
hooks/ # Custom hooks
|
||||
constants/ # Design tokens
|
||||
app/ # Expo Router routes
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Unit tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Component render tests
|
||||
npm run test:render
|
||||
|
||||
# All unit + render tests
|
||||
npm test && npm run test:render
|
||||
|
||||
# Maestro E2E (requires Expo dev server + simulator)
|
||||
npm run test:maestro
|
||||
|
||||
# Admin-web tests
|
||||
cd admin-web && npm test # Unit tests
|
||||
cd admin-web && npm run test:e2e # Playwright E2E
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
| Layer | Target | Tests |
|
||||
|-------|--------|-------|
|
||||
| Stores | 80%+ | playerStore, activityStore, userStore, programStore |
|
||||
| Services | 80%+ | analytics, music, purchases, sync |
|
||||
| Hooks | 70%+ | useTimer, useHaptics, useAudio, usePurchases, useMusicPlayer, useNotifications, useSupabaseData |
|
||||
| Components | 50%+ | StyledText, VideoPlayer, WorkoutCard, GlassCard, CollectionCard, modals, Skeleton |
|
||||
| Data | 80%+ | achievements, collections, programs, trainers, workouts |
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- **Mobile (Maestro)**: Onboarding, tab navigation, program browse, workout player, activity, profile/settings
|
||||
- **Admin Web (Playwright)**: Auth, navigation, workouts CRUD, trainers, collections
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for HIIT lovers
|
||||
@@ -1,182 +0,0 @@
|
||||
# Supabase Music Storage Setup
|
||||
|
||||
This guide walks you through setting up the Supabase Storage bucket for music tracks in TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit loads background music from Supabase Storage based on workout `musicVibe` values. The music service organizes tracks by vibe folders.
|
||||
|
||||
## Step 1: Create the Storage Bucket
|
||||
|
||||
1. Go to your Supabase Dashboard → Storage
|
||||
2. Click **New bucket**
|
||||
3. Name: `music`
|
||||
4. Enable **Public access** (tracks are streamed to authenticated users)
|
||||
5. Click **Create bucket**
|
||||
|
||||
## Step 2: Set Up Folder Structure
|
||||
|
||||
Create folders for each music vibe:
|
||||
|
||||
```
|
||||
music/
|
||||
├── electronic/
|
||||
├── hip-hop/
|
||||
├── pop/
|
||||
├── rock/
|
||||
└── chill/
|
||||
```
|
||||
|
||||
### Via Dashboard:
|
||||
1. Open the `music` bucket
|
||||
2. Click **New folder** for each vibe
|
||||
3. Name folders exactly as the `MusicVibe` type values
|
||||
|
||||
### Via SQL (optional):
|
||||
```sql
|
||||
-- Storage folders are virtual in Supabase
|
||||
-- Just upload files with path prefixes like "electronic/track.mp3"
|
||||
```
|
||||
|
||||
## Step 3: Upload Music Tracks
|
||||
|
||||
### Supported Formats
|
||||
- MP3 (recommended)
|
||||
- M4A (AAC)
|
||||
- OGG (if needed)
|
||||
|
||||
### File Naming Convention
|
||||
Name files with artist and title for best results:
|
||||
```
|
||||
Artist Name - Track Title.mp3
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
Neon Dreams - Energy Pulse.mp3
|
||||
Urban Flow - Street Heat.mp3
|
||||
The Popstars - Summer Energy.mp3
|
||||
```
|
||||
|
||||
### Upload via Dashboard:
|
||||
1. Open a vibe folder (e.g., `electronic/`)
|
||||
2. Click **Upload files**
|
||||
3. Select your audio files
|
||||
4. Ensure the path shows `music/electronic/`
|
||||
|
||||
### Upload via CLI:
|
||||
```bash
|
||||
# Install Supabase CLI if not already installed
|
||||
npm install -g supabase
|
||||
|
||||
# Login
|
||||
supabase login
|
||||
|
||||
# Link your project
|
||||
supabase link --project-ref your-project-ref
|
||||
|
||||
# Upload tracks
|
||||
supabase storage upload music/electronic/ "path/to/your/tracks/*.mp3"
|
||||
```
|
||||
|
||||
## Step 4: Configure Storage Policies
|
||||
|
||||
### RLS Policy for Authenticated Users
|
||||
|
||||
Go to Supabase Dashboard → Storage → Policies → `music` bucket
|
||||
|
||||
Add these policies:
|
||||
|
||||
#### 1. Select Policy (Read Access)
|
||||
```sql
|
||||
CREATE POLICY "Allow authenticated users to read music"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'music');
|
||||
```
|
||||
|
||||
#### 2. Insert Policy (Admin Upload Only)
|
||||
```sql
|
||||
CREATE POLICY "Allow admin uploads"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'music'
|
||||
AND (auth.jwt() ->> 'role') = 'admin'
|
||||
);
|
||||
```
|
||||
|
||||
### Via Dashboard UI:
|
||||
1. Go to **Storage** → **Policies**
|
||||
2. Under `music` bucket, click **New policy**
|
||||
3. Select **For SELECT** (get)
|
||||
4. Allowed operation: **Authenticated**
|
||||
5. Policy definition: `true` (or custom check)
|
||||
|
||||
## Step 5: Test the Setup
|
||||
|
||||
1. Start your Expo app: `npx expo start`
|
||||
2. Start a workout with music enabled
|
||||
3. Check console logs for:
|
||||
```
|
||||
[Music] Loaded X tracks for vibe: electronic
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**No tracks loading:**
|
||||
- Check Supabase credentials in `.env`
|
||||
- Verify folder names match `MusicVibe` type exactly
|
||||
- Check Storage RLS policies allow read access
|
||||
|
||||
**Tracks not playing:**
|
||||
- Ensure files are accessible (try signed URL in browser)
|
||||
- Check audio format is supported by expo-av
|
||||
- Verify CORS settings in Supabase
|
||||
|
||||
**CORS errors:**
|
||||
Add to Supabase Dashboard → Settings → API → CORS:
|
||||
```
|
||||
app://*
|
||||
http://localhost:8081
|
||||
```
|
||||
|
||||
## Step 6: Environment Variables
|
||||
|
||||
Ensure your `.env` file has:
|
||||
|
||||
```env
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Music Vibes Reference
|
||||
|
||||
The app supports these vibe categories:
|
||||
|
||||
| Vibe | Description | Typical BPM |
|
||||
|------|-------------|-------------|
|
||||
| `electronic` | EDM, House, Techno | 128-140 |
|
||||
| `hip-hop` | Rap, Trap, Beats | 85-110 |
|
||||
| `pop` | Pop hits, Dance-pop | 100-130 |
|
||||
| `rock` | Rock, Alternative | 120-160 |
|
||||
| `chill` | Lo-fi, Ambient, Downtempo | 60-90 |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Track Duration**: 3-5 minutes ideal for Tabata workouts
|
||||
2. **File Size**: Keep under 10MB for faster loading
|
||||
3. **Bitrate**: 128-192kbps MP3 for good quality/size balance
|
||||
4. **Loudness**: Normalize tracks to similar levels (-14 LUFS)
|
||||
5. **Metadata**: Include ID3 tags for artist/title info
|
||||
|
||||
## Alternative: Local Development
|
||||
|
||||
If Supabase is not configured, the app uses mock tracks automatically. To force mock data, temporarily set invalid Supabase credentials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Upload initial track library (5-10 tracks per vibe)
|
||||
- [ ] Test on physical device
|
||||
- [ ] Consider CDN for production scale
|
||||
- [ ] Implement track favoriting/personal playlists
|
||||
@@ -1,228 +0,0 @@
|
||||
# TabataFit Supabase Integration
|
||||
|
||||
This document explains how to set up and use the Supabase backend for TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit now uses Supabase as its backend for:
|
||||
- **Database**: Storing workouts, trainers, collections, programs, and achievements
|
||||
- **Storage**: Managing video files, thumbnails, and trainer avatars
|
||||
- **Authentication**: Admin dashboard access control
|
||||
- **Real-time**: Future support for live features
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Create Supabase Project
|
||||
|
||||
1. Go to [Supabase Dashboard](https://app.supabase.com)
|
||||
2. Create a new project
|
||||
3. Note your project URL and anon key
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
1. Go to your Supabase project's SQL Editor
|
||||
2. Open `supabase/migrations/001_initial_schema.sql`
|
||||
3. Run the entire script
|
||||
|
||||
This creates:
|
||||
- All necessary tables (workouts, trainers, collections, programs, achievements)
|
||||
- Storage buckets (videos, thumbnails, avatars)
|
||||
- Row Level Security policies
|
||||
- Triggers for auto-updating timestamps
|
||||
|
||||
### 4. Seed the Database
|
||||
|
||||
Run the seed script to populate your database with initial data:
|
||||
|
||||
```bash
|
||||
npx ts-node supabase/seed.ts
|
||||
```
|
||||
|
||||
This will import all 50 workouts, 5 trainers, 6 collections, 3 programs, and 8 achievements.
|
||||
|
||||
### 5. Set Up Admin User
|
||||
|
||||
1. Go to Supabase Dashboard → Authentication → Users
|
||||
2. Create a new user with email/password
|
||||
3. Go to SQL Editor and run:
|
||||
|
||||
```sql
|
||||
INSERT INTO admin_users (id, email, role)
|
||||
VALUES ('USER_UUID_HERE', 'admin@example.com', 'admin');
|
||||
```
|
||||
|
||||
Replace `USER_UUID_HERE` with the actual user UUID from step 2.
|
||||
|
||||
### 6. Configure Storage
|
||||
|
||||
In Supabase Dashboard → Storage:
|
||||
|
||||
1. Verify buckets exist: `videos`, `thumbnails`, `avatars`
|
||||
2. Set bucket privacy to public for all three
|
||||
3. Configure CORS if needed for your domain
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ React Native │────▶│ Supabase │────▶│ PostgreSQL │
|
||||
│ Client │ │ Client │ │ Database │
|
||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└──────────────▶│ Admin │
|
||||
│ Dashboard │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Supabase Client** (`src/shared/supabase/`)
|
||||
- `client.ts`: Configured Supabase client
|
||||
- `database.types.ts`: TypeScript definitions for tables
|
||||
|
||||
2. **Data Service** (`src/shared/data/dataService.ts`)
|
||||
- `SupabaseDataService`: Handles all database operations
|
||||
- Falls back to mock data if Supabase is not configured
|
||||
|
||||
3. **React Hooks** (`src/shared/hooks/useSupabaseData.ts`)
|
||||
- `useWorkouts`, `useTrainers`, `useCollections`, etc.
|
||||
- Automatic loading states and error handling
|
||||
|
||||
4. **Admin Service** (`src/admin/services/adminService.ts`)
|
||||
- CRUD operations for content management
|
||||
- File upload/delete for storage
|
||||
- Admin authentication
|
||||
|
||||
5. **Admin Dashboard** (`app/admin/`)
|
||||
- `/admin/login`: Authentication screen
|
||||
- `/admin`: Main dashboard with stats
|
||||
- `/admin/workouts`: Manage workouts
|
||||
- `/admin/trainers`: Manage trainers
|
||||
- `/admin/collections`: Manage collections
|
||||
- `/admin/media`: Storage management
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `trainers` | Trainer profiles with colors and avatars |
|
||||
| `workouts` | Workout definitions with exercises and metadata |
|
||||
| `collections` | Curated workout collections |
|
||||
| `collection_workouts` | Many-to-many link between collections and workouts |
|
||||
| `programs` | Multi-week workout programs |
|
||||
| `program_workouts` | Link between programs and workouts with week/day |
|
||||
| `achievements` | User achievement definitions |
|
||||
| `admin_users` | Admin dashboard access control |
|
||||
|
||||
### Storage Buckets
|
||||
|
||||
| Bucket | Purpose | Access |
|
||||
|--------|---------|--------|
|
||||
| `videos` | Workout videos | Public read, Admin write |
|
||||
| `thumbnails` | Workout thumbnails | Public read, Admin write |
|
||||
| `avatars` | Trainer avatars | Public read, Admin write |
|
||||
|
||||
## Using the App
|
||||
|
||||
### As a User
|
||||
|
||||
The app works seamlessly:
|
||||
- If Supabase is configured: Loads data from the cloud
|
||||
- If not configured: Falls back to local mock data
|
||||
|
||||
### As an Admin
|
||||
|
||||
1. Navigate to `/admin` in the app
|
||||
2. Sign in with your admin credentials
|
||||
3. Manage content through the dashboard:
|
||||
- View all workouts, trainers, collections
|
||||
- Delete items (create/edit coming soon)
|
||||
- Upload media files
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Workouts
|
||||
|
||||
```typescript
|
||||
import { adminService } from '@/src/admin/services/adminService'
|
||||
|
||||
await adminService.createWorkout({
|
||||
title: 'New Workout',
|
||||
trainer_id: 'emma',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
calories: 45,
|
||||
rounds: 8,
|
||||
prep_time: 10,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
equipment: ['No equipment'],
|
||||
music_vibe: 'electronic',
|
||||
exercises: [
|
||||
{ name: 'Jumping Jacks', duration: 20 },
|
||||
// ...
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Uploading Media
|
||||
|
||||
```typescript
|
||||
const videoUrl = await adminService.uploadVideo(file, 'workout-1.mp4')
|
||||
const thumbnailUrl = await adminService.uploadThumbnail(file, 'workout-1.jpg')
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Supabase is not configured" Warning
|
||||
|
||||
This is expected if environment variables are not set. The app will use mock data.
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
1. Verify admin user exists in `admin_users` table
|
||||
2. Check that email/password match
|
||||
3. Ensure user is confirmed in Supabase Auth
|
||||
|
||||
### Storage Upload Failures
|
||||
|
||||
1. Verify storage buckets exist
|
||||
2. Check RLS policies allow admin uploads
|
||||
3. Ensure file size is within limits
|
||||
|
||||
### Data Not Syncing
|
||||
|
||||
1. Check network connection
|
||||
2. Verify Supabase URL and key are correct
|
||||
3. Check browser console for errors
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Row Level Security (RLS) is enabled on all tables
|
||||
- Public can only read content
|
||||
- Only admin users can write content
|
||||
- Storage buckets have public read access
|
||||
- Storage uploads restricted to admin users
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Real-time workout tracking
|
||||
- [ ] User progress sync across devices
|
||||
- [ ] Offline support with local caching
|
||||
- [ ] Push notifications
|
||||
- [ ] Analytics and user insights
|
||||
@@ -1,594 +0,0 @@
|
||||
# TabataFit — Brand Design Specification v2.0
|
||||
> Apple Liquid Glass Design for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Brand Positioning
|
||||
|
||||
**TabataFit = Apple Fitness+ avec Liquid Glass (iOS 18.4)**
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| **Analogy** | "If Apple made a Tabata app in 2026" |
|
||||
| **Vibe** | Premium, glassy, fluid, immersive |
|
||||
| **Emotion** | Confident, empowering, sleek |
|
||||
| **Differentiator** | Video-led + Liquid Glass UI |
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Liquid Glass System (iOS 18.4 Style)
|
||||
|
||||
### Concept
|
||||
Le **Liquid Glass** est le nouveau design language d'Apple qui combine :
|
||||
- **Glassmorphism avancé** — Blur dynamique, transparence multicouche
|
||||
- **Fluidité organique** — Formes arrondies, animations liquides
|
||||
- **Lumière réactive** — Reflets, glows qui répondent au contenu
|
||||
- **Profondeur atmosphérique** — Layers de verre empilés
|
||||
|
||||
### Glass Layers
|
||||
|
||||
```typescript
|
||||
const GLASS = {
|
||||
// Base glass (surfaces)
|
||||
BASE: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 32,
|
||||
},
|
||||
|
||||
// Elevated glass (cards, modals)
|
||||
ELEVATED: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(60px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 48,
|
||||
},
|
||||
|
||||
// Inset glass (input fields, controls)
|
||||
INSET: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
|
||||
// Tinted glass (accent overlays)
|
||||
TINTED: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)', // Brand tint
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 107, 53, 0.3)',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Liquid Animations
|
||||
|
||||
```typescript
|
||||
const LIQUID = {
|
||||
// Morphing shapes
|
||||
MORPH: {
|
||||
duration: 600,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
// Ripple effect on tap
|
||||
RIPPLE: {
|
||||
scale: { from: 0.8, to: 1 },
|
||||
opacity: { from: 0.5, to: 0 },
|
||||
duration: 400,
|
||||
},
|
||||
|
||||
// Breathing glow
|
||||
BREATHE: {
|
||||
scale: { from: 1, to: 1.02 },
|
||||
shadowRadius: { from: 20, to: 30 },
|
||||
duration: 2000,
|
||||
loop: true,
|
||||
},
|
||||
|
||||
// Slide with liquid easing
|
||||
SLIDE: {
|
||||
damping: 20,
|
||||
stiffness: 300,
|
||||
mass: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Identity
|
||||
|
||||
### Logo Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ TABATA │ ← Bold, black
|
||||
│ FIT │ ← Accent orange
|
||||
│ │
|
||||
│ [Flame icon] │ ← Optional mark
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Brand Voice
|
||||
|
||||
| DO | DON'T |
|
||||
|----|-------|
|
||||
| "Let's burn." | "Get ripped fast!" |
|
||||
| "4 minutes to stronger." | "Lose weight now!" |
|
||||
| "Your daily dose of HIIT." | "The #1 fitness app!" |
|
||||
| Minimal, confident copy | Excessive exclamation marks!! |
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ BLACK ████████████ #000000 Background │
|
||||
│ CHARCOAL ████████████ #1C1C1E Surfaces │
|
||||
│ SLATE ████████████ #2C2C2E Elevated │
|
||||
│ │
|
||||
│ FLAME ████████████ #FF6B35 Brand accent │
|
||||
│ FLAME LIGHT ████████████ #FF8C5A Highlights │
|
||||
│ │
|
||||
│ ICE ████████████ #5AC8FA Rest phases │
|
||||
│ GLOW ████████████ #FFD60A Achievement │
|
||||
│ ENERGY ████████████ #30D158 Success/Complete│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Semantic Usage
|
||||
|
||||
| Color | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| **Black** | #000000 | Main background, video frames |
|
||||
| **Charcoal** | #1C1C1E | Cards, raised surfaces |
|
||||
| **Slate** | #2C2C2E | Modals, elevated elements |
|
||||
| **Flame** | #FF6B35 | Work phase, CTAs, brand |
|
||||
| **Ice** | #5AC8FA | Rest phase, calm states |
|
||||
| **Glow** | #FFD60A | Achievement badges, streaks |
|
||||
| **Energy** | #30D158 | Complete states, success |
|
||||
|
||||
### Phase Colors (Critical)
|
||||
|
||||
```typescript
|
||||
const PHASE_COLORS = {
|
||||
PREP: '#FF9500', // Orange-yellow - get ready
|
||||
WORK: '#FF6B35', // Flame orange - WORK!
|
||||
REST: '#5AC8FA', // Ice blue - recover
|
||||
COMPLETE: '#30D158', // Energy green - done!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
**Primary**: Inter (Google Fonts)
|
||||
- Clean, modern, excellent readability
|
||||
- Variable weight support
|
||||
- Apple SF Pro alternative
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Style | Size | Weight | Use Case |
|
||||
|-------|------|--------|----------|
|
||||
| **HERO** | 48px | 900 | Marketing, celebration |
|
||||
| **TITLE_1** | 34px | 700 | Screen titles |
|
||||
| **TITLE_2** | 28px | 700 | Section headers |
|
||||
| **TITLE_3** | 22px | 600 | Card titles |
|
||||
| **BODY** | 17px | 400 | Default text |
|
||||
| **BODY_BOLD** | 17px | 600 | Emphasis |
|
||||
| **CAPTION** | 15px | 400 | Metadata |
|
||||
| **MICRO** | 13px | 400 | Small labels |
|
||||
| **TIMER** | 96px | 900 | Countdown display |
|
||||
|
||||
### Timer Typography (Special)
|
||||
|
||||
```typescript
|
||||
const TIMER_STYLES = {
|
||||
// Main countdown number
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
fontVariant: ['tabular-nums'], // Monospace digits
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
// Phase label (WORK, REST)
|
||||
PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// Round indicator
|
||||
ROUND: {
|
||||
fontFamily: 'Inter_500Medium',
|
||||
fontSize: 17,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
```typescript
|
||||
const SPACING = {
|
||||
// Base unit: 4px
|
||||
0: 0,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
5: 20,
|
||||
6: 24,
|
||||
8: 32,
|
||||
10: 40,
|
||||
12: 48,
|
||||
16: 64,
|
||||
|
||||
// Semantic
|
||||
XS: 4,
|
||||
SM: 8,
|
||||
MD: 16,
|
||||
LG: 24,
|
||||
XL: 32,
|
||||
XXL: 48,
|
||||
}
|
||||
|
||||
const LAYOUT = {
|
||||
SCREEN_PADDING: 24, // Horizontal screen padding
|
||||
CARD_RADIUS: 16, // Standard card radius
|
||||
BUTTON_RADIUS: 12, // Button radius
|
||||
TAB_BAR_HEIGHT: 80, // Bottom tab bar
|
||||
STATUS_BAR: 44, // iOS status bar
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Styles
|
||||
|
||||
### Cards
|
||||
|
||||
```typescript
|
||||
const CARD = {
|
||||
CONTAINER: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
THUMBNAIL: {
|
||||
aspectRatio: 16/9,
|
||||
backgroundColor: '#2C2C2E',
|
||||
},
|
||||
|
||||
CONTENT: {
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// Variants
|
||||
FEATURED: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
|
||||
COMPACT: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Buttons
|
||||
|
||||
```typescript
|
||||
const BUTTON = {
|
||||
PRIMARY: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
PRIMARY_TEXT: {
|
||||
color: '#FFFFFF',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
SECONDARY: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#3A3A3C',
|
||||
},
|
||||
|
||||
GHOST: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Timer Display
|
||||
|
||||
```typescript
|
||||
const TIMER = {
|
||||
CONTAINER: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 24,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
|
||||
},
|
||||
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
PROGRESS_BAR: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 2,
|
||||
marginTop: 16,
|
||||
},
|
||||
|
||||
PROGRESS_FILL: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
// Color based on phase
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Timing Constants
|
||||
|
||||
```typescript
|
||||
const DURATION = {
|
||||
INSTANT: 100,
|
||||
FAST: 200,
|
||||
NORMAL: 300,
|
||||
SLOW: 500,
|
||||
XSLow: 800,
|
||||
}
|
||||
|
||||
const EASING = {
|
||||
// Standard iOS ease
|
||||
DEFAULT: 'ease-out',
|
||||
|
||||
// Spring animations
|
||||
BOUNCY: {
|
||||
damping: 15,
|
||||
stiffness: 180,
|
||||
},
|
||||
|
||||
GENTLE: {
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Key Animations
|
||||
|
||||
**Timer Countdown:**
|
||||
```typescript
|
||||
// Number pulses slightly each second
|
||||
Animated.sequence([
|
||||
Animated.timing(scale, { toValue: 1.05, duration: 100 }),
|
||||
Animated.timing(scale, { toValue: 1, duration: 100 }),
|
||||
])
|
||||
```
|
||||
|
||||
**Progress Bar:**
|
||||
```typescript
|
||||
// Smooth linear fill during phase
|
||||
Animated.timing(width, {
|
||||
toValue: 100,
|
||||
duration: phaseDuration,
|
||||
easing: Easing.linear,
|
||||
})
|
||||
```
|
||||
|
||||
**Phase Transition:**
|
||||
```typescript
|
||||
// Color crossfade
|
||||
Animated.timing(colorProgress, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
})
|
||||
```
|
||||
|
||||
**Workout Complete:**
|
||||
```typescript
|
||||
// Celebration with scale + fade
|
||||
Animated.parallel([
|
||||
Animated.spring(scale, { toValue: 1, ...BOUNCY }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 500 }),
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon System
|
||||
|
||||
Using SF Symbols / Ionicons equivalent:
|
||||
|
||||
| Context | Icon | Size |
|
||||
|---------|------|------|
|
||||
| Home Tab | house.fill | 24 |
|
||||
| Workouts Tab | flame.fill | 24 |
|
||||
| Activity Tab | chart.bar.fill | 24 |
|
||||
| Browse Tab | square.grid.2x2.fill | 24 |
|
||||
| Profile Tab | person.fill | 24 |
|
||||
| Play | play.fill | 20 |
|
||||
| Pause | pause.fill | 20 |
|
||||
| Close | xmark | 20 |
|
||||
| Back | chevron.left | 20 |
|
||||
| Forward | chevron.right | 20 |
|
||||
| Heart | heart.fill | 20 |
|
||||
| Search | magnifyingglass | 20 |
|
||||
|
||||
---
|
||||
|
||||
## Video Player UI
|
||||
|
||||
### Overlay Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Close X] [♥︎] [···] │ ← Top bar
|
||||
│ │
|
||||
│ │
|
||||
│ [VIDEO CONTENT] │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 🔥 WORK │ │
|
||||
│ │ │ │
|
||||
│ │ 00:14 │ │ ← Timer overlay
|
||||
│ │ │ │
|
||||
│ │ Round 3 of 8 │ │
|
||||
│ │ ████████████░░░░ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Video Requirements
|
||||
|
||||
- **Resolution**: 1080p minimum
|
||||
- **Aspect**: 16:9 (landscape) or 9:16 (portrait mode)
|
||||
- **Format**: HLS (m3u8) for streaming
|
||||
- **Audio**: AAC, 128kbps minimum
|
||||
- **Thumbnail**: JPG, same aspect ratio
|
||||
|
||||
---
|
||||
|
||||
## Sound Design
|
||||
|
||||
### Sound Effects
|
||||
|
||||
| Event | Sound | Description |
|
||||
|-------|-------|-------------|
|
||||
| Phase Start | "ding" | Quick, bright chime |
|
||||
| Count 3-2-1 | "tick" | Subtle tick sound |
|
||||
| Workout Start | "whoosh" | Energy whoosh |
|
||||
| Workout Complete | "success" | Celebratory chime |
|
||||
| Button Tap | "tap" | Soft tap feedback |
|
||||
| Streak Achieved | "fire" | Crackling fire |
|
||||
|
||||
### Haptics
|
||||
|
||||
| Event | Haptic |
|
||||
|-------|--------|
|
||||
| Phase change | Medium |
|
||||
| Button tap | Light |
|
||||
| Countdown tick | Selection |
|
||||
| Workout complete | Success |
|
||||
| Error | Error |
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode Only
|
||||
|
||||
TabataFit is **dark mode only** — no light mode. This is a deliberate design choice matching Apple Fitness+ and creating an immersive, cinematic experience.
|
||||
|
||||
Reasons:
|
||||
1. Better video contrast
|
||||
2. Less eye strain during workouts
|
||||
3. Premium feel
|
||||
4. Consistent with fitness studio lighting
|
||||
|
||||
---
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
### Trainer Photos
|
||||
|
||||
- Professional, high-quality headshots
|
||||
- Warm, approachable expressions
|
||||
- Fitness attire visible
|
||||
- Consistent lighting style
|
||||
- Background: Dark or studio setting
|
||||
|
||||
### Workout Thumbnails
|
||||
|
||||
- Action shot from the workout
|
||||
- Clear exercise demonstration
|
||||
- Trainer visible
|
||||
- Dark/gradient background
|
||||
- Text overlay: Title, duration, level
|
||||
|
||||
### Collection Banners
|
||||
|
||||
- 16:9 aspect ratio
|
||||
- Composite of trainer + exercises
|
||||
- Gradient overlay for text
|
||||
- Brand accent color elements
|
||||
|
||||
---
|
||||
|
||||
## Copy Guidelines
|
||||
|
||||
### Tone
|
||||
|
||||
- **Confident** but not arrogant
|
||||
- **Encouraging** but not cheesy
|
||||
- **Clear** and direct
|
||||
- **Minimal** — let the content speak
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| CTA | "Start Workout" | "Start Your Workout Now!" |
|
||||
| Empty state | "No workouts yet" | "You haven't done any workouts :(" |
|
||||
| Error | "Connection lost" | "Oh no! Something went wrong!" |
|
||||
| Success | "Workout complete" | "AMAZING! You crushed it!!!" |
|
||||
|
||||
### Coach Dialogue
|
||||
|
||||
- Motivational but authentic
|
||||
- Form cues during exercises
|
||||
- Breathing reminders during rest
|
||||
- Encouragement without clichés
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Brand: Apple Fitness+ inspired*
|
||||
@@ -1,765 +0,0 @@
|
||||
# TabataFit — Product Design Document v2.0
|
||||
> Apple Fitness+ Design Language for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
**"Make it feel like a premium fitness studio in your pocket."**
|
||||
|
||||
TabataFit adopts le design language d'Apple Fitness+ :
|
||||
- **Dark mode premium** — Fond noir profond, couleurs vibrantes
|
||||
- **Video-first** — Le contenu est le héros
|
||||
- **Typography bold** — Gros titres, textes épurés
|
||||
- **Subtle animations** — Transitions fluides, feedback délicat
|
||||
- **Inclusive imagery** — Diversité des coachs et body types
|
||||
|
||||
---
|
||||
|
||||
## Color System — Dark Premium
|
||||
|
||||
### Background Colors
|
||||
|
||||
```typescript
|
||||
const COLORS = {
|
||||
// Backgrounds
|
||||
BACKGROUND: '#000000', // Pure black — comme Apple TV
|
||||
SURFACE: '#1C1C1E', // Raised surfaces
|
||||
ELEVATED: '#2C2C2E', // Cards, modals
|
||||
OVERLAY: 'rgba(0,0,0,0.6)', // Video overlays
|
||||
|
||||
// Brand Accent (Vibrant Orange-Red)
|
||||
BRAND: '#FF6B35', // Energy, action
|
||||
BRAND_LIGHT: '#FF8C5A', // Highlights
|
||||
BRAND_DARK: '#E55A25', // Pressed states
|
||||
|
||||
// Secondary Accents
|
||||
SUCCESS: '#34C759', // Completed, streaks
|
||||
WARNING: '#FF9500', // Rest phases
|
||||
INFO: '#5AC8FA', // Tips, info
|
||||
|
||||
// Text
|
||||
TEXT_PRIMARY: '#FFFFFF', // Main text
|
||||
TEXT_SECONDARY: '#EBEBF5', // Secondary (87% opacity)
|
||||
TEXT_TERTIARY: '#EBEBF599', // Tertiary (60% opacity)
|
||||
TEXT_DISABLED: '#3A3A3C', // Disabled text
|
||||
|
||||
// Semantic
|
||||
WORK: '#FF6B35', // Active work phase
|
||||
REST: '#5AC8FA', // Rest phase (calm blue)
|
||||
PREP: '#FF9500', // Countdown prep
|
||||
}
|
||||
```
|
||||
|
||||
### Gradient Presets
|
||||
|
||||
```typescript
|
||||
const GRADIENTS = {
|
||||
// Hero banners
|
||||
HERO_WORK: ['#FF6B35', '#E55A25'],
|
||||
HERO_REST: ['#5AC8FA', '#007AFF'],
|
||||
HERO_FEAT: ['#1C1C1E', '#000000'],
|
||||
|
||||
// Video overlays
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
VIDEO_TOP: ['rgba(0,0,0,0.4)', 'transparent'],
|
||||
|
||||
// Buttons
|
||||
CTA: ['#FF6B35', '#FF8C5A'],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography System — Apple SF Pro Style
|
||||
|
||||
```typescript
|
||||
const TYPOGRAPHY = {
|
||||
// Hero/Display
|
||||
HERO: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 48,
|
||||
lineHeight: 56,
|
||||
letterSpacing: -1,
|
||||
},
|
||||
|
||||
// Section Headers (like Apple Fitness+)
|
||||
TITLE_1: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 34,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
},
|
||||
|
||||
TITLE_2: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
},
|
||||
|
||||
TITLE_3: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
},
|
||||
|
||||
// Body
|
||||
BODY: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
BODY_BOLD: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
// Metadata
|
||||
CAPTION_1: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
},
|
||||
|
||||
CAPTION_2: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
|
||||
// Timer (special)
|
||||
TIMER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
lineHeight: 96,
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
TIMER_PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 2, // Uppercase tracking
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Designs
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Bonjour, Alex [Profile] │ ← 24px padding
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │ ← Hero Card
|
||||
│ │ │ │ 16:9 aspect
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 FEATURED │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ FULL BODY BURN │ │ │ ← Text overlay
|
||||
│ │ │ 4 min • Beginner • Emma │ │ │ on video
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [▶️ START] [♡ Save] │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue See All → │ ← Section header
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Horizontal scroll
|
||||
│ │ ━━━━━ │ │ ━━━━━ │ │ ━━━━━ │ │ 140x200px cards
|
||||
│ │ 65% │ │ 30% │ │ 10% │ │
|
||||
│ │ Core │ │ HIIT │ │ Full │ │
|
||||
│ │ Burn │ │ Extreme │ │ Body │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Smaller cards
|
||||
│ │ │ │ │ │ │ │ │ │ │ 120x120px
|
||||
│ │ Quick │ │ Strength│ │ Cardio │ │ Core │ │
|
||||
│ │ Burn │ │ Tabata │ │ Blast │ │ Crush │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer │ │ ← Full-width cards
|
||||
│ │ 5 workouts • 20 min total │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge │ │
|
||||
│ │ 7 workouts • Progressive intensity │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Component Specs
|
||||
|
||||
**Hero Card:**
|
||||
- Full width - 48px padding
|
||||
- 16:9 aspect ratio
|
||||
- Video preview looping (muted)
|
||||
- Gradient overlay: transparent → rgba(0,0,0,0.8)
|
||||
- Featured badge top-left
|
||||
- Title, metadata, CTA bottom
|
||||
|
||||
**Continue Watching Card:**
|
||||
- 140px width × 200px height
|
||||
- Thumbnail with progress bar overlay
|
||||
- Progress percentage badge
|
||||
- Workout name + duration
|
||||
|
||||
**Popular Card:**
|
||||
- 120px × 120px square
|
||||
- Thumbnail only
|
||||
- Category name below
|
||||
|
||||
**Collection Card:**
|
||||
- Full width - 48px padding
|
||||
- 80px height
|
||||
- Icon + title + description
|
||||
- Chevron right
|
||||
|
||||
---
|
||||
|
||||
### 2. Workouts Tab
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Workouts [🔍 Search] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels • 15 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner • 6 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced • 10 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Category Detail View
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← Quick Burn │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 4 min • All levels • 12 workouts │ │
|
||||
│ │ │ │
|
||||
│ │ Filter: [All] [Beginner] [Intermediate] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Full Body Ignite │ │
|
||||
│ │ │ │ 4 min • Beginner • Emma │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Cardio Crusher │ │
|
||||
│ │ │ │ 4 min • Intermediate • Alex │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Lower Body Blast │ │
|
||||
│ │ │ │ 4 min • Intermediate • Jake │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Pre-Workout Detail Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← [♡] [···] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach Emma in action │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY IGNITE │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Beginner • ⏱️ 4 min • 🔥 45cal │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment required │
|
||||
│ ○ Yoga mat optional │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Exercises (8 rounds) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 1. Jump Squats 20s work │ │
|
||||
│ │ 2. Mountain Climbers 20s work │ │
|
||||
│ │ 3. Burpees 20s work │ │
|
||||
│ │ 4. High Knees 20s work │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Repeat × 2 rounds │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy │
|
||||
│ Upbeat, high-energy electronic tracks │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Active Workout Screen — The Core Experience
|
||||
|
||||
#### Work Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing Jump Squats │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 🔥 WORK │ │ │ │ ← Timer overlay
|
||||
│ │ │ │ │ │ │ │ bottom gradient
|
||||
│ │ │ │ 00:14 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Round 3 of 8 │ │ │ │
|
||||
│ │ │ │ ████████████░░░░ 65% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 142 │ │ 85% │ │
|
||||
│ │ CALORIES │ │ BPM │ │ EFFORT │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ░░░░░░░░████████████████░░░░ 72nd percentile │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSITION] │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, take a breath" │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 💙 REST │ │ │ │ ← Blue rest theme
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ 00:08 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Next: Mountain Climbers │ │ │ │
|
||||
│ │ │ │ ░░░░░░░░░░░░░░░░░░░ 40% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ UP NEXT │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │ GIF │ Mountain Climbers │ │
|
||||
│ │ │preview│ "Core engaged, drive knees forward" │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 3-2-1 Countdown (Pre-Work)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ GET READY! │ │
|
||||
│ │ │ │
|
||||
│ │ 3 │ │ ← Giant number
|
||||
│ │ │ │ centered
|
||||
│ │ │ │
|
||||
│ │ JUMP SQUATS │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Workout Complete Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ 🎉 │
|
||||
│ │
|
||||
│ WORKOUT COMPLETE │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED CELEBRATION RINGS] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 💪 ⚡ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES │ │ MINUTES │ │ COMPLETE │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ You beat 73% of users! │
|
||||
│ ░░░░░░░░████████████████░░░░ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! │
|
||||
│ Keep the momentum going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📤 SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Recommended Next │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │
|
||||
│ │ Core │ │ Upper │ │ Cardio │ │
|
||||
│ │ Crush │ │ Body │ │ Blast │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Activity Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Activity │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ● ● ● ● ● ● ● ○ ○ ○ │ │
|
||||
│ │ M T W T F S S M T W │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 20 │ │
|
||||
│ │ WORKOUTS │ │ CALORIES │ │ MINUTES │ │
|
||||
│ │ 5 goal │ │ 150 goal │ │ 20 goal │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ [CALENDAR HEAT MAP] │ │
|
||||
│ │ │ │
|
||||
│ │ Jan 2026 │ │
|
||||
│ │ S M T W T F S │ │
|
||||
│ │ 1 2 3 4 5 6 │ │
|
||||
│ │ 7 8 9 10 11 12 13 │ │
|
||||
│ │ 14 15 16 17 18 19 20 │ │
|
||||
│ │ ░ ░ █ █ ░ █ █ │ │
|
||||
│ │ █ █ ░ █ █ ░ ░ │ │
|
||||
│ │ ░ █ █ █ █ █ █ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ │ │ │
|
||||
│ │ [WEEKLY CHART] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Your average: 45 cal/workout │ │
|
||||
│ │ ████████████░░░░ 68th percentile │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Browse Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Browse │
|
||||
│ │
|
||||
│ Filters [Edit] │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ All ▼ │ │ 4 min │ │ 8 min │ │ Begin │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Trainers │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │ Emma │ │ Jake │ │ Mia │ │ Alex │ │
|
||||
│ │ 12 wk │ │ 8 wk │ │ 10 wk │ │ 6 wk │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Duration │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ 4 min (Classic Tabata) 20 │ │
|
||||
│ │ ○ 8 min (Double Tabata) 15 │ │
|
||||
│ │ ○ 12 min (Triple Tabata) 10 │ │
|
||||
│ │ ○ 20 min (Tabata Marathon) 5 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Focus Area │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Full Body 20 │ │
|
||||
│ │ ○ Upper Body 8 │ │
|
||||
│ │ ○ Lower Body 8 │ │
|
||||
│ │ ○ Core 8 │ │
|
||||
│ │ ○ Cardio 6 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Electronic Energy 18 │ │
|
||||
│ │ ○ Hip-Hop Beats 12 │ │
|
||||
│ │ ○ Rock Power 10 │ │
|
||||
│ │ ○ Chill Focus 10 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer • 5 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 💪 No Equipment • 15 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge • 7 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Profile Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Profile │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ ✨ Premium │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Weekly Goal │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 5 workouts per week │ │
|
||||
│ │ ████████████████░░░░ 4/5 this week │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏆 7-Day Streak 🥵 First Sweat │ │
|
||||
│ │ 💯 100 Workouts 🌅 Early Bird │ │
|
||||
│ │ 🔥 500 Calories ⚡ Speed Demon │ │
|
||||
│ │ │ │
|
||||
│ │ [See All Achievements →] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Notifications [→] │ │
|
||||
│ │ Apple Watch [→] │ │
|
||||
│ │ Music Preferences [→] │ │
|
||||
│ │ Workout Preferences [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Account │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Subscription [→] │ │
|
||||
│ │ Privacy & Security [→] │ │
|
||||
│ │ Help & Support [→] │ │
|
||||
│ │ Sign Out │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ TabataFit v1.0.0 │
|
||||
│ Made with ❤️ for HIIT lovers │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Screen Transitions
|
||||
- **Push/Pop**: 300ms ease-out
|
||||
- **Modal**: Slide up from bottom, 350ms
|
||||
|
||||
### Micro-interactions
|
||||
- **Button press**: Scale to 0.96, 100ms
|
||||
- **Card tap**: Scale to 0.98, 150ms
|
||||
- **Toggle**: 200ms spring animation
|
||||
|
||||
### Timer Animations
|
||||
- **Countdown**: Number scales up/down each second
|
||||
- **Progress bar**: Smooth width animation
|
||||
- **Phase change**: Color crossfade 300ms
|
||||
|
||||
### Celebration
|
||||
- **Confetti**: Lottie animation on workout complete
|
||||
- **Rings**: Animated fill when stats update
|
||||
- **Streak badge**: Pulse animation
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Dynamic Type**: Support up to 200% text scaling
|
||||
- **VoiceOver**: Full screen reader support
|
||||
- **Reduce Motion**: Disable animations when requested
|
||||
- **High Contrast**: Alternative color scheme option
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Design System: Apple Fitness+ Inspired*
|
||||
@@ -1,545 +0,0 @@
|
||||
# TabataFit — Product Requirements Document v2.0
|
||||
> Apple Fitness+ for Tabata — The Premium HIIT Experience
|
||||
|
||||
---
|
||||
|
||||
## Vision Statement
|
||||
|
||||
**TabataFit est l'Apple Fitness+ du Tabata.** Une expérience premium, visuellement stunante, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
*"Workouts that work. Beautifully."*
|
||||
|
||||
---
|
||||
|
||||
## Positionnement
|
||||
|
||||
| Aspect | Apple Fitness+ | TabataFit |
|
||||
|--------|---------------|-----------|
|
||||
| **Focus** | Multi-activité (Yoga, HIIT, Strength, etc.) | Spécialiste Tabata/HIIT |
|
||||
| **Durée** | 5-45 min | 4-20 min (format Tabata) |
|
||||
| **Différenciateur** | Intégration Apple Watch | Timer intelligent + Coaching audio |
|
||||
| **Cible** | Grand public fitness | Athlètes HIIT, busy professionals |
|
||||
| **Vibe** | Studio californien | Énergie explosive, motivational |
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy — Apple Fitness+ Principles
|
||||
|
||||
1. **Content is King** — Vidéos HD, coachs charismatiques, production Netflix-quality
|
||||
2. **Inclusive** — Tous niveaux, modifications montrées
|
||||
3. **Personalized** — Recommandations basées sur l'historique
|
||||
4. **Immersive** — Music sync, Burn Bar, stats temps réel
|
||||
5. **Beautiful** — Design épuré, animations fluides, dark theme élégant
|
||||
|
||||
---
|
||||
|
||||
## Architecture Produit
|
||||
|
||||
### Tab Bar (5 onglets — comme Apple Fitness+)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🏠 Home 🔥 Workouts 📊 Activity 🔍 Browse 👤 Profile │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
**Inspiration**: Apple Fitness+ Home — grande bannière, collections, recommandations
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ☀️ Bonjour Alex │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 🎬 FEATURED WORKOUT │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Full Body Burn │ │
|
||||
│ │ 4 min • Beginner • Emma │ │
|
||||
│ │ │ │
|
||||
│ │ [▶️ START NOW] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue Watching │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 65%│ │ 30%│ │ 10%│ │
|
||||
│ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ 🌅 Morning Energizer │
|
||||
│ 💪 No Equipment Needed │
|
||||
│ 🔥 7-Day Challenge │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Elements clés:**
|
||||
- Hero banner avec vidéo preview en boucle
|
||||
- "Continue Watching" — workouts non terminés
|
||||
- "Popular This Week" — trending workouts
|
||||
- Collections thématiques
|
||||
- Coach du moment
|
||||
|
||||
### 2. Workouts Tab — Parcourir par type
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout browser — categories visuelles
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ WORKOUTS 🔍 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels │ │
|
||||
│ │ 15 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner friendly │ │
|
||||
│ │ 6 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced │ │
|
||||
│ │ 10 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Categories:**
|
||||
1. **Quick Burn** — 4 min, perfect for beginners
|
||||
2. **Strength Tabata** — Resistance exercises
|
||||
3. **Cardio Blast** — Pure cardio, no equipment
|
||||
4. **Core & Flexibility** — Abs, stretching
|
||||
5. **HIIT Extreme** — Advanced, longer sessions
|
||||
|
||||
### 3. Activity Tab — Stats & Progress
|
||||
|
||||
**Inspiration**: Apple Fitness+ Activity rings + trends
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ACTIVITY │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ ───────── │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ○ ○ ○ ○ ○ ○ ○ ● ○ ○ │ │
|
||||
│ │ M T W T F S S M T │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 32 │ │
|
||||
│ │Workout│ │ Calories│ │ Minutes│ │
|
||||
│ └───────┘ └───────┘ └───────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts are trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Your avg: 45 cal/workout │ │
|
||||
│ │ ████████░░░░ 68th percentile │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ [ Calendar view with heat map ] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Streak counter avec calendrier visuel
|
||||
- Stats hebdomadaires (workouts, calories, minutes)
|
||||
- Trends ("You're on fire! 🔥")
|
||||
- Burn Bar — comparaison avec autres utilisateurs
|
||||
- Calendar heat map
|
||||
|
||||
### 4. Browse Tab — Tout le contenu
|
||||
|
||||
**Inspiration**: Apple Fitness+ Browse — filtres, trainers, music
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ BROWSE │
|
||||
│ │
|
||||
│ Filters [Edit]│
|
||||
│ [All ▼] [4 min] [8 min] [Beginner] │
|
||||
│ │
|
||||
│ By Trainer │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │Emma │ │Jake │ │Mia │ │Alex │ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ By Duration │
|
||||
│ ○ 4 min (Classic Tabata) │
|
||||
│ ○ 8 min (Double Tabata) │
|
||||
│ ○ 12 min (Triple Tabata) │
|
||||
│ ○ 20 min (Tabata Marathon) │
|
||||
│ │
|
||||
│ By Focus Area │
|
||||
│ ○ Full Body │
|
||||
│ ○ Upper Body │
|
||||
│ ○ Lower Body │
|
||||
│ ○ Core │
|
||||
│ ○ Cardio │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ○ Electronic Energy │
|
||||
│ ○ Hip-Hop Beats │
|
||||
│ ○ Rock Power │
|
||||
│ ○ Chill Focus │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. Profile Tab — Settings & Account
|
||||
|
||||
**Inspiration**: Apple Fitness+ Profile — minimal, clean
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PROFILE │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ Premium ✨ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Goals │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Weekly Goal: 5 workouts │ │
|
||||
│ │ ████████░░ 4/5 this week │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ 🏆 7-Day Streak │
|
||||
│ 🥵 First Sweat │
|
||||
│ 💯 100 Workouts │
|
||||
│ [See all →] │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ • Notifications │
|
||||
│ • Apple Watch │
|
||||
│ • Music Preferences │
|
||||
│ • Account │
|
||||
│ • Subscription │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Workout Experience — Cœur du Produit
|
||||
|
||||
### Pre-Workout Screen
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout preview — trailer, details, start
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW LOOP] │ │
|
||||
│ │ Coach Emma demonstrating │ │
|
||||
│ │ Jump Squats │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY BURN │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Intermediate • ⏱️ 4 min │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment │
|
||||
│ ○ Mat recommended │
|
||||
│ │
|
||||
│ Exercises Preview │
|
||||
│ 1. Jump Squats (20s work) │
|
||||
│ 2. Mountain Climbers (20s work) │
|
||||
│ 3. Burpees (20s work) │
|
||||
│ 4. High Knees (20s work) │
|
||||
│ × 2 rounds │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy playlist │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Active Workout Screen — Apple Fitness+ Style
|
||||
|
||||
**Inspiration**: Apple Fitness+ player — video dominant, stats overlay
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing exercise │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 WORK • 00:14 │ │ │
|
||||
│ │ │ Round 3 of 8 │ │ │
|
||||
│ │ │ ████████████░░░░ 65% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 14 │ │ 52 │ │ 85% │ │
|
||||
│ │ cal │ │ bpm │ │ effort │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar: ████████░░░░ 72% │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Video full screen avec coach
|
||||
- Timer overlay (phase + countdown)
|
||||
- Round indicator
|
||||
- Progress bar
|
||||
- Stats temps réel (calories, bpm si Apple Watch)
|
||||
- Burn Bar
|
||||
|
||||
### During Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSE] │ │
|
||||
│ │ Stretching / breathing │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 💙 REST • 00:08 │ │ │
|
||||
│ │ │ Next: Mountain Climbers │ │ │
|
||||
│ │ │ ░░░░░░░░░░░░░░░░░░ 40% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, you're │ │
|
||||
│ │ doing great!" │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Up Next: Mountain Climbers │
|
||||
│ [GIF preview of next exercise] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Workout Complete — Celebration
|
||||
|
||||
**Inspiration**: Apple Fitness+ celebration screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🎉 WORKOUT COMPLETE! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED RINGS] │ │
|
||||
│ │ 🔥 🔥 🔥 │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ YOUR STATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES│ │ MINUTES │ │ COMPLETE│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ████████████░░░ You beat 73%! │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! Keep it going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Strategy — 50+ Workouts au Launch
|
||||
|
||||
### Par Durée
|
||||
|
||||
| Duration | Format | Rounds | Count |
|
||||
|----------|--------|--------|-------|
|
||||
| 4 min | Classic Tabata | 8 rounds | 20 workouts |
|
||||
| 8 min | Double Tabata | 16 rounds | 15 workouts |
|
||||
| 12 min | Triple Tabata | 24 rounds | 10 workouts |
|
||||
| 20 min | Tabata Marathon | 40 rounds | 5 workouts |
|
||||
|
||||
### Par Focus
|
||||
|
||||
- **Full Body** — 20 workouts
|
||||
- **Upper Body** — 8 workouts
|
||||
- **Lower Body** — 8 workouts
|
||||
- **Core** — 8 workouts
|
||||
- **Cardio Only** — 6 workouts
|
||||
|
||||
### Par Niveau
|
||||
|
||||
- **Beginner** — 15 workouts
|
||||
- **Intermediate** — 20 workouts
|
||||
- **Advanced** — 15 workouts
|
||||
|
||||
### Trainers (5 au launch)
|
||||
|
||||
1. **Emma** — Energy queen, beginner-friendly
|
||||
2. **Jake** — Strength focus, motivating
|
||||
3. **Mia** — Form perfectionist, technical
|
||||
4. **Alex** — Cardio beast, intense
|
||||
5. **Sofia** — Chill but effective, recovery
|
||||
|
||||
---
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Video Pipeline
|
||||
- HLS streaming (adaptive bitrate)
|
||||
- 1080p minimum, 4K for featured
|
||||
- Offline download for Premium
|
||||
- Preload next exercise during rest
|
||||
|
||||
### Audio
|
||||
- Multiple music tracks (by vibe)
|
||||
- Coach voice-over (can be muted)
|
||||
- Sound effects (beeps, transitions)
|
||||
- Haptic feedback sync
|
||||
|
||||
### Apple Watch Integration
|
||||
- Heart rate display
|
||||
- Calories calculation
|
||||
- Activity rings update
|
||||
- Now Playing controls
|
||||
|
||||
### Offline Support
|
||||
- Download workouts for offline
|
||||
- Sync when back online
|
||||
- Local stats caching
|
||||
|
||||
---
|
||||
|
||||
## Monetization
|
||||
|
||||
### Free Tier
|
||||
- 3 workouts free forever
|
||||
- Basic stats
|
||||
- Ads between workouts
|
||||
|
||||
### Premium ($6.99/mo or $49.99/yr)
|
||||
- Unlimited workouts
|
||||
- All trainers
|
||||
- Offline downloads
|
||||
- Advanced stats & trends
|
||||
- Apple Watch integration
|
||||
- No ads
|
||||
- Family Sharing (up to 5)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target (Month 3) |
|
||||
|--------|------------------|
|
||||
| DAU | 10,000 |
|
||||
| Workout completion rate | 75% |
|
||||
| 7-day retention | 40% |
|
||||
| Premium conversion | 8% |
|
||||
| Average workouts/user/week | 3.5 |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — MVP (Weeks 1-4)
|
||||
- [ ] Home + Workouts tabs
|
||||
- [ ] 20 workouts (4 min only)
|
||||
- [ ] 2 trainers
|
||||
- [ ] Basic timer + video player
|
||||
|
||||
### Phase 2 — Core (Weeks 5-8)
|
||||
- [ ] Activity tab with stats
|
||||
- [ ] 30 workouts total
|
||||
- [ ] 4 trainers
|
||||
- [ ] Apple Watch integration
|
||||
|
||||
### Phase 3 — Premium (Weeks 9-12)
|
||||
- [ ] Browse + Profile tabs
|
||||
- [ ] 50+ workouts
|
||||
- [ ] 5 trainers
|
||||
- [ ] Offline downloads
|
||||
- [ ] Burn Bar
|
||||
- [ ] Subscription system
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Status: Ready for Design Phase*
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
|
||||
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
|
||||
| #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>
|
||||
94
admin-web/app/programs/[id]/edit/page.tsx
Normal file
94
admin-web/app/programs/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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 (*),
|
||||
workout_warmup_exercises (*),
|
||||
workout_stretch_exercises (*)
|
||||
`)
|
||||
.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)
|
||||
}
|
||||
if (data.workout_warmup_exercises) {
|
||||
data.workout_warmup_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
if (data.workout_stretch_exercises) {
|
||||
data.workout_stretch_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
// Map to ProgramForm's expected shape
|
||||
data.tabatas = data.program_tabatas
|
||||
data.warmup = data.workout_warmup_exercises
|
||||
data.stretch = data.workout_stretch_exercises
|
||||
|
||||
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>
|
||||
575
admin-web/components/program-form.tsx
Normal file
575
admin-web/components/program-form.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
"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 { TimedExerciseList } from "@/components/timed-exercise-list"
|
||||
import { TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-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"]
|
||||
type WarmupRow = Database["public"]["Tables"]["workout_warmup_exercises"]["Row"]
|
||||
type StretchRow = Database["public"]["Tables"]["workout_stretch_exercises"]["Row"]
|
||||
|
||||
interface ProgramFormProps {
|
||||
initialData?: WorkoutProgram & {
|
||||
tabatas?: ProgramTabata[]
|
||||
warmup?: WarmupRow[]
|
||||
stretch?: StretchRow[]
|
||||
}
|
||||
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_1_video_url: t.exercise_1_video_url || "",
|
||||
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 || "",
|
||||
exercise_2_video_url: t.exercise_2_video_url || "",
|
||||
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_1_video_url: "", 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: "", exercise_2_video_url: "", 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_1_video_url: "", 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: "", exercise_2_video_url: "", 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_1_video_url: "", 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: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
]
|
||||
})
|
||||
|
||||
// Warmup state
|
||||
const [warmup, setWarmup] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.warmup && initialData.warmup.length > 0) {
|
||||
return initialData.warmup
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((w, i) => ({
|
||||
position: i + 1,
|
||||
name: w.name || "",
|
||||
name_en: w.name_en || "",
|
||||
tip: w.tip || "",
|
||||
tip_en: w.tip_en || "",
|
||||
duration: w.duration || 30,
|
||||
video_url: w.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Stretch state
|
||||
const [stretch, setStretch] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.stretch && initialData.stretch.length > 0) {
|
||||
return initialData.stretch
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s, i) => ({
|
||||
position: i + 1,
|
||||
name: s.name || "",
|
||||
name_en: s.name_en || "",
|
||||
tip: s.tip || "",
|
||||
tip_en: s.tip_en || "",
|
||||
duration: s.duration || 30,
|
||||
video_url: s.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
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`
|
||||
}
|
||||
})
|
||||
|
||||
warmup.forEach((w, i) => {
|
||||
if (!w.name.trim()) newErrors[`warmup_${i}`] = `Warmup ${i + 1}: Name is required`
|
||||
if (!w.duration || w.duration < 1) newErrors[`warmup_${i}_dur`] = `Warmup ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
stretch.forEach((s, i) => {
|
||||
if (!s.name.trim()) newErrors[`stretch_${i}`] = `Stretch ${i + 1}: Name is required`
|
||||
if (!s.duration || s.duration < 1) newErrors[`stretch_${i}_dur`] = `Stretch ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
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_1_video_url: tabata.exercise_1_video_url.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,
|
||||
exercise_2_video_url: tabata.exercise_2_video_url.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
|
||||
}
|
||||
}
|
||||
|
||||
// Replace warmup exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (warmup.length > 0) {
|
||||
const warmupPayload = warmup.map((w, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: w.name.trim(),
|
||||
name_en: w.name_en.trim() || null,
|
||||
tip: w.tip.trim() || null,
|
||||
tip_en: w.tip_en.trim() || null,
|
||||
duration: w.duration,
|
||||
video_url: w.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.insert(warmupPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
// Replace stretch exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (stretch.length > 0) {
|
||||
const stretchPayload = stretch.map((s, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: s.name.trim(),
|
||||
name_en: s.name_en.trim() || null,
|
||||
tip: s.tip.trim() || null,
|
||||
tip_en: s.tip_en.trim() || null,
|
||||
duration: s.duration,
|
||||
video_url: s.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.insert(stretchPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
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-4 bg-neutral-900">
|
||||
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
|
||||
Basics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="warmup" className="data-[state=active]:bg-neutral-800">
|
||||
Warmup
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tabatas" className="data-[state=active]:bg-neutral-800">
|
||||
Tabatas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stretch" className="data-[state=active]:bg-neutral-800">
|
||||
Stretch
|
||||
</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: Warmup */}
|
||||
<TabsContent value="warmup" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Warmup exercises"
|
||||
description="Dynamic warmup sequence before the tabatas. Amber accent in the player."
|
||||
emptyLabel="No warmup exercises yet. Add the first one to get started."
|
||||
accentColor="text-amber-400"
|
||||
items={warmup}
|
||||
onChange={setWarmup}
|
||||
errors={Object.fromEntries(
|
||||
warmup.map((_, i) => [i, errors[`warmup_${i}`] || errors[`warmup_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: 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>
|
||||
|
||||
{/* Tab 4: Stretch */}
|
||||
<TabsContent value="stretch" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Stretch exercises"
|
||||
description="Cool-down stretches after the tabatas. Lavender accent in the player."
|
||||
emptyLabel="No stretch exercises yet. Add the first one to get started."
|
||||
accentColor="text-violet-300"
|
||||
items={stretch}
|
||||
onChange={setStretch}
|
||||
errors={Object.fromEntries(
|
||||
stretch.map((_, i) => [i, errors[`stretch_${i}`] || errors[`stretch_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</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 },
|
||||
|
||||
311
admin-web/components/tabata-editor.tsx
Normal file
311
admin-web/components/tabata-editor.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"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 { MediaUpload } from "@/components/media-upload"
|
||||
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_1_video_url: 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
|
||||
exercise_2_video_url: 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_1_video_url: "",
|
||||
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: "",
|
||||
exercise_2_video_url: "",
|
||||
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>
|
||||
|
||||
{/* Background video */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={(data[`${prefix}_video_url` as keyof TabataData] as string) || undefined}
|
||||
onChange={(url) => onChange(`${prefix}_video_url`, url)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
177
admin-web/components/timed-exercise-editor.tsx
Normal file
177
admin-web/components/timed-exercise-editor.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ArrowDown, ArrowUp, Trash2, Clock } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MediaUpload } from "@/components/media-upload"
|
||||
|
||||
export interface TimedExerciseData {
|
||||
position: number
|
||||
name: string
|
||||
name_en: string
|
||||
tip: string
|
||||
tip_en: string
|
||||
duration: number
|
||||
video_url: string
|
||||
}
|
||||
|
||||
interface TimedExerciseRowProps {
|
||||
data: TimedExerciseData
|
||||
index: number
|
||||
total: number
|
||||
onChange: (data: TimedExerciseData) => void
|
||||
onRemove: () => void
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
accentColor?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function TimedExerciseRow({
|
||||
data,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
accentColor = "text-orange-500",
|
||||
error,
|
||||
}: TimedExerciseRowProps) {
|
||||
const update = <K extends keyof TimedExerciseData>(key: K, value: TimedExerciseData[K]) => {
|
||||
onChange({ ...data, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-neutral-900 border-b border-neutral-800">
|
||||
<div className={cn("flex h-6 w-6 items-center justify-center rounded-full bg-neutral-800 text-xs font-semibold", accentColor)}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white flex-1">
|
||||
{data.name || `Exercise ${index + 1}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-neutral-500 mr-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
{data.duration}s
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-7 w-7 p-0 text-red-400 hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className={cn("h-9 text-sm", error && "border-red-500")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (EN)</Label>
|
||||
<Input
|
||||
value={data.name_en}
|
||||
onChange={(e) => update("name_en", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.tip}
|
||||
onChange={(e) => update("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.tip_en}
|
||||
onChange={(e) => update("tip_en", e.target.value)}
|
||||
placeholder="Tip in English"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Duration (seconds) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.duration}
|
||||
onChange={(e) => update("duration", parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
max={300}
|
||||
className="h-9 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={data.video_url || undefined}
|
||||
onChange={(url) => update("video_url", url)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmptyTimedExercise(position: number): TimedExerciseData {
|
||||
return {
|
||||
position,
|
||||
name: "",
|
||||
name_en: "",
|
||||
tip: "",
|
||||
tip_en: "",
|
||||
duration: 30,
|
||||
video_url: "",
|
||||
}
|
||||
}
|
||||
90
admin-web/components/timed-exercise-list.tsx
Normal file
90
admin-web/components/timed-exercise-list.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TimedExerciseRow, TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor"
|
||||
|
||||
interface TimedExerciseListProps {
|
||||
title: string
|
||||
description: string
|
||||
emptyLabel: string
|
||||
accentColor: string
|
||||
items: TimedExerciseData[]
|
||||
onChange: (items: TimedExerciseData[]) => void
|
||||
errors?: Record<number, string>
|
||||
}
|
||||
|
||||
export function TimedExerciseList({
|
||||
title,
|
||||
description,
|
||||
emptyLabel,
|
||||
accentColor,
|
||||
items,
|
||||
onChange,
|
||||
errors = {},
|
||||
}: TimedExerciseListProps) {
|
||||
const addItem = () => {
|
||||
onChange([...items, createEmptyTimedExercise(items.length + 1)])
|
||||
}
|
||||
|
||||
const updateItem = (index: number, data: TimedExerciseData) => {
|
||||
const next = [...items]
|
||||
next[index] = data
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const next = items.filter((_, i) => i !== index).map((item, i) => ({ ...item, position: i + 1 }))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= items.length) return
|
||||
const next = [...items]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
onChange(next.map((item, i) => ({ ...item, position: i + 1 })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-neutral-500">{description}</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-800 bg-neutral-950/50 p-8 text-center">
|
||||
<p className="text-sm text-neutral-500 mb-4">{emptyLabel}</p>
|
||||
<Button type="button" onClick={addItem} variant="outline" className="border-neutral-700">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.map((item, i) => (
|
||||
<TimedExerciseRow
|
||||
key={i}
|
||||
data={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
accentColor={accentColor}
|
||||
onChange={(data) => updateItem(i, data)}
|
||||
onRemove={() => removeItem(i)}
|
||||
onMoveUp={() => move(i, i - 1)}
|
||||
onMoveDown={() => move(i, i + 1)}
|
||||
error={errors[i]}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" onClick={addItem} variant="outline" className="w-full border-neutral-700 border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</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,194 @@ 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
|
||||
exercise_1_video_url: string | null
|
||||
exercise_2_video_url: 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
|
||||
exercise_1_video_url?: string | null
|
||||
exercise_2_video_url?: string | null
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_warmup_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_warmup_exercises']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_stretch_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_stretch_exercises']['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;
|
||||
71
admin-web/migrations/006_warmup_stretch_video.sql
Normal file
71
admin-web/migrations/006_warmup_stretch_video.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Migration 006: Warmup, Stretch, and Exercise Video URLs
|
||||
-- Extends workout_programs with warmup and stretch blocks
|
||||
-- Adds video_url to tabata exercises for background playback during player
|
||||
|
||||
-- ─── Add video URLs to tabata exercises ─────────────────────
|
||||
|
||||
ALTER TABLE public.program_tabatas
|
||||
ADD COLUMN IF NOT EXISTS exercise_1_video_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exercise_2_video_url TEXT;
|
||||
|
||||
-- ─── Warmup Exercises ───────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.workout_warmup_exercises (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
tip TEXT,
|
||||
tip_en TEXT,
|
||||
duration INTEGER NOT NULL, -- seconds
|
||||
video_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (program_id, position)
|
||||
);
|
||||
|
||||
-- ─── Stretch Exercises ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.workout_stretch_exercises (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
tip TEXT,
|
||||
tip_en TEXT,
|
||||
duration INTEGER NOT NULL, -- seconds
|
||||
video_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (program_id, position)
|
||||
);
|
||||
|
||||
-- ─── Indexes ────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_warmup_program_position
|
||||
ON public.workout_warmup_exercises (program_id, position);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stretch_program_position
|
||||
ON public.workout_stretch_exercises (program_id, position);
|
||||
|
||||
-- ─── Row Level Security ─────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.workout_warmup_exercises ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.workout_stretch_exercises ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Public read workout_warmup_exercises"
|
||||
ON public.workout_warmup_exercises FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY "Public read workout_stretch_exercises"
|
||||
ON public.workout_stretch_exercises FOR SELECT USING (true);
|
||||
|
||||
-- Admin write (service_role bypass RLS, authenticated users controlled elsewhere)
|
||||
CREATE POLICY "Admin write workout_warmup_exercises"
|
||||
ON public.workout_warmup_exercises FOR ALL
|
||||
USING (auth.role() = 'service_role')
|
||||
WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Admin write workout_stretch_exercises"
|
||||
ON public.workout_stretch_exercises FOR ALL
|
||||
USING (auth.role() = 'service_role')
|
||||
WITH CHECK (auth.role() = 'service_role');
|
||||
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",
|
||||
|
||||
64
app.json
64
app.json
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "TabataFit",
|
||||
"slug": "tabatafit",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "tabatafit",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.millianlmx.tabatafit",
|
||||
"buildNumber": "1",
|
||||
"infoPlist": {
|
||||
"NSHealthShareUsageDescription": "TabataFit uses HealthKit to read and write workout data including heart rate, calories burned, and exercise minutes.",
|
||||
"NSHealthUpdateUsageDescription": "TabataFit saves your workout sessions to Apple Health so you can track your fitness progress.",
|
||||
"NSCameraUsageDescription": "TabataFit uses the camera for profile photos and workout form checks.",
|
||||
"NSUserTrackingUsageDescription": "TabataFit uses this to provide personalized workout recommendations.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.millianlmx.tabatafit"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-video",
|
||||
"expo-localization",
|
||||
"./plugins/withStoreKitConfig"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5056 | 8:24 AM | ✅ | Completed Host wrapper restoration in home screen | ~258 |
|
||||
| #5055 | " | ✅ | Re-added Host wrapper to home screen JSX | ~187 |
|
||||
| #5054 | " | ✅ | Re-added Host import to home screen | ~184 |
|
||||
| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 |
|
||||
| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 |
|
||||
| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 |
|
||||
| #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 |
|
||||
</claude-mem-context>
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* TabataFit Tab Layout
|
||||
* Native iOS tabs with liquid glass effect
|
||||
* 4 tabs: Home, Workouts, Activity, Profile
|
||||
* Redirects to onboarding if not completed
|
||||
*/
|
||||
|
||||
import { Redirect } from 'expo-router'
|
||||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BRAND } from '@/src/shared/constants/colors'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useTranslation('screens')
|
||||
const onboardingCompleted = useUserStore((s) => s.profile.onboardingCompleted)
|
||||
|
||||
if (!onboardingCompleted) {
|
||||
return <Redirect href="/onboarding" />
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeTabs
|
||||
tintColor={BRAND.PRIMARY}
|
||||
>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Icon sf={{ default: 'house', selected: 'house.fill' }} />
|
||||
<Label>{t('tabs.home')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<Icon sf={{ default: 'flame', selected: 'flame.fill' }} />
|
||||
<Label>{t('tabs.explore')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="activity">
|
||||
<Icon sf={{ default: 'chart.bar', selected: 'chart.bar.fill' }} />
|
||||
<Label>{t('tabs.activity')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="profile">
|
||||
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
|
||||
<Label>{t('tabs.profile')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
)
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
/**
|
||||
* TabataFit Activity Screen
|
||||
* Premium stats dashboard — streak, rings, weekly chart, history
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Dimensions, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
import Svg, { Circle } from 'react-native-svg'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
|
||||
import { getWorkoutById } from '@/src/shared/data'
|
||||
import { ACHIEVEMENTS } from '@/src/shared/data'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND, PHASE, GRADIENTS } 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'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STAT RING — Custom circular progress (pure RN, no SwiftUI)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function StatRing({
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
size = 64,
|
||||
}: {
|
||||
value: number
|
||||
max: number
|
||||
color: string
|
||||
size?: number
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const strokeWidth = 5
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const progress = Math.min(value / max, 1)
|
||||
const strokeDashoffset = circumference * (1 - progress)
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size}>
|
||||
{/* Track */}
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={colors.bg.overlay2}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Progress */}
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
opacity={progress > 0 ? 1 : 0.3}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STAT CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
icon,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
max: number
|
||||
color: string
|
||||
icon: IconName
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<View style={styles.statCard}>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.statCardInner}>
|
||||
<StatRing value={value} max={max} color={color} size={52} />
|
||||
<View style={{ flex: 1, marginLeft: SPACING[3] }}>
|
||||
<StyledText size={24} weight="bold" color={colors.text.primary}>
|
||||
{String(value)}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary}>
|
||||
{label}
|
||||
</StyledText>
|
||||
</View>
|
||||
<Icon name={icon} size={18} tintColor={color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WEEKLY BAR
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function WeeklyBar({
|
||||
day,
|
||||
completed,
|
||||
isToday,
|
||||
}: {
|
||||
day: string
|
||||
completed: boolean
|
||||
isToday: boolean
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<View style={styles.weekBarColumn}>
|
||||
<View style={[styles.weekBar, completed && styles.weekBarFilled]}>
|
||||
{completed && (
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
|
||||
style={[StyleSheet.absoluteFill, { borderRadius: 4 }]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<StyledText
|
||||
size={11}
|
||||
weight={isToday ? 'bold' : 'regular'}
|
||||
color={isToday ? colors.text.primary : colors.text.hint}
|
||||
>
|
||||
{day}
|
||||
</StyledText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMPTY STATE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIconCircle}>
|
||||
<Icon name="flame" size={48} tintColor={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary} style={styles.emptyTitle}>
|
||||
{t('screens:activity.emptyTitle')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary} style={styles.emptySubtitle}>
|
||||
{t('screens:activity.emptySubtitle')}
|
||||
</StyledText>
|
||||
<Pressable style={styles.emptyCtaButton} onPress={onStartWorkout}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name="play.fill" size={18} tintColor="#FFFFFF" style={{ marginRight: SPACING[2] }} />
|
||||
<StyledText size={16} weight="semibold" color="#FFFFFF">
|
||||
{t('screens:activity.startFirstWorkout')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'days.fri', 'days.sat'] as const
|
||||
|
||||
export default function ActivityScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const totalWorkouts = history.length
|
||||
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
|
||||
const totalCalories = useMemo(() => history.reduce((sum, r) => sum + r.calories, 0), [history])
|
||||
const recentWorkouts = useMemo(() => history.slice(0, 5), [history])
|
||||
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
|
||||
|
||||
const today = new Date().getDay() // 0=Sun
|
||||
|
||||
// Check achievements
|
||||
const unlockedAchievements = ACHIEVEMENTS.filter(a => {
|
||||
switch (a.type) {
|
||||
case 'workouts': return totalWorkouts >= a.requirement
|
||||
case 'streak': return streak.longest >= a.requirement
|
||||
case 'minutes': return totalMinutes >= a.requirement
|
||||
case 'calories': return totalCalories >= a.requirement
|
||||
default: return false
|
||||
}
|
||||
})
|
||||
const displayAchievements = ACHIEVEMENTS.slice(0, 4).map(a => ({
|
||||
...a,
|
||||
unlocked: unlockedAchievements.some(u => u.id === a.id),
|
||||
}))
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
if (diff < 86400000) return t('screens:activity.today')
|
||||
if (diff < 172800000) return t('screens:activity.yesterday')
|
||||
return t('screens:activity.daysAgo', { count: Math.floor(diff / 86400000) })
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<StyledText
|
||||
size={34}
|
||||
weight="bold"
|
||||
color={colors.text.primary}
|
||||
style={{ marginBottom: SPACING[6] }}
|
||||
>
|
||||
{t('screens:activity.title')}
|
||||
</StyledText>
|
||||
|
||||
{/* Empty state when no history */}
|
||||
{history.length === 0 ? (
|
||||
<EmptyState onStartWorkout={() => router.push('/(tabs)/explore' as any)} />
|
||||
) : (
|
||||
<>
|
||||
{/* Streak Banner */}
|
||||
<View style={styles.streakBanner}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.streakRow}>
|
||||
<View style={styles.streakIconWrap}>
|
||||
<Icon name="flame.fill" size={28} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<StyledText size={28} weight="bold" color="#FFFFFF">
|
||||
{String(streak.current || 0)}
|
||||
</StyledText>
|
||||
<StyledText size={13} color="rgba(255,255,255,0.8)">
|
||||
{t('screens:activity.dayStreak')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.streakMeta}>
|
||||
<StyledText size={11} color="rgba(255,255,255,0.6)">
|
||||
{t('screens:activity.longest')}
|
||||
</StyledText>
|
||||
<StyledText size={20} weight="bold" color="#FFFFFF">
|
||||
{String(streak.longest)}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Grid — 2x2 */}
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard
|
||||
label={t('screens:activity.workouts')}
|
||||
value={totalWorkouts}
|
||||
max={100}
|
||||
color={BRAND.PRIMARY}
|
||||
icon="dumbbell"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.minutes')}
|
||||
value={totalMinutes}
|
||||
max={300}
|
||||
color={PHASE.REST}
|
||||
icon="clock"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.calories')}
|
||||
value={totalCalories}
|
||||
max={5000}
|
||||
color={BRAND.SECONDARY}
|
||||
icon="bolt"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.bestStreak')}
|
||||
value={streak.longest}
|
||||
max={30}
|
||||
color={BRAND.SUCCESS}
|
||||
icon="arrow.up.right"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* This Week */}
|
||||
<View style={styles.section}>
|
||||
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
|
||||
{t('screens:activity.thisWeek')}
|
||||
</StyledText>
|
||||
<View style={styles.weekCard}>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.weekBarsRow}>
|
||||
{weeklyActivity.map((d, i) => (
|
||||
<WeeklyBar
|
||||
key={i}
|
||||
day={t(DAY_KEYS[i])}
|
||||
completed={d.completed}
|
||||
isToday={i === today}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.weekSummary}>
|
||||
<StyledText size={13} color={colors.text.tertiary}>
|
||||
{t('screens:activity.ofDays', { completed: weeklyActivity.filter(d => d.completed).length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recent Workouts */}
|
||||
{recentWorkouts.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
|
||||
{t('screens:activity.recent')}
|
||||
</StyledText>
|
||||
<View style={styles.recentCard}>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
{recentWorkouts.map((result, idx) => {
|
||||
const workout = getWorkoutById(result.workoutId)
|
||||
const workoutTitle = workout ? t(`content:workouts.${workout.id}`, { defaultValue: workout.title }) : t('screens:activity.workouts')
|
||||
return (
|
||||
<View key={result.id}>
|
||||
<View style={styles.recentRow}>
|
||||
<View style={styles.recentDot}>
|
||||
<View style={[styles.dot, { backgroundColor: BRAND.PRIMARY }]} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<StyledText size={15} weight="semibold" color={colors.text.primary}>
|
||||
{workoutTitle}
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary}>
|
||||
{formatDate(result.completedAt) + ' \u00B7 ' + t('units.minUnit', { count: result.durationMinutes })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<StyledText size={14} weight="semibold" color={BRAND.PRIMARY}>
|
||||
{t('units.calUnit', { count: result.calories })}
|
||||
</StyledText>
|
||||
</View>
|
||||
{idx < recentWorkouts.length - 1 && <View style={styles.recentDivider} />}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Achievements */}
|
||||
<View style={styles.section}>
|
||||
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
|
||||
{t('screens:activity.achievements')}
|
||||
</StyledText>
|
||||
<View style={styles.achievementsRow}>
|
||||
{displayAchievements.map((a) => (
|
||||
<View key={a.id} style={styles.achievementCard}>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View
|
||||
style={[
|
||||
styles.achievementIcon,
|
||||
a.unlocked
|
||||
? { backgroundColor: 'rgba(255, 107, 53, 0.15)' }
|
||||
: { backgroundColor: 'rgba(255, 255, 255, 0.04)' },
|
||||
]}
|
||||
>
|
||||
<Icon
|
||||
name={a.unlocked ? 'trophy.fill' : 'lock.fill'}
|
||||
size={22}
|
||||
tintColor={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
<StyledText
|
||||
size={11}
|
||||
weight="semibold"
|
||||
color={a.unlocked ? colors.text.primary : colors.text.hint}
|
||||
numberOfLines={1}
|
||||
style={{ marginTop: SPACING[2], textAlign: 'center' }}
|
||||
>
|
||||
{t(`content:achievements.${a.id}.title`, { defaultValue: a.title })}
|
||||
</StyledText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const CARD_HALF = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Streak
|
||||
streakBanner: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[5],
|
||||
...colors.shadow.md,
|
||||
},
|
||||
streakRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[5],
|
||||
paddingVertical: SPACING[5],
|
||||
gap: SPACING[4],
|
||||
},
|
||||
streakIconWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
streakMeta: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.MD,
|
||||
},
|
||||
|
||||
// Stats 2x2
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
statCard: {
|
||||
width: CARD_HALF,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
},
|
||||
statCardInner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
|
||||
// Weekly
|
||||
weekCard: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
paddingTop: SPACING[5],
|
||||
paddingBottom: SPACING[4],
|
||||
},
|
||||
weekBarsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: SPACING[4],
|
||||
height: 100,
|
||||
},
|
||||
weekBarColumn: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: SPACING[2],
|
||||
},
|
||||
weekBar: {
|
||||
width: 24,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.border.glassLight,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
weekBarFilled: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
},
|
||||
weekSummary: {
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING[3],
|
||||
paddingTop: SPACING[3],
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.bg.overlay2,
|
||||
marginHorizontal: SPACING[4],
|
||||
},
|
||||
|
||||
// Recent
|
||||
recentCard: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
paddingVertical: SPACING[2],
|
||||
},
|
||||
recentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
recentDot: {
|
||||
width: 24,
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
recentDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border.glassLight,
|
||||
marginLeft: SPACING[4] + 24 + SPACING[3],
|
||||
},
|
||||
|
||||
// Achievements
|
||||
achievementsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
achievementCard: {
|
||||
flex: 1,
|
||||
aspectRatio: 0.9,
|
||||
borderRadius: RADIUS.LG,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: SPACING[1],
|
||||
},
|
||||
achievementIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Empty State
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: SPACING[10],
|
||||
paddingHorizontal: SPACING[6],
|
||||
},
|
||||
emptyIconCircle: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
emptyTitle: {
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center' as const,
|
||||
lineHeight: 22,
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
emptyCtaButton: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
height: 52,
|
||||
paddingHorizontal: SPACING[8],
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,735 +0,0 @@
|
||||
/**
|
||||
* TabataFit Home Screen - 3 Program Design
|
||||
* Premium Apple Fitness+ inspired layout
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable, Animated } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useUserStore, useProgramStore, useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
|
||||
import { PROGRAMS, ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
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 type { ProgramId } from '@/src/shared/types'
|
||||
|
||||
// Feature flags — disable incomplete features
|
||||
const FEATURE_FLAGS = {
|
||||
ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
|
||||
}
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE: 28,
|
||||
TITLE_2: 22,
|
||||
HEADLINE: 17,
|
||||
BODY: 16,
|
||||
CAPTION: 13,
|
||||
}
|
||||
|
||||
// Program metadata for display
|
||||
const PROGRAM_META: Record<ProgramId, { icon: IconName; gradient: [string, string]; accent: string }> = {
|
||||
'upper-body': {
|
||||
icon: 'dumbbell',
|
||||
gradient: ['#FF6B35', '#FF3B30'],
|
||||
accent: '#FF6B35',
|
||||
},
|
||||
'lower-body': {
|
||||
icon: 'figure.walk',
|
||||
gradient: ['#30D158', '#28A745'],
|
||||
accent: '#30D158',
|
||||
},
|
||||
'full-body': {
|
||||
icon: 'flame',
|
||||
gradient: ['#5AC8FA', '#007AFF'],
|
||||
accent: '#5AC8FA',
|
||||
},
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROGRAM CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ProgramCard({
|
||||
programId,
|
||||
onPress,
|
||||
}: {
|
||||
programId: ProgramId
|
||||
onPress: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('screens')
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const program = PROGRAMS[programId]
|
||||
const meta = PROGRAM_META[programId]
|
||||
const programStatus = useProgramStore((s) => s.getProgramStatus(programId))
|
||||
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const statusText = {
|
||||
'not-started': t('programs.status.notStarted'),
|
||||
'in-progress': `${completion}% ${t('programs.status.complete')}`,
|
||||
'completed': t('programs.status.completed'),
|
||||
}[programStatus]
|
||||
|
||||
const handlePress = () => {
|
||||
haptics.buttonTap()
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.programCard}
|
||||
testID={`program-card-${programId}`}
|
||||
>
|
||||
{/* Glass Background */}
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Color Gradient Overlay */}
|
||||
<LinearGradient
|
||||
colors={[meta.gradient[0] + '40', meta.gradient[1] + '18', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Top Accent Line */}
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.accentLine}
|
||||
/>
|
||||
|
||||
<View style={styles.programCardContent}>
|
||||
{/* Icon + Title Row */}
|
||||
<View style={styles.programCardHeader}>
|
||||
{/* Gradient Icon Circle */}
|
||||
<View style={styles.programIconWrapper}>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.programIconGradient}
|
||||
/>
|
||||
<View style={styles.programIconInner}>
|
||||
<Icon name={meta.icon} size={24} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.programHeaderText}>
|
||||
<View style={styles.programTitleRow}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="bold" color={colors.text.primary} style={{ flex: 1 }}>
|
||||
{t(`content:programs.${program.id}.title`)}
|
||||
</StyledText>
|
||||
{programStatus !== 'not-started' && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: meta.accent + '20', borderColor: meta.accent + '35' }]}>
|
||||
<StyledText size={11} weight="semibold" color={meta.accent}>
|
||||
{statusText}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} numberOfLines={2} style={{ lineHeight: 18 }}>
|
||||
{t(`content:programs.${program.id}.description`)}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress Bar (if started) */}
|
||||
{programStatus !== 'not-started' && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={styles.progressFillWrapper}>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.max(completion, 2)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{programStatus === 'completed'
|
||||
? t('programs.allWorkoutsComplete')
|
||||
: `${completion}% ${t('programs.complete')}`
|
||||
}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats — inline text, not chips */}
|
||||
<StyledText size={12} color={colors.text.tertiary} style={styles.programMeta}>
|
||||
{program.durationWeeks} {t('programs.weeks')} · {program.workoutsPerWeek}×{t('programs.perWeek')} · {program.totalWorkouts} {t('programs.workouts')}
|
||||
</StyledText>
|
||||
|
||||
{/* Premium CTA Button — only interactive element */}
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
styles.ctaButtonWrapper,
|
||||
{ transform: [{ scale: scaleValue }] },
|
||||
]}
|
||||
onPress={handlePress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
testID={`program-${programId}-cta`}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.ctaButton}
|
||||
>
|
||||
<StyledText size={15} weight="semibold" color="#FFFFFF">
|
||||
{programStatus === 'not-started'
|
||||
? t('programs.startProgram')
|
||||
: programStatus === 'completed'
|
||||
? t('programs.restart')
|
||||
: t('programs.continue')
|
||||
}
|
||||
</StyledText>
|
||||
<Icon
|
||||
name={programStatus === 'completed' ? 'arrow.clockwise' : 'arrow.right'}
|
||||
size={17}
|
||||
tintColor="#FFFFFF"
|
||||
style={styles.ctaIcon}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// QUICK STATS ROW
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function QuickStats() {
|
||||
const { t } = useTranslation('screens')
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
|
||||
const thisWeekCount = weeklyActivity.filter((d) => d.completed).length
|
||||
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
|
||||
|
||||
const stats = [
|
||||
{ icon: 'flame.fill' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
|
||||
{ icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
|
||||
{ icon: 'clock' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View style={styles.quickStatsRow}>
|
||||
{stats.map((stat) => (
|
||||
<View key={stat.label} style={styles.quickStatPill}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name={stat.icon} size={16} tintColor={stat.color} />
|
||||
<StyledText size={17} weight="bold" color={colors.text.primary} style={{ fontVariant: ['tabular-nums'] }}>
|
||||
{String(stat.value)}
|
||||
</StyledText>
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{stat.label}
|
||||
</StyledText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ASSESSMENT CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function AssessmentCard({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation('screens')
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const isCompleted = useProgramStore((s) => s.assessment.isCompleted)
|
||||
|
||||
if (isCompleted) return null
|
||||
|
||||
const handlePress = () => {
|
||||
haptics.buttonTap()
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.assessmentCard}
|
||||
onPress={handlePress}
|
||||
testID="assessment-card"
|
||||
>
|
||||
{/* Glass Background */}
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
{/* Subtle brand gradient overlay */}
|
||||
<LinearGradient
|
||||
colors={[`${BRAND.PRIMARY}18`, 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.assessmentContent}>
|
||||
{/* Gradient Icon Circle */}
|
||||
<View style={styles.assessmentIconCircle}>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.assessmentIconInner}>
|
||||
<Icon name="clipboard" size={22} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.assessmentText}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
|
||||
{t('assessment.title')}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={{ marginTop: 2 }}>
|
||||
{ASSESSMENT_WORKOUT.duration} {t('assessment.duration')} · {ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.assessmentArrow}>
|
||||
<Icon name="arrow.right" size={16} tintColor={BRAND.PRIMARY} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation('screens')
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const haptics = useHaptics()
|
||||
const userName = useUserStore((s) => s.profile.name)
|
||||
const selectedProgram = useProgramStore((s) => s.selectedProgramId)
|
||||
const changeProgram = useProgramStore((s) => s.changeProgram)
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
|
||||
const greeting = (() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return t('common:greetings.morning')
|
||||
if (hour < 18) return t('common:greetings.afternoon')
|
||||
return t('common:greetings.evening')
|
||||
})()
|
||||
|
||||
const handleProgramPress = (programId: ProgramId) => {
|
||||
// Navigate to program detail
|
||||
router.push(`/program/${programId}` as any)
|
||||
}
|
||||
|
||||
const handleAssessmentPress = () => {
|
||||
router.push('/assessment' as any)
|
||||
}
|
||||
|
||||
const handleSwitchProgram = () => {
|
||||
haptics.buttonTap()
|
||||
changeProgram(null as any)
|
||||
}
|
||||
|
||||
const programOrder: ProgramId[] = ['upper-body', 'lower-body', 'full-body']
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Ambient gradient glow at top */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 107, 53, 0.06)', 'rgba(255, 107, 53, 0.02)', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={styles.ambientGlow}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<View style={styles.heroSection}>
|
||||
<View style={styles.heroGreetingRow}>
|
||||
<StyledText size={FONTS.BODY} color={colors.text.tertiary}>
|
||||
{greeting}
|
||||
</StyledText>
|
||||
{/* Inline streak badge */}
|
||||
{streak.current > 0 && (
|
||||
<View style={styles.streakBadge}>
|
||||
<Icon name="flame.fill" size={13} tintColor={BRAND.PRIMARY} />
|
||||
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
|
||||
{streak.current}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroName}>
|
||||
{userName}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={styles.heroSubtitle}>
|
||||
{selectedProgram
|
||||
? t('home.continueYourJourney')
|
||||
: t('home.chooseYourPath')
|
||||
}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<QuickStats />
|
||||
|
||||
{/* Assessment Card (if not completed and feature enabled) */}
|
||||
{FEATURE_FLAGS.ASSESSMENT_ENABLED && (
|
||||
<AssessmentCard onPress={handleAssessmentPress} />
|
||||
)}
|
||||
|
||||
{/* Program Cards */}
|
||||
<View style={styles.programsSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('home.yourPrograms')}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary} style={styles.sectionSubtitle}>
|
||||
{t('home.programsSubtitle')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{programOrder.map((programId) => (
|
||||
<ProgramCard
|
||||
key={programId}
|
||||
programId={programId}
|
||||
onPress={() => handleProgramPress(programId)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Switch Program Option (if has progress) */}
|
||||
{selectedProgram && (
|
||||
<Pressable
|
||||
style={styles.switchProgramButton}
|
||||
onPress={handleSwitchProgram}
|
||||
>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name="shuffle" size={16} tintColor={colors.text.secondary} />
|
||||
<StyledText size={14} weight="medium" color={colors.text.secondary}>
|
||||
{t('home.switchProgram')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Ambient gradient glow
|
||||
ambientGlow: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
},
|
||||
|
||||
// Hero Section
|
||||
heroSection: {
|
||||
marginTop: SPACING[4],
|
||||
marginBottom: SPACING[7],
|
||||
},
|
||||
heroGreetingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
streakBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${BRAND.PRIMARY}30`,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
heroName: {
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
heroSubtitle: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
|
||||
// Quick Stats Row
|
||||
quickStatsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[7],
|
||||
},
|
||||
quickStatPill: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderCurve: 'continuous',
|
||||
gap: SPACING[1],
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
|
||||
// Assessment Card
|
||||
assessmentCard: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
padding: SPACING[5],
|
||||
marginBottom: SPACING[8],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassStrong,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
assessmentContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
assessmentIconCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
marginRight: SPACING[4],
|
||||
},
|
||||
assessmentIconInner: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
assessmentText: {
|
||||
flex: 1,
|
||||
},
|
||||
assessmentArrow: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${BRAND.PRIMARY}18`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
// Programs Section
|
||||
programsSection: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
sectionHeader: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionSubtitle: {
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
// Program Card
|
||||
programCard: {
|
||||
borderRadius: RADIUS.XL,
|
||||
marginBottom: SPACING[6],
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassStrong,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
accentLine: {
|
||||
height: 2,
|
||||
width: '100%',
|
||||
},
|
||||
programCardContent: {
|
||||
padding: SPACING[5],
|
||||
paddingRight: SPACING[6],
|
||||
},
|
||||
programCardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: SPACING[4],
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
// Gradient icon circle
|
||||
programIconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
programIconGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
programIconInner: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
programHeaderText: {
|
||||
flex: 1,
|
||||
paddingBottom: SPACING[1],
|
||||
},
|
||||
programTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
},
|
||||
programTitle: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
programDescription: {
|
||||
marginBottom: SPACING[4],
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressContainer: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginBottom: SPACING[2],
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.glass.inset.backgroundColor,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
progressFillWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
// Stats as inline meta text
|
||||
programMeta: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Premium CTA Button
|
||||
ctaButtonWrapper: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
ctaButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[5],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
ctaIcon: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
|
||||
// Switch Program — glass pill
|
||||
switchProgramButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
gap: SPACING[2],
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[6],
|
||||
marginTop: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
/**
|
||||
* TabataFit Profile Screen — Premium React Native
|
||||
* Apple Fitness+ inspired design, pure React Native components
|
||||
*/
|
||||
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Switch,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import * as Linking from 'expo-linking'
|
||||
import Constants from 'expo-constants'
|
||||
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 type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } 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'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPONENT: PROFILE SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { t } = useTranslation('screens')
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const profile = useUserStore((s) => s.profile)
|
||||
const settings = useUserStore((s) => s.settings)
|
||||
const updateSettings = useUserStore((s) => s.updateSettings)
|
||||
const updateProfile = useUserStore((s) => s.updateProfile)
|
||||
const setSyncStatus = useUserStore((s) => s.setSyncStatus)
|
||||
const { restorePurchases, isPremium } = usePurchases()
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
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(() => ({
|
||||
workouts: history.length,
|
||||
streak: streak.current,
|
||||
calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0),
|
||||
}), [history, streak])
|
||||
|
||||
const handleSignOut = () => {
|
||||
updateProfile({
|
||||
name: '',
|
||||
email: '',
|
||||
subscription: 'free',
|
||||
onboardingCompleted: false,
|
||||
})
|
||||
router.replace('/onboarding')
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await restorePurchases()
|
||||
}
|
||||
|
||||
const handleDeleteData = async () => {
|
||||
const result = await deleteSyncedData()
|
||||
if (result.success) {
|
||||
setSyncStatus('unsynced', null)
|
||||
setShowDeleteModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReminderToggle = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
const granted = await requestNotificationPermissions()
|
||||
if (!granted) return
|
||||
}
|
||||
updateSettings({ reminders: enabled })
|
||||
}
|
||||
|
||||
const handleRateApp = () => {
|
||||
Linking.openURL('https://apps.apple.com/app/tabatafit/id1234567890')
|
||||
}
|
||||
|
||||
const handleContactUs = () => {
|
||||
Linking.openURL('mailto:contact@tabatafit.app')
|
||||
}
|
||||
|
||||
const handlePrivacyPolicy = () => {
|
||||
router.push('/privacy')
|
||||
}
|
||||
|
||||
const handleFAQ = () => {
|
||||
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 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PROFILE HEADER CARD
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Avatar with gradient background */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<StyledText size={48} weight="bold" color="#FFFFFF">
|
||||
{avatarInitial}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Name & Plan */}
|
||||
<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 ? 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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
UPGRADE CTA (FREE USERS ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{!isPremium && (
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={styles.premiumContainer}
|
||||
onPress={() => router.push('/paywall')}
|
||||
>
|
||||
<View style={styles.premiumContent}>
|
||||
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
|
||||
✨ {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] }}>
|
||||
{t('profile.learnMore')} →
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
WORKOUT SETTINGS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<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>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
NOTIFICATIONS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<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>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<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>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ACCOUNT (PREMIUM USERS 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
SIGN OUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={[styles.section, styles.signOutSection]}>
|
||||
<Pressable style={styles.button} onPress={handleSignOut}>
|
||||
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Data Deletion Modal */}
|
||||
<DataDeletionModal
|
||||
visible={showDeleteModal}
|
||||
onDelete={handleDeleteData}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
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],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 45,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
|
||||
},
|
||||
nameContainer: {
|
||||
marginTop: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
planContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING[1],
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING[4],
|
||||
gap: SPACING[8],
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
premiumContainer: {
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
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: {
|
||||
marginTop: SPACING[5],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 19, 2026
|
||||
|
||||
| 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
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5115 | 8:57 AM | 🔵 | Root Layout Stack Configuration with Screen Animations | ~256 |
|
||||
| #5061 | 8:47 AM | 🔵 | Expo Router Tab Navigation Structure Found | ~196 |
|
||||
| #5053 | 8:23 AM | ✅ | Completed removal of all Host wrappers from application | ~255 |
|
||||
| #5052 | " | ✅ | Removed Host wrapper from root layout entirely | ~224 |
|
||||
| #5019 | 8:13 AM | 🔵 | Root layout properly wraps Stack with Host component | ~198 |
|
||||
|
||||
### Feb 28, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5598 | 9:22 PM | 🟣 | Enabled PostHog analytics in development mode | ~253 |
|
||||
| #5597 | " | 🔄 | PostHogProvider initialization updated with client check and autocapture config | ~303 |
|
||||
| #5589 | 7:51 PM | 🟣 | PostHog screen tracking added to onboarding flow | ~246 |
|
||||
| #5588 | 7:50 PM | ✅ | Added trackScreen function to onboarding analytics imports | ~203 |
|
||||
| #5585 | " | ✅ | Enhanced PostHogProvider initialization with null safety | ~239 |
|
||||
| #5584 | 7:49 PM | ✅ | Imported trackScreen function in root layout | ~202 |
|
||||
| #5583 | " | 🟣 | PostHog user identification added to onboarding completion | ~291 |
|
||||
| #5582 | " | ✅ | Enhanced onboarding analytics with user identification | ~187 |
|
||||
| #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 |
|
||||
</claude-mem-context>
|
||||
216
app/_layout.tsx
216
app/_layout.tsx
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* TabataFit Root Layout
|
||||
* Expo Router v3 + Inter font loading
|
||||
* Waits for font + store hydration before rendering
|
||||
*/
|
||||
|
||||
import '@/src/shared/i18n'
|
||||
import '@/src/shared/i18n/types'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Stack } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { View } from 'react-native'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {
|
||||
useFonts,
|
||||
Inter_400Regular,
|
||||
Inter_500Medium,
|
||||
Inter_600SemiBold,
|
||||
Inter_700Bold,
|
||||
Inter_900Black,
|
||||
} from '@expo-google-fonts/inter'
|
||||
|
||||
import { PostHogProvider } from 'posthog-react-native'
|
||||
|
||||
import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { useNotifications } from '@/src/shared/hooks'
|
||||
import { initializePurchases } from '@/src/shared/services/purchases'
|
||||
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: false,
|
||||
shouldShowList: false,
|
||||
}),
|
||||
})
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
// Create React Query Client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function RootLayoutInner() {
|
||||
const colors = useThemeColors()
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
Inter_500Medium,
|
||||
Inter_600SemiBold,
|
||||
Inter_700Bold,
|
||||
Inter_900Black,
|
||||
})
|
||||
|
||||
useNotifications()
|
||||
|
||||
// Wait for persisted store to hydrate from AsyncStorage
|
||||
const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated())
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
// Initialize RevenueCat + PostHog after hydration
|
||||
useEffect(() => {
|
||||
if (hydrated) {
|
||||
initializePurchases().catch((err) => {
|
||||
console.error('Failed to initialize RevenueCat:', err)
|
||||
})
|
||||
initializeAnalytics().catch((err) => {
|
||||
console.error('Failed to initialize PostHog:', err)
|
||||
})
|
||||
}
|
||||
}, [hydrated])
|
||||
|
||||
const onLayoutRootView = useCallback(async () => {
|
||||
if (fontsLoaded && hydrated) {
|
||||
await SplashScreen.hideAsync()
|
||||
}
|
||||
}, [fontsLoaded, hydrated])
|
||||
|
||||
if (!fontsLoaded || !hydrated) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<View style={{ flex: 1, backgroundColor: colors.bg.base }} onLayout={onLayoutRootView}>
|
||||
<StatusBar style={colors.statusBarStyle} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bg.base },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="onboarding"
|
||||
options={{
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="workout/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
|
||||
headerShadowVisible: false,
|
||||
headerTitle: '',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="workout/category/[id]"
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="program/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
|
||||
headerShadowVisible: false,
|
||||
headerTitle: '',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="collection/[id]"
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="assessment"
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="player/[id]"
|
||||
options={{
|
||||
presentation: 'fullScreenModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="complete/[id]"
|
||||
options={{
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="explore-filters"
|
||||
options={{
|
||||
presentation: 'formSheet',
|
||||
headerShown: false,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5],
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const posthogClient = getPostHogClient()
|
||||
|
||||
// Only wrap with PostHogProvider if client is initialized
|
||||
if (!posthogClient) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<PostHogProvider
|
||||
client={posthogClient}
|
||||
autocapture={{
|
||||
captureScreens: true,
|
||||
captureTouches: true,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</PostHogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutInner />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Stack } from 'expo-router'
|
||||
import { AdminAuthProvider, useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
|
||||
import { View, ActivityIndicator } from 'react-native'
|
||||
import { Redirect } from 'expo-router'
|
||||
|
||||
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isAdmin, isLoading } = useAdminAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#FF6B35" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !isAdmin) {
|
||||
return <Redirect href="/admin/login" />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AdminAuthProvider>
|
||||
<AdminLayoutContent>{children}</AdminLayoutContent>
|
||||
</AdminAuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useCollections } from '../../src/shared/hooks/useSupabaseData'
|
||||
import { adminService } from '../../src/admin/services/adminService'
|
||||
import type { Collection } from '../../src/shared/types'
|
||||
|
||||
export default function AdminCollectionsScreen() {
|
||||
const router = useRouter()
|
||||
const { collections, loading, refetch } = useCollections()
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = (collection: Collection) => {
|
||||
Alert.alert(
|
||||
'Delete Collection',
|
||||
`Are you sure you want to delete "${collection.title}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
Alert.alert('Info', 'Collection deletion not yet implemented')
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centered]}>
|
||||
<ActivityIndicator size="large" color="#FF6B35" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Text style={styles.backText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Collections</Text>
|
||||
<TouchableOpacity style={styles.addButton}>
|
||||
<Text style={styles.addButtonText}>+ Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{collections.map((collection) => (
|
||||
<View key={collection.id} style={styles.collectionCard}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Text style={styles.icon}>{collection.icon}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.collectionInfo}>
|
||||
<Text style={styles.collectionTitle}>{collection.title}</Text>
|
||||
<Text style={styles.collectionDescription}>{collection.description}</Text>
|
||||
<Text style={styles.collectionMeta}>
|
||||
{collection.workoutIds.length} workouts
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.editButton}>
|
||||
<Text style={styles.editText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteButton, updatingId === collection.id && styles.disabledButton]}
|
||||
onPress={() => handleDelete(collection)}
|
||||
disabled={updatingId === collection.id}
|
||||
>
|
||||
<Text style={styles.deleteText}>
|
||||
{updatingId === collection.id ? '...' : 'Delete'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
centered: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1C1C1E',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
color: '#FF6B35',
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
collectionCard: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#2C2C2E',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
collectionInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
collectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
collectionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
collectionMeta: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
editButton: {
|
||||
backgroundColor: '#2C2C2E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
editText: {
|
||||
color: '#5AC8FA',
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#2C2C2E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
deleteText: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
})
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
|
||||
import { useWorkouts, useTrainers, useCollections } from '../../src/shared/hooks/useSupabaseData'
|
||||
|
||||
export default function AdminDashboardScreen() {
|
||||
const router = useRouter()
|
||||
const { signOut } = useAdminAuth()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const {
|
||||
workouts,
|
||||
loading: workoutsLoading,
|
||||
refetch: refetchWorkouts
|
||||
} = useWorkouts()
|
||||
|
||||
const {
|
||||
trainers,
|
||||
loading: trainersLoading,
|
||||
refetch: refetchTrainers
|
||||
} = useTrainers()
|
||||
|
||||
const {
|
||||
collections,
|
||||
loading: collectionsLoading,
|
||||
refetch: refetchCollections
|
||||
} = useCollections()
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await Promise.all([
|
||||
refetchWorkouts(),
|
||||
refetchTrainers(),
|
||||
refetchCollections(),
|
||||
])
|
||||
setRefreshing(false)
|
||||
}, [refetchWorkouts, refetchTrainers, refetchCollections])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut()
|
||||
router.replace('/admin/login')
|
||||
}
|
||||
|
||||
const isLoading = workoutsLoading || trainersLoading || collectionsLoading
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Admin Dashboard</Text>
|
||||
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#FF6B35" />
|
||||
}
|
||||
>
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{workouts.length}</Text>
|
||||
<Text style={styles.statLabel}>Workouts</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{trainers.length}</Text>
|
||||
<Text style={styles.statLabel}>Trainers</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{collections.length}</Text>
|
||||
<Text style={styles.statLabel}>Collections</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
|
||||
<View style={styles.actionsGrid}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionCard}
|
||||
onPress={() => router.push('/admin/workouts')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>💪</Text>
|
||||
<Text style={styles.actionTitle}>Manage Workouts</Text>
|
||||
<Text style={styles.actionDescription}>Add, edit, or delete workouts</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionCard}
|
||||
onPress={() => router.push('/admin/trainers')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>👥</Text>
|
||||
<Text style={styles.actionTitle}>Manage Trainers</Text>
|
||||
<Text style={styles.actionDescription}>Update trainer profiles</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionCard}
|
||||
onPress={() => router.push('/admin/collections')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>📁</Text>
|
||||
<Text style={styles.actionTitle}>Manage Collections</Text>
|
||||
<Text style={styles.actionDescription}>Organize workout collections</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionCard}
|
||||
onPress={() => router.push('/admin/media')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>🎬</Text>
|
||||
<Text style={styles.actionTitle}>Media Library</Text>
|
||||
<Text style={styles.actionDescription}>Upload videos and images</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1C1C1E',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 8,
|
||||
},
|
||||
logoutText: {
|
||||
color: '#FF6B35',
|
||||
fontSize: 16,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 32,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#FF6B35',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 16,
|
||||
},
|
||||
actionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
actionCard: {
|
||||
width: '47%',
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionIcon: {
|
||||
fontSize: 32,
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
actionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
|
||||
|
||||
export default function AdminLoginScreen() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const { signIn, isLoading } = useAdminAuth()
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
setError('Please enter both email and password')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
try {
|
||||
await signIn(email, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>TabataFit Admin</Text>
|
||||
<Text style={styles.subtitle}>Sign in to manage content</Text>
|
||||
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
placeholderTextColor="#666"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor="#666"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#000" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 16,
|
||||
padding: 32,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorText: {
|
||||
color: '#FF3B30',
|
||||
marginBottom: 16,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#2C2C2E',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#FF6B35',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#000',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { supabase } from '../../src/shared/supabase'
|
||||
|
||||
export default function AdminMediaScreen() {
|
||||
const router = useRouter()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'videos' | 'thumbnails' | 'avatars'>('videos')
|
||||
|
||||
const handleUpload = async () => {
|
||||
Alert.alert('Info', 'File upload requires file picker integration. This is a placeholder.')
|
||||
}
|
||||
|
||||
const handleDelete = async (path: string) => {
|
||||
Alert.alert(
|
||||
'Delete File',
|
||||
`Are you sure you want to delete "${path}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const { error } = await supabase.storage
|
||||
.from(activeTab)
|
||||
.remove([path])
|
||||
|
||||
if (error) throw error
|
||||
Alert.alert('Success', 'File deleted')
|
||||
} catch (err) {
|
||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Text style={styles.backText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Media Library</Text>
|
||||
<TouchableOpacity style={styles.uploadButton} onPress={handleUpload}>
|
||||
<Text style={styles.uploadButtonText}>Upload</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabs}>
|
||||
{(['videos', 'thumbnails', 'avatars'] as const).map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
style={[styles.tab, activeTab === tab && styles.activeTab]}
|
||||
onPress={() => setActiveTab(tab)}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === tab && styles.activeTabText]}>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>Storage Buckets</Text>
|
||||
<Text style={styles.infoText}>
|
||||
• videos - Workout videos (MP4, MOV){'\n'}
|
||||
• thumbnails - Workout thumbnails (JPG, PNG){'\n'}
|
||||
• avatars - Trainer avatars (JPG, PNG)
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.placeholderCard}>
|
||||
<Text style={styles.placeholderIcon}>🎬</Text>
|
||||
<Text style={styles.placeholderTitle}>Media Management</Text>
|
||||
<Text style={styles.placeholderText}>
|
||||
Upload and manage media files for workouts and trainers.{'\n\n'}
|
||||
This feature requires file picker integration.{'\n'}
|
||||
Files will be stored in Supabase Storage.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1C1C1E',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
color: '#FF6B35',
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
uploadButton: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
uploadButtonText: {
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1C1C1E',
|
||||
alignItems: 'center',
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#FF6B35',
|
||||
},
|
||||
tabText: {
|
||||
color: '#999',
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeTabText: {
|
||||
color: '#000',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
lineHeight: 20,
|
||||
},
|
||||
placeholderCard: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
placeholderTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 8,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
})
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTrainers } from '../../src/shared/hooks/useSupabaseData'
|
||||
import { adminService } from '../../src/admin/services/adminService'
|
||||
import type { Trainer } from '../../src/shared/types'
|
||||
|
||||
export default function AdminTrainersScreen() {
|
||||
const router = useRouter()
|
||||
const { trainers, loading, refetch } = useTrainers()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = (trainer: Trainer) => {
|
||||
Alert.alert(
|
||||
'Delete Trainer',
|
||||
`Are you sure you want to delete "${trainer.name}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setDeletingId(trainer.id)
|
||||
try {
|
||||
await adminService.deleteTrainer(trainer.id)
|
||||
await refetch()
|
||||
} catch (err) {
|
||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centered]}>
|
||||
<ActivityIndicator size="large" color="#FF6B35" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Text style={styles.backText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Trainers</Text>
|
||||
<TouchableOpacity style={styles.addButton}>
|
||||
<Text style={styles.addButtonText}>+ Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{trainers.map((trainer) => (
|
||||
<View key={trainer.id} style={styles.trainerCard}>
|
||||
<View style={[styles.colorIndicator, { backgroundColor: trainer.color }]} />
|
||||
<View style={styles.trainerInfo}>
|
||||
<Text style={styles.trainerName}>{trainer.name}</Text>
|
||||
<Text style={styles.trainerMeta}>
|
||||
{trainer.specialty} • {trainer.workoutCount} workouts
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.editButton}>
|
||||
<Text style={styles.editText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteButton, deletingId === trainer.id && styles.disabledButton]}
|
||||
onPress={() => handleDelete(trainer)}
|
||||
disabled={deletingId === trainer.id}
|
||||
>
|
||||
<Text style={styles.deleteText}>
|
||||
{deletingId === trainer.id ? '...' : 'Delete'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
centered: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1C1C1E',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
color: '#FF6B35',
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
trainerCard: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorIndicator: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 12,
|
||||
},
|
||||
trainerInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
trainerName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
trainerMeta: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
editButton: {
|
||||
backgroundColor: '#2C2C2E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
editText: {
|
||||
color: '#5AC8FA',
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#2C2C2E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
deleteText: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user