clementpep commited on
Commit
2477176
·
1 Parent(s): 9062066

feat: add credits logic

Browse files
backend/app/models/participant.py CHANGED
@@ -69,7 +69,15 @@ class Participant(Base):
69
  default=0,
70
  nullable=False,
71
  index=True,
72
- comment="Current total points accumulated"
 
 
 
 
 
 
 
 
73
  )
74
 
75
  # Pack Inventory (Phase 2 feature, prepared for future use)
@@ -125,7 +133,10 @@ class Participant(Base):
125
 
126
  def add_points(self, amount: int) -> None:
127
  """
128
- Add points to the participant's total.
 
 
 
129
 
130
  Args:
131
  amount: Points to add (positive integer)
@@ -134,24 +145,27 @@ class Participant(Base):
134
  ValueError: If amount is negative
135
  """
136
  if amount < 0:
137
- raise ValueError("Cannot add negative points. Use subtract_points() instead.")
138
  self.total_points += amount
 
139
 
140
- def subtract_points(self, amount: int) -> None:
141
  """
142
- Subtract points from the participant's total.
 
 
143
 
144
  Args:
145
- amount: Points to subtract (positive integer)
146
 
147
  Raises:
148
- ValueError: If amount is negative or would result in negative total
149
  """
150
  if amount < 0:
151
- raise ValueError("Cannot subtract negative points. Use add_points() instead.")
152
- if self.total_points - amount < 0:
153
- raise ValueError("Insufficient points. Cannot have negative total points.")
154
- self.total_points -= amount
155
 
156
  def add_pack(self, pack_tier: str) -> None:
157
  """
 
69
  default=0,
70
  nullable=False,
71
  index=True,
72
+ comment="Current total points accumulated (never decreases)"
73
+ )
74
+
75
+ # Pack Credits - separate from points, used to purchase packs
76
+ pack_credits = Column(
77
+ Integer,
78
+ default=0,
79
+ nullable=False,
80
+ comment="Credits for purchasing packs (1 point earned = 1 credit)"
81
  )
82
 
83
  # Pack Inventory (Phase 2 feature, prepared for future use)
 
133
 
134
  def add_points(self, amount: int) -> None:
135
  """
136
+ Add points to the participant's total AND pack credits.
137
+
138
+ Points are used for leaderboard ranking and never decrease.
139
+ Pack credits are also added (1:1 ratio) and can be spent on packs.
140
 
141
  Args:
142
  amount: Points to add (positive integer)
 
145
  ValueError: If amount is negative
146
  """
147
  if amount < 0:
148
+ raise ValueError("Cannot add negative points.")
149
  self.total_points += amount
150
+ self.pack_credits += amount
151
 
152
+ def subtract_credits(self, amount: int) -> None:
153
  """
154
+ Subtract pack credits (used for purchasing packs).
155
+
156
+ Note: This does NOT affect total_points, which never decrease.
157
 
158
  Args:
159
+ amount: Credits to subtract (positive integer)
160
 
161
  Raises:
162
+ ValueError: If amount is negative or insufficient credits
163
  """
164
  if amount < 0:
165
+ raise ValueError("Cannot subtract negative credits.")
166
+ if self.pack_credits < amount:
167
+ raise ValueError(f"Insufficient credits. Need {amount}, have {self.pack_credits}")
168
+ self.pack_credits -= amount
169
 
170
  def add_pack(self, pack_tier: str) -> None:
171
  """
backend/app/schemas/participant.py CHANGED
@@ -104,7 +104,8 @@ class ParticipantResponse(ParticipantBase):
104
  Used in GET requests and as part of other responses.
105
  """
106
  id: int = Field(..., description="Unique participant ID")
107
- total_points: int = Field(..., description="Current total points")
 
108
  current_packs: dict = Field(
109
  ...,
110
  description="Pack counts for each tier (bronze, silver, gold, ultimate)"
@@ -122,6 +123,7 @@ class ParticipantResponse(ParticipantBase):
122
  "avatar_url": "https://example.com/avatars/paul.jpg",
123
  "is_groom": True,
124
  "total_points": 350,
 
125
  "current_packs": {
126
  "bronze": 2,
127
  "silver": 1,
@@ -145,6 +147,7 @@ class ParticipantSummary(BaseModel):
145
  avatar_url: Optional[str] = None
146
  is_groom: bool
147
  total_points: int
 
148
 
149
  class Config:
150
  """Pydantic configuration."""
@@ -155,7 +158,8 @@ class ParticipantSummary(BaseModel):
155
  "name": "Paul C.",
156
  "avatar_url": "https://example.com/avatars/paul.jpg",
157
  "is_groom": True,
158
- "total_points": 350
 
159
  }
160
  }
161
 
@@ -182,6 +186,7 @@ class ParticipantWithRank(ParticipantSummary):
182
  "avatar_url": "https://example.com/avatars/paul.jpg",
183
  "is_groom": True,
184
  "total_points": 350,
 
185
  "rank": 1,
186
  "points_today": 75
187
  }
 
104
  Used in GET requests and as part of other responses.
105
  """
106
  id: int = Field(..., description="Unique participant ID")
107
+ total_points: int = Field(..., description="Current total points (never decreases)")
108
+ pack_credits: int = Field(..., description="Credits available for purchasing packs")
109
  current_packs: dict = Field(
110
  ...,
111
  description="Pack counts for each tier (bronze, silver, gold, ultimate)"
 
123
  "avatar_url": "https://example.com/avatars/paul.jpg",
124
  "is_groom": True,
125
  "total_points": 350,
126
+ "pack_credits": 150,
127
  "current_packs": {
128
  "bronze": 2,
129
  "silver": 1,
 
147
  avatar_url: Optional[str] = None
148
  is_groom: bool
149
  total_points: int
150
+ pack_credits: int
151
 
152
  class Config:
153
  """Pydantic configuration."""
 
158
  "name": "Paul C.",
159
  "avatar_url": "https://example.com/avatars/paul.jpg",
160
  "is_groom": True,
161
+ "total_points": 350,
162
+ "pack_credits": 150
163
  }
164
  }
165
 
 
186
  "avatar_url": "https://example.com/avatars/paul.jpg",
187
  "is_groom": True,
188
  "total_points": 350,
189
+ "pack_credits": 150,
190
  "rank": 1,
191
  "points_today": 75
192
  }
backend/app/services/leaderboard_service.py CHANGED
@@ -47,6 +47,7 @@ def get_leaderboard(db: Session, include_today_points: bool = False) -> List[Par
47
  avatar_url=participant.avatar_url,
48
  is_groom=participant.is_groom,
49
  total_points=participant.total_points,
 
50
  rank=rank,
51
  points_today=points_today
52
  )
@@ -153,6 +154,7 @@ def get_daily_leader(db: Session) -> ParticipantWithRank:
153
  avatar_url=participant.avatar_url,
154
  is_groom=participant.is_groom,
155
  total_points=participant.total_points,
 
156
  rank=get_participant_rank(db, participant.id),
157
  points_today=leader_data["points_today"]
158
  )
 
47
  avatar_url=participant.avatar_url,
48
  is_groom=participant.is_groom,
49
  total_points=participant.total_points,
50
+ pack_credits=participant.pack_credits,
51
  rank=rank,
52
  points_today=points_today
53
  )
 
154
  avatar_url=participant.avatar_url,
155
  is_groom=participant.is_groom,
156
  total_points=participant.total_points,
157
+ pack_credits=participant.pack_credits,
158
  rank=get_participant_rank(db, participant.id),
159
  points_today=leader_data["points_today"]
160
  )
backend/app/services/pack_service.py CHANGED
@@ -330,9 +330,10 @@ def _get_animation_effects(rarity: str) -> list[str]:
330
 
331
  def purchase_pack(db: Session, participant_id: int, tier: str) -> None:
332
  """
333
- Purchase a pack using points.
334
 
335
- Deducts points from participant and adds pack to inventory.
 
336
 
337
  Args:
338
  db: Database session
@@ -340,11 +341,11 @@ def purchase_pack(db: Session, participant_id: int, tier: str) -> None:
340
  tier: Pack tier to purchase (bronze/silver/gold/ultimate)
341
 
342
  Raises:
343
- ValueError: If participant not found, invalid tier, or insufficient points
344
 
345
  Example:
346
  >>> purchase_pack(db, 1, "bronze")
347
- >>> # Deducts 100 points and adds 1 bronze pack
348
  """
349
  participant = db.query(Participant).filter(Participant.id == participant_id).first()
350
 
@@ -356,19 +357,19 @@ def purchase_pack(db: Session, participant_id: int, tier: str) -> None:
356
 
357
  cost = PACK_COSTS[tier]
358
 
359
- # Check if participant has enough points
360
- if participant.total_points < cost:
361
- raise ValueError(f"Insufficient points. Need {cost} points, have {participant.total_points}")
362
 
363
- # Deduct points
364
- participant.subtract_points(cost)
365
 
366
  # Add pack to inventory
367
  participant.add_pack(tier)
368
 
369
  db.commit()
370
 
371
- logger.info(f"Participant {participant_id} purchased {tier} pack for {cost} points")
372
 
373
 
374
  def add_free_pack(db: Session, participant_id: int, tier: str, count: int = 1) -> None:
 
330
 
331
  def purchase_pack(db: Session, participant_id: int, tier: str) -> None:
332
  """
333
+ Purchase a pack using pack credits.
334
 
335
+ Deducts credits from participant and adds pack to inventory.
336
+ Note: This does NOT affect total_points, which never decrease.
337
 
338
  Args:
339
  db: Database session
 
341
  tier: Pack tier to purchase (bronze/silver/gold/ultimate)
342
 
343
  Raises:
344
+ ValueError: If participant not found, invalid tier, or insufficient credits
345
 
346
  Example:
347
  >>> purchase_pack(db, 1, "bronze")
348
+ >>> # Deducts 100 credits and adds 1 bronze pack
349
  """
350
  participant = db.query(Participant).filter(Participant.id == participant_id).first()
351
 
 
357
 
358
  cost = PACK_COSTS[tier]
359
 
360
+ # Check if participant has enough credits
361
+ if participant.pack_credits < cost:
362
+ raise ValueError(f"Insufficient credits. Need {cost} credits, have {participant.pack_credits}")
363
 
364
+ # Deduct credits (NOT points - points never decrease)
365
+ participant.subtract_credits(cost)
366
 
367
  # Add pack to inventory
368
  participant.add_pack(tier)
369
 
370
  db.commit()
371
 
372
+ logger.info(f"Participant {participant_id} purchased {tier} pack for {cost} credits")
373
 
374
 
375
  def add_free_pack(db: Session, participant_id: int, tier: str, count: int = 1) -> None:
backend/scripts/add_pack_credits.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Add pack_credits column to participants table.
3
+
4
+ This migration adds a new column to track credits used for purchasing packs,
5
+ separate from the points used for leaderboard ranking.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+
11
+ # Add parent directory to path
12
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
13
+
14
+ from app.database import engine
15
+ from sqlalchemy import text
16
+
17
+
18
+ def add_pack_credits_column():
19
+ """Add pack_credits column to participants table."""
20
+ with engine.connect() as connection:
21
+ # Check if column already exists
22
+ result = connection.execute(text(
23
+ "PRAGMA table_info(participants)"
24
+ ))
25
+
26
+ columns = [row[1] for row in result]
27
+
28
+ if 'pack_credits' in columns:
29
+ print("[OK] Column 'pack_credits' already exists")
30
+ return
31
+
32
+ # Add the column with default value equal to total_points
33
+ # This gives existing participants credits based on their current points
34
+ connection.execute(text(
35
+ "ALTER TABLE participants ADD COLUMN pack_credits INTEGER DEFAULT 0 NOT NULL"
36
+ ))
37
+
38
+ # Set pack_credits to total_points for existing participants
39
+ connection.execute(text(
40
+ "UPDATE participants SET pack_credits = total_points"
41
+ ))
42
+
43
+ connection.commit()
44
+
45
+ print("[OK] Added column 'pack_credits' to participants table")
46
+ print("[OK] Initialized pack_credits = total_points for existing participants")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ print("Adding pack_credits column to participants table...")
51
+ add_pack_credits_column()
52
+ print("\nMigration completed successfully!")
frontend/src/components/packs/PackCard.tsx CHANGED
@@ -11,14 +11,14 @@ interface PackCardProps {
11
  tier: PackTier;
12
  count: number;
13
  canOpen: boolean;
14
- userPoints: number;
15
  onOpen: () => void;
16
  onPurchase: () => void;
17
  }
18
 
19
- export const PackCard: React.FC<PackCardProps> = ({ tier, count, canOpen, userPoints, onOpen, onPurchase }) => {
20
  const config = PACK_CONFIG[tier];
21
- const canPurchase = userPoints >= config.cost;
22
 
23
  return (
24
  <motion.div
@@ -71,7 +71,7 @@ export const PackCard: React.FC<PackCardProps> = ({ tier, count, canOpen, userPo
71
 
72
  {/* Cost/Action */}
73
  <div className="text-text-tertiary text-xs sm:text-sm mb-3 sm:mb-4">
74
- <span className="font-semibold">{config.cost} pts</span>
75
  </div>
76
 
77
  {/* Buttons */}
@@ -82,19 +82,19 @@ export const PackCard: React.FC<PackCardProps> = ({ tier, count, canOpen, userPo
82
  </button>
83
  ) : canPurchase ? (
84
  <button className="btn-secondary w-full" onClick={(e) => { e.stopPropagation(); onPurchase(); }}>
85
- ACHETER ({config.cost} pts)
86
  </button>
87
  ) : (
88
  <button
89
  className="btn-secondary w-full opacity-40 cursor-not-allowed"
90
  disabled
91
  >
92
- ACHETER ({config.cost} pts)
93
  </button>
94
  )}
95
  {canPurchase && !canOpen && (
96
  <div className="text-xs text-fifa-green">
97
- ✓ {userPoints} pts disponibles
98
  </div>
99
  )}
100
  </div>
 
11
  tier: PackTier;
12
  count: number;
13
  canOpen: boolean;
14
+ userCredits: number;
15
  onOpen: () => void;
16
  onPurchase: () => void;
17
  }
18
 
19
+ export const PackCard: React.FC<PackCardProps> = ({ tier, count, canOpen, userCredits, onOpen, onPurchase }) => {
20
  const config = PACK_CONFIG[tier];
21
+ const canPurchase = userCredits >= config.cost;
22
 
23
  return (
24
  <motion.div
 
71
 
72
  {/* Cost/Action */}
73
  <div className="text-text-tertiary text-xs sm:text-sm mb-3 sm:mb-4">
74
+ <span className="font-semibold">{config.cost} crédits</span>
75
  </div>
76
 
77
  {/* Buttons */}
 
82
  </button>
83
  ) : canPurchase ? (
84
  <button className="btn-secondary w-full" onClick={(e) => { e.stopPropagation(); onPurchase(); }}>
85
+ ACHETER ({config.cost} crédits)
86
  </button>
87
  ) : (
88
  <button
89
  className="btn-secondary w-full opacity-40 cursor-not-allowed"
90
  disabled
91
  >
92
+ ACHETER ({config.cost} crédits)
93
  </button>
94
  )}
95
  {canPurchase && !canOpen && (
96
  <div className="text-xs text-fifa-green">
97
+ ✓ {userCredits} crédits disponibles
98
  </div>
99
  )}
100
  </div>
frontend/src/pages/PacksPage.tsx CHANGED
@@ -19,20 +19,22 @@ export const PacksPage = () => {
19
  const [selectedTier, setSelectedTier] = useState<PackTier | null>(null);
20
  const [openResult, setOpenResult] = useState<PackOpenResult | null>(null);
21
  const [showModal, setShowModal] = useState(false);
 
22
  const [userPoints, setUserPoints] = useState<number>(0);
23
  const hasShownWelcome = useRef(false);
24
 
25
- // Fetch user points
26
  useEffect(() => {
27
- const fetchPoints = async () => {
28
  try {
29
  const profile = await getMyProfile();
 
30
  setUserPoints(profile.total_points);
31
  } catch (err) {
32
- console.error('Failed to fetch user points:', err);
33
  }
34
  };
35
- fetchPoints();
36
  }, [inventory]); // Refresh when inventory changes
37
 
38
  // Show welcome pack notification on first load if user has exactly 1 silver pack
@@ -87,6 +89,25 @@ export const PacksPage = () => {
87
  <p className="text-text-secondary text-lg">
88
  Ouvre tes packs pour obtenir des récompenses exclusives !
89
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </div>
91
 
92
  {/* Error Display */}
@@ -156,7 +177,7 @@ export const PacksPage = () => {
156
  tier="bronze"
157
  count={inventory?.bronze || 0}
158
  canOpen={(inventory?.bronze || 0) > 0 && !isOpening}
159
- userPoints={userPoints}
160
  onOpen={() => handleOpenPack('bronze')}
161
  onPurchase={() => handlePurchasePack('bronze')}
162
  />
@@ -164,7 +185,7 @@ export const PacksPage = () => {
164
  tier="silver"
165
  count={inventory?.silver || 0}
166
  canOpen={(inventory?.silver || 0) > 0 && !isOpening}
167
- userPoints={userPoints}
168
  onOpen={() => handleOpenPack('silver')}
169
  onPurchase={() => handlePurchasePack('silver')}
170
  />
@@ -172,7 +193,7 @@ export const PacksPage = () => {
172
  tier="gold"
173
  count={inventory?.gold || 0}
174
  canOpen={(inventory?.gold || 0) > 0 && !isOpening}
175
- userPoints={userPoints}
176
  onOpen={() => handleOpenPack('gold')}
177
  onPurchase={() => handlePurchasePack('gold')}
178
  />
@@ -180,7 +201,7 @@ export const PacksPage = () => {
180
  tier="ultimate"
181
  count={inventory?.ultimate || 0}
182
  canOpen={(inventory?.ultimate || 0) > 0 && !isOpening}
183
- userPoints={userPoints}
184
  onOpen={() => handleOpenPack('ultimate')}
185
  onPurchase={() => handlePurchasePack('ultimate')}
186
  />
@@ -200,7 +221,8 @@ export const PacksPage = () => {
200
  </h3>
201
  <ul className="text-text-secondary text-left space-y-2">
202
  <li>🎁 <strong>Packs gratuits :</strong> 2x par jour (9h et 18h)</li>
203
- <li>⭐ <strong>Acheter avec des points :</strong> Bronze (100), Silver (200), Gold (300), Ultimate (500)</li>
 
204
  <li>🏆 <strong>Récompenses :</strong> Shots, immunités, pouvoirs spéciaux et plus !</li>
205
  </ul>
206
  </div>
 
19
  const [selectedTier, setSelectedTier] = useState<PackTier | null>(null);
20
  const [openResult, setOpenResult] = useState<PackOpenResult | null>(null);
21
  const [showModal, setShowModal] = useState(false);
22
+ const [userCredits, setUserCredits] = useState<number>(0);
23
  const [userPoints, setUserPoints] = useState<number>(0);
24
  const hasShownWelcome = useRef(false);
25
 
26
+ // Fetch user credits and points
27
  useEffect(() => {
28
+ const fetchProfile = async () => {
29
  try {
30
  const profile = await getMyProfile();
31
+ setUserCredits(profile.pack_credits);
32
  setUserPoints(profile.total_points);
33
  } catch (err) {
34
+ console.error('Failed to fetch user profile:', err);
35
  }
36
  };
37
+ fetchProfile();
38
  }, [inventory]); // Refresh when inventory changes
39
 
40
  // Show welcome pack notification on first load if user has exactly 1 silver pack
 
89
  <p className="text-text-secondary text-lg">
90
  Ouvre tes packs pour obtenir des récompenses exclusives !
91
  </p>
92
+ {/* Display Credits and Points */}
93
+ <div className="mt-6 flex justify-center gap-6">
94
+ <div className="inline-flex items-center gap-2 px-6 py-3 rounded-xl border" style={{
95
+ background: 'rgba(26, 41, 66, 0.75)',
96
+ borderColor: 'rgba(255, 255, 255, 0.1)',
97
+ backdropFilter: 'blur(10px)',
98
+ }}>
99
+ <span className="text-text-secondary text-sm uppercase tracking-wide">Crédits:</span>
100
+ <span className="font-numbers text-2xl font-bold text-fifa-gold">{userCredits}</span>
101
+ </div>
102
+ <div className="inline-flex items-center gap-2 px-6 py-3 rounded-xl border" style={{
103
+ background: 'rgba(26, 41, 66, 0.75)',
104
+ borderColor: 'rgba(255, 255, 255, 0.1)',
105
+ backdropFilter: 'blur(10px)',
106
+ }}>
107
+ <span className="text-text-secondary text-sm uppercase tracking-wide">Points:</span>
108
+ <span className="font-numbers text-2xl font-bold text-fifa-green">{userPoints}</span>
109
+ </div>
110
+ </div>
111
  </div>
112
 
113
  {/* Error Display */}
 
177
  tier="bronze"
178
  count={inventory?.bronze || 0}
179
  canOpen={(inventory?.bronze || 0) > 0 && !isOpening}
180
+ userCredits={userCredits}
181
  onOpen={() => handleOpenPack('bronze')}
182
  onPurchase={() => handlePurchasePack('bronze')}
183
  />
 
185
  tier="silver"
186
  count={inventory?.silver || 0}
187
  canOpen={(inventory?.silver || 0) > 0 && !isOpening}
188
+ userCredits={userCredits}
189
  onOpen={() => handleOpenPack('silver')}
190
  onPurchase={() => handlePurchasePack('silver')}
191
  />
 
193
  tier="gold"
194
  count={inventory?.gold || 0}
195
  canOpen={(inventory?.gold || 0) > 0 && !isOpening}
196
+ userCredits={userCredits}
197
  onOpen={() => handleOpenPack('gold')}
198
  onPurchase={() => handlePurchasePack('gold')}
199
  />
 
201
  tier="ultimate"
202
  count={inventory?.ultimate || 0}
203
  canOpen={(inventory?.ultimate || 0) > 0 && !isOpening}
204
+ userCredits={userCredits}
205
  onOpen={() => handleOpenPack('ultimate')}
206
  onPurchase={() => handlePurchasePack('ultimate')}
207
  />
 
221
  </h3>
222
  <ul className="text-text-secondary text-left space-y-2">
223
  <li>🎁 <strong>Packs gratuits :</strong> 2x par jour (9h et 18h)</li>
224
+ <li>⭐ <strong>Acheter avec des crédits :</strong> Bronze (100), Silver (200), Gold (300), Ultimate (500)</li>
225
+ <li>💰 <strong>Crédits :</strong> Gagne 1 crédit par point marqué. Les points restent, les crédits s'utilisent !</li>
226
  <li>🏆 <strong>Récompenses :</strong> Shots, immunités, pouvoirs spéciaux et plus !</li>
227
  </ul>
228
  </div>
frontend/src/types/participant.ts CHANGED
@@ -10,6 +10,7 @@ export interface Participant {
10
  avatar_url: string | null;
11
  is_groom: boolean;
12
  total_points: number;
 
13
  current_packs: PackInventory;
14
  created_at: string;
15
  updated_at: string;
@@ -28,6 +29,7 @@ export interface ParticipantSummary {
28
  avatar_url: string | null;
29
  is_groom: boolean;
30
  total_points: number;
 
31
  }
32
 
33
  export interface ParticipantWithRank extends ParticipantSummary {
 
10
  avatar_url: string | null;
11
  is_groom: boolean;
12
  total_points: number;
13
+ pack_credits: number;
14
  current_packs: PackInventory;
15
  created_at: string;
16
  updated_at: string;
 
29
  avatar_url: string | null;
30
  is_groom: boolean;
31
  total_points: number;
32
+ pack_credits: number;
33
  }
34
 
35
  export interface ParticipantWithRank extends ParticipantSummary {